diff --git a/.config/LICENSE b/.config/LICENSE new file mode 100644 index 0000000..2e3e984 --- /dev/null +++ b/.config/LICENSE @@ -0,0 +1,14 @@ +Copyright 2023 The Iceshrimp contributors +Copyright 2023 The Firefish contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/.config/devenv.yml b/.config/devenv.yml new file mode 100644 index 0000000..59907a3 --- /dev/null +++ b/.config/devenv.yml @@ -0,0 +1,29 @@ +url: http://localhost:3000 +port: 3000 + +db: + host: 127.0.0.1 + port: 5432 + + db: iceshrimp + + user: iceshrimp + pass: iceshrimp + +redis: + host: localhost + port: 6379 + family: 4 +#sonic: +# host: localhost +# port: 1491 +# auth: SecretPassword +# collection: notes +# bucket: default + +reservedUsernames: + - root + - admin + - administrator + - me + - system diff --git a/.config/docker_example.env b/.config/docker_example.env new file mode 100644 index 0000000..cf105b1 --- /dev/null +++ b/.config/docker_example.env @@ -0,0 +1,4 @@ +# db settings +POSTGRES_PASSWORD=example-iceshrimp-pass +POSTGRES_USER=example-iceshrimp-user +POSTGRES_DB=iceshrimp diff --git a/.config/example-docker.yml b/.config/example-docker.yml new file mode 100644 index 0000000..58ca01d --- /dev/null +++ b/.config/example-docker.yml @@ -0,0 +1,263 @@ +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# Iceshrimp configuration +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +# After starting your server, please don't change the URL! Doing so will break federation. + +# ┌─────┐ +#───┘ URL └───────────────────────────────────────────────────── + +# Final accessible URL seen by a user. +url: https://example.org/ + +# (Optional - ADVANCED) Domain used for account handles. +# Only uncomment this if you want to for example have the URL be ice.example.org +# and the handles to be example.org +# accountDomain: example.org + +# ┌───────────────────────┐ +#───┘ Port and TLS settings └─────────────────────────────────── + +# +# Iceshrimp requires a reverse proxy to support HTTPS connections. +# +# +----- https://example.com/ ------------+ +# +------+ |+-------------+ +----------------+| +# | User | ---> || Proxy (443) | ---> | Iceshrimp (3000) || +# +------+ |+-------------+ +----------------+| +# +---------------------------------------+ +# +# You need to set up a reverse proxy. (e.g. nginx, caddy) +# An encrypted connection with HTTPS is highly recommended +# because tokens may be transferred in GET requests. + +# The port that your Iceshrimp server should listen on. +port: 3000 + +# ┌──────────────────────────┐ +#───┘ PostgreSQL configuration └──────────────────────────────── + +db: + host: db + port: 5432 + #ssl: false + # Database name + db: iceshrimp + + # Auth + user: example-iceshrimp-user + pass: example-iceshrimp-pass + + # Whether disable Caching queries + #disableCache: true + + # Extra Connection options + #extra: + # ssl: + # host: localhost + # rejectUnauthorized: false + +# ┌─────────────────────┐ +#───┘ Redis configuration └───────────────────────────────────── + +redis: + host: redis + port: 6379 + #tls: + # host: localhost + # rejectUnauthorized: false + #family: 0 # 0=Both, 4=IPv4, 6=IPv6 + #pass: example-pass + #prefix: example-prefix + #db: 1 + #user: default + + +# ┌───────────────┐ +#───┘ ID generation └─────────────────────────────────────────── + +# No need to uncomment in most cases, but you may want to change +# these settings if you plan to run a large and/or distributed server. + +# cuid: +# # Min 16, Max 24 +# length: 16 +# +# # Set this to a unique string across workers (e.g., machine's hostname) +# # if your workers are running in multiple hosts. +# fingerprint: my-fingerprint + + +# ┌─────────────────────┐ +#───┘ Other configuration └───────────────────────────────────── + +# Maximum length of a post (default 3000, max 100000) +#maxNoteLength: 3000 + +# Maximum length of an image caption (default 1500, max 8192) +#maxCaptionLength: 1500 + +# Reserved usernames that only the administrator can register with +reservedUsernames: [ + 'root', + 'admin', + 'administrator', + 'me', + 'system' +] + +# Whether disable HSTS +#disableHsts: true + +# Number of worker processes +#clusterLimit: 1 + +# Worker only mode +#onlyQueueProcessor: 1 + +# Job concurrency per worker +# deliverJobConcurrency: 128 +# inboxJobConcurrency: 16 + +# Job rate limiter +# deliverJobPerSec: 128 +# inboxJobPerSec: 16 + +# Job attempts +# deliverJobMaxAttempts: 12 +# inboxJobMaxAttempts: 8 + +# IP address family used for outgoing request (ipv4, ipv6 or dual) +#outgoingAddressFamily: ipv4 + +# Syslog option +#syslog: +# host: localhost +# port: 514 + +# Proxy for HTTP/HTTPS +#proxy: http://127.0.0.1:3128 + +#proxyBypassHosts: [ +# 'web.kaiteki.app', +# 'example.com', +# '192.0.2.8' +#] + +# Proxy for SMTP/SMTPS +#proxySmtp: http://127.0.0.1:3128 # use HTTP/1.1 CONNECT +#proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 +#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 + +# Media Proxy +#mediaProxy: https://example.com/proxy + +# Proxy remote files (default: false) +#proxyRemoteFiles: true + +# Media cleanup settings (defaults: false, 0, false, false) +#mediaCleanup: +# cron: true +# maxAgeDays: 30 +# cleanAvatars: false +# cleanHeaders: false + +# Status code images +#images: +# info: '/twemoji/1f440.svg' +# notFound: '/twemoji/2049.svg' +# error: '/twemoji/1f480.svg' + +# Search engine (MFM) +#searchEngine: 'https://duckduckgo.com/?q=' + +#allowedPrivateNetworks: [ +# '127.0.0.1/32' +#] + +# TWA +#twa: +# nameSpace: android_app +# packageName: tld.domain.twa +# sha256CertFingerprints: ['AB:CD:EF'] + +# Upload or download file size limits (bytes) +#maxFileSize: 262144000 + +# ┌────────────────────────────────┐ +#───┘ Mastodon client API HTML Cache └────────────────────────── +# Caution: rendered post html content is stored in redis (in-memory cache) +# for the duration of ttl, so don't set it too high if you have little system memory. +# +# The prewarm option causes every incoming user/note create/update event to +# be rendered so the cache is always "warm". This trades background cpu load for +# better request response time and better scaling, as posts won't have to be rendered +# on request. +# +# The dbFallback option stores html data that expires into postgres, +# which is more expensive than fetching it from redis, +# but cheaper than re-rendering the HTML. + +#htmlCache: +# ttl: 1h +# prewarm: false +# dbFallback: false + +# Duration hard muted notes are stored in redis for. +# Increasing this trades higher memory consumption for lower cpu usage on repeated requests within the specified ttl. +#wordMuteCache: +# ttl: 24h + +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# Congrats, you've reached the end of the config file needed for most deployments! +# Enjoy your Iceshrimp server! +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# Managed hosting settings +# >>> NORMAL SELF-HOSTERS, STAY AWAY! <<< +# >>> YOU DON'T NEED THIS! <<< +# Each category is optional, but if each item in each category is mandatory! +# If you mess this up, that's on you, you've been warned... +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#maxUserSignups: 100 +#isManagedHosting: true +#deepl: +# managed: true +# authKey: '' +# isPro: false +# +#email: +# managed: true +# address: 'example@email.com' +# host: 'email.com' +# port: 587 +# user: 'example@email.com' +# pass: '' +# useImplicitSslTls: false +# +#objectStorage: +# managed: true +# baseUrl: '' +# bucket: '' +# prefix: '' +# endpoint: '' +# region: '' +# accessKey: '' +# secretKey: '' +# useSsl: true +# connnectOverProxy: false +# setPublicReadOnUpload: true +# s3ForcePathStyle: true + +# !!!!!!!!!! +# >>>>>> AGAIN, NORMAL SELF-HOSTERS, STAY AWAY! <<<<<< +# >>>>>> YOU DON'T NEED THIS, ABOVE SETTINGS ARE FOR MANAGED HOSTING ONLY! <<<<<< +# !!!!!!!!!! + +# Seriously. Do NOT fill out the above settings if you're self-hosting. +# They're much better off being set from the control panel. diff --git a/.config/example.yml b/.config/example.yml new file mode 100644 index 0000000..274305d --- /dev/null +++ b/.config/example.yml @@ -0,0 +1,283 @@ +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# Iceshrimp configuration +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +# After starting your server, please don't change the URL! Doing so will break federation. + +# ┌─────┐ +#───┘ URL └───────────────────────────────────────────────────── + +# Final accessible URL seen by a user. +url: http://100.87.21.24:3000/ + +# (Optional - ADVANCED) Domain used for account handles. +# Only uncomment this if you want to for example have the URL be ice.example.org +# and the handles to be example.org +# accountDomain: example.org + +# ┌───────────────────────┐ +#───┘ Port and TLS settings └─────────────────────────────────── + +# +# Iceshrimp requires a reverse proxy to support HTTPS connections. +# +# +------- https://example.com/ ------------+ +# +------+ |+-------------+ +------------------+| +# | User | ---> || Proxy (443) | ---> | Iceshrimp (3000) || +# +------+ |+-------------+ +------------------+| +# +-----------------------------------------+ +# +# You need to set up a reverse proxy. (e.g. nginx, caddy) +# An encrypted connection with HTTPS is highly recommended +# because tokens may be transferred in GET requests. + +# The port that your Iceshrimp server should listen on. +port: 3000 + +# The local address that your Iceshrimp server should bind to. +listen: 0.0.0.0 + +# Local HTTPS certificate files. +# tls: +# keyPath: .config/tls/localhost.key +# certPath: .config/tls/localhost.crt + +# ┌──────────────────────────┐ +#───┘ PostgreSQL configuration └──────────────────────────────── + +db: + host: localhost + port: 5432 + #ssl: false + # Database name + db: iceshrimp + + # Auth + user: postgres + pass: a + + # Whether disable Caching queries + #disableCache: true + + # Extra Connection options + #extra: + # ssl: + # host: localhost + # rejectUnauthorized: false + + # You can enable different different logging levels by setting the value of logging to any of the values listed below + # * 'error' - logs all failed queries and errors + # * 'slow' - logs slow queries + # * 'query' - logs all queries + # * 'schema' - logs the schema build process + # * 'info' - logs internal orm informative messages + # * 'log' - logs internal orm log messages + # You can set multiple log level by specifying them as an array i.e ['log', 'info'] + # You can set disable all log levels by specifying an empty array: [] + # You can set enable all log levels by specifying the special value: 'all' + logging: ['error', 'slow'] + +# ┌─────────────────────┐ +#───┘ Redis configuration └───────────────────────────────────── + +redis: + host: localhost + port: 6379 + #tls: + # host: localhost + # rejectUnauthorized: false + #family: 0 # 0=Both, 4=IPv4, 6=IPv6 + #pass: example-pass + #prefix: example-prefix + #db: 1 + #user: default + + +# ┌───────────────┐ +#───┘ ID generation └─────────────────────────────────────────── + +# No need to uncomment in most cases, but you may want to change +# these settings if you plan to run a large and/or distributed server. + +# cuid: +# # Min 16, Max 24 +# length: 16 +# +# # Set this to a unique string across workers (e.g., machine's hostname) +# # if your workers are running in multiple hosts. +# fingerprint: my-fingerprint + + +# ┌─────────────────────┐ +#───┘ Other configuration └───────────────────────────────────── + +# Maximum length of a post (default 3000, max 100000) +#maxNoteLength: 3000 + +# Maximum length of an image caption (default 1500, max 8192) +#maxCaptionLength: 1500 + +# Reserved usernames that only the administrator can register with +reservedUsernames: [ + 'root', + 'admin', + 'administrator', + 'me', + 'system' +] + +# Whether disable HSTS +disableHsts: true + +# Number of worker processes +#clusterLimit: 1 + +# Worker only mode +#onlyQueueProcessor: 1 + +# Job concurrency per worker +# deliverJobConcurrency: 128 +# inboxJobConcurrency: 16 + +# Job rate limiter +# deliverJobPerSec: 128 +# inboxJobPerSec: 16 + +# Job attempts +# deliverJobMaxAttempts: 12 +# inboxJobMaxAttempts: 8 + +# IP address family used for outgoing request (ipv4, ipv6 or dual) +#outgoingAddressFamily: ipv4 + +# Syslog option +#syslog: +# host: localhost +# port: 514 + +# Proxy for HTTP/HTTPS +#proxy: http://127.0.0.1:3128 + +#proxyBypassHosts: [ +# 'web.kaiteki.app', +# 'example.com', +# '192.0.2.8' +#] + +# Proxy for SMTP/SMTPS +#proxySmtp: http://127.0.0.1:3128 # use HTTP/1.1 CONNECT +#proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 +#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 + +# Media Proxy +#mediaProxy: https://example.com/proxy + +# Proxy remote files (default: false) +#proxyRemoteFiles: true + +# Media cleanup settings (defaults: false, 0, false, false) +#mediaCleanup: +# cron: true +# maxAgeDays: 30 +# cleanAvatars: false +# cleanHeaders: false + +# Status code images +#images: +# info: '/twemoji/1f440.svg' +# notFound: '/twemoji/2049.svg' +# error: '/twemoji/1f480.svg' + +# Search engine (MFM) +#searchEngine: 'https://duckduckgo.com/?q=' + +#allowedPrivateNetworks: [ +# '127.0.0.1/32' +#] + +# TWA +#twa: +# nameSpace: android_app +# packageName: tld.domain.twa +# sha256CertFingerprints: ['AB:CD:EF'] + +# Upload or download file size limits (bytes) +#maxFileSize: 262144000 + +# ┌────────────────────────────────┐ +#───┘ Mastodon client API HTML Cache └────────────────────────── +# Caution: rendered post html content is stored in redis (in-memory cache) +# for the duration of ttl, so don't set it too high if you have little system memory. +# +# The prewarm option causes every incoming user/note create/update event to +# be rendered so the cache is always "warm". This trades background cpu load for +# better request response time and better scaling, as posts won't have to be rendered +# on request. +# +# The dbFallback option stores html data that expires into postgres, +# which is more expensive than fetching it from redis, +# but cheaper than re-rendering the HTML. + +#htmlCache: +# ttl: 1h +# prewarm: false +# dbFallback: false + +# Duration hard muted notes are stored in redis for. +# Increasing this trades higher memory consumption for lower cpu usage on repeated requests within the specified ttl. +#wordMuteCache: +# ttl: 24h + +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# Congrats, you've reached the end of the config file needed for most deployments! +# Enjoy your Iceshrimp server! +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + + + + +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# Managed hosting settings +# >>> NORMAL SELF-HOSTERS, STAY AWAY! <<< +# >>> YOU DON'T NEED THIS! <<< +# Each category is optional, but if each item in each category is mandatory! +# If you mess this up, that's on you, you've been warned... +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +#maxUserSignups: 100 +#isManagedHosting: true +#deepl: +# managed: true +# authKey: '' +# isPro: false +# +#email: +# managed: true +# address: 'example@email.com' +# host: 'email.com' +# port: 587 +# user: 'example@email.com' +# pass: '' +# useImplicitSslTls: false +# +#objectStorage: +# managed: true +# baseUrl: '' +# bucket: '' +# prefix: '' +# endpoint: '' +# region: '' +# accessKey: '' +# secretKey: '' +# useSsl: true +# connnectOverProxy: false +# setPublicReadOnUpload: true +# s3ForcePathStyle: true + +# !!!!!!!!!! +# >>>>>> AGAIN, NORMAL SELF-HOSTERS, STAY AWAY! <<<<<< +# >>>>>> YOU DON'T NEED THIS, ABOVE SETTINGS ARE FOR MANAGED HOSTING ONLY! <<<<<< +# !!!!!!!!!! + +# Seriously. Do NOT fill out the above settings if you're self-hosting. +# They're much better off being set from the control panel. diff --git a/.config/helm_values_example.yml b/.config/helm_values_example.yml new file mode 100644 index 0000000..ee47399 --- /dev/null +++ b/.config/helm_values_example.yml @@ -0,0 +1,82 @@ +replicaCount: 1 + +resources: + requests: + cpu: 0.5 + memory: 512Mi + limits: + cpu: 1 + memory: 1Gi + +iceshrimp: + domain: example.tld + smtp: + from_address: noreply@example.tld + port: 587 + server: smtp.gmail.com + useImplicitSslTls: false + login: me@example.tld + password: CHANGEME + objectStorage: + baseUrl: https://example-bucket.nyc3.cdn.digitaloceanspaces.com + access_key: CHANGEME + access_secret: CHANGEME + bucket: example-bucket + endpoint: nyc3.digitaloceanspaces.com:443 + region: nyc3 + allowedPrivateNetworks: [] + +ingress: + enabled: true + annotations: + cert-manager.io/cluster-issuer: letsencrypt + hosts: + - host: example.tld + paths: + - path: / + pathType: ImplementationSpecific + tls: + - secretName: example-tld-certificate + hosts: + - example.tld + +elasticsearch: + enabled: false + +postgresql: + auth: + password: CHANGEME + postgresPassword: CHANGEME + primary: + persistence: + enabled: true + storageClass: vultr-block-storage + size: 25Gi + resources: + requests: + cpu: 0.25 + memory: 256Mi + limits: + cpu: 0.5 + memory: 512Mi + metrics: + enabled: true + +redis: + auth: + password: CHANGEME + master: + resources: + requests: + cpu: 0.25 + memory: 256Mi + limits: + cpu: 0.5 + memory: 256Mi + persistence: + storageclass: vultr-block-storage + size: 10Gi + replica: + replicaCount: 0 + metrics: + enabled: true diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d1a3ad2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,40 @@ +# Visual Studio Code +.vscode + +# Intelij-IDEA +.idea + +# Node.js +node_modules +**/node_modules +report.*.json + +# Rust +packages/backend/native-utils/target + +# Coverage +coverage + +# config +/.config + +# misskey +built +db +elasticsearch +redis +npm-debug.log +*.pem +run.bat +api-docs.json +*.log +*.code-workspace +.DS_Store +files +ormconfig.json +packages/backend/assets/instance.css + +# dockerignore custom +.git +Dockerfile +docker-compose.yml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..e5b17a3 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +indent_style = tab +indent_size = 4 +charset = utf-8 +insert_final_newline = true + +[*.yml] +indent_style = space diff --git a/.env.yarn b/.env.yarn new file mode 100644 index 0000000..8ce28f7 --- /dev/null +++ b/.env.yarn @@ -0,0 +1 @@ +JOBS=max diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3ce7171 --- /dev/null +++ b/.envrc @@ -0,0 +1,4 @@ +if ! has nix_direnv_version || ! nix_direnv_version 2.3.0; then + source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/2.3.0/direnvrc" "sha256-Dmd+j63L84wuzgyjITIfSxSD57Tx7v51DMxVZOsiUD8=" +fi +use flake . --impure diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..3cfb30c --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,4 @@ +# refactor: :art: rome +2aab2de38d12f67c65360f7767df7a29bd98c832 +# chore: rome formatting +1309bafb07f91d7ba1f001f647d294139a96ea74 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..caf3427 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,53 @@ +*.7z filter=lfs diff=lfs merge=lfs -text +*.afdesign filter=lfs diff=lfs merge=lfs -text +*.ai filter=lfs diff=lfs merge=lfs -text +*.blend filter=lfs diff=lfs merge=lfs -text +*.BMP filter=lfs diff=lfs merge=lfs -text +*.bmp filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.doc filter=lfs diff=lfs merge=lfs -text +*.docx filter=lfs diff=lfs merge=lfs -text +*.enc filter=lfs diff=lfs merge=lfs -text +*.flac filter=lfs diff=lfs merge=lfs -text +*.gif filter=lfs diff=lfs merge=lfs -text +*.glb filter=lfs diff=lfs merge=lfs -text +*.gpg filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.ico filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text +*.JPEG filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.JPG filter=lfs diff=lfs merge=lfs -text +*.lockb filter=lfs diff=lfs merge=lfs -text +*.mkv filter=lfs diff=lfs merge=lfs -text +*.mov filter=lfs diff=lfs merge=lfs -text +*.MOV filter=lfs diff=lfs merge=lfs -text +*.mp3 filter=lfs diff=lfs merge=lfs -text +*.mp4 filter=lfs diff=lfs merge=lfs -text +*.MP4 filter=lfs diff=lfs merge=lfs -text +*.MPG filter=lfs diff=lfs merge=lfs -text +*.mpg filter=lfs diff=lfs merge=lfs -text +*.mqo filter=lfs diff=lfs merge=lfs -text +*.odp filter=lfs diff=lfs merge=lfs -text +*.ods filter=lfs diff=lfs merge=lfs -text +*.odt filter=lfs diff=lfs merge=lfs -text +*.ogg filter=lfs diff=lfs merge=lfs -text +*.pdf filter=lfs diff=lfs merge=lfs -text +*.png filter=lfs diff=lfs merge=lfs -text +*.PNG filter=lfs diff=lfs merge=lfs -text +*.pptx filter=lfs diff=lfs merge=lfs -text +*.psd filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.svg filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wav filter=lfs diff=lfs merge=lfs -text +*.webm filter=lfs diff=lfs merge=lfs -text +*.webp filter=lfs diff=lfs merge=lfs -text +*.woff2 filter=lfs diff=lfs merge=lfs -text +*.xcf filter=lfs diff=lfs merge=lfs -text +*.xls filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zstd filter=lfs diff=lfs merge=lfs -text +group1-shard?of6 filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5a8e49a --- /dev/null +++ b/.gitignore @@ -0,0 +1,82 @@ +# Visual Studio Code +/.vscode/* +!/.vscode/extensions.json +!/.vscode/settings.dev.json + +# Intelij-IDEA +/.idea +packages/backend/.idea/backend.iml +packages/backend/.idea/modules.xml +packages/backend/.idea/vcs.xml + +# Node.js +node_modules +report.*.json + +# Coverage +coverage + +# config +/.config/* +!/.config/example.yml +!/.config/example-docker.yml +!/.config/devenv.yml +!/.config/docker_example.env +!/.config/helm_values_example.yml +!/.config/LICENSE + +# docker configs +/dev/docker-compose.yml +/docker-compose.yml + +# misskey +built +elasticsearch +redis +meili_data +sonic +dragonflydb +keydb +redis_cache +npm-debug.log +*.pem +run.bat +api-docs.json +*.log +*.code-workspace +.DS_Store +files +ormconfig.json +packages/backend/assets/instance.css +packages/backend/assets/sounds/None.mp3 +packages/backend/assets/LICENSE + +!/packages/backend/queue/processors/db +!packages/backend/src/db + +# blender backups +*.blend1 +*.blend2 +*.blend3 +*.blend4 +*.blend5 + +#old pnpm +pnpm* + +.yarn/* +!.yarn/cache +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions +!.yarn/corepack.tgz + +# Nix Development shell items +.devenv +.direnv + +# helm chart dependencies +chart/charts +chart/Chart.lock diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e67ee62 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "assets/branding"] + path = assets/branding + url = https://iceshrimp.dev/iceshrimp/branding.git diff --git a/.noai b/.noai new file mode 100644 index 0000000..e69de29 diff --git a/.node-version b/.node-version new file mode 100644 index 0000000..a374504 --- /dev/null +++ b/.node-version @@ -0,0 +1 @@ +v22.22.2 diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..abb787e --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +use-lockfile-v6=true diff --git a/.pnp.cjs b/.pnp.cjs new file mode 100644 index 0000000..baeeb22 --- /dev/null +++ b/.pnp.cjs @@ -0,0 +1,23906 @@ +#!/usr/bin/env node +/* eslint-disable */ +// @ts-nocheck +"use strict"; + +const RAW_RUNTIME_STATE = +'{\ + "__info": [\ + "This file is automatically generated. Do not touch it, or risk",\ + "your modifications being lost."\ + ],\ + "dependencyTreeRoots": [\ + {\ + "name": "iceshrimp",\ + "reference": "workspace:."\ + },\ + {\ + "name": "backend",\ + "reference": "workspace:packages/backend"\ + },\ + {\ + "name": "client",\ + "reference": "workspace:packages/client"\ + },\ + {\ + "name": "iceshrimp-sdk",\ + "reference": "workspace:packages/iceshrimp-sdk"\ + },\ + {\ + "name": "sw",\ + "reference": "workspace:packages/sw"\ + }\ + ],\ + "enableTopLevelFallback": true,\ + "ignorePatternData": "(^(?:\\\\.yarn\\\\/sdks(?:\\\\/(?!\\\\.{1,2}(?:\\\\/|$))(?:(?:(?!(?:^|\\\\/)\\\\.{1,2}(?:\\\\/|$)).)*?)|$))$)",\ + "pnpZipBackend": "libzip",\ + "fallbackExclusionList": [\ + ["backend", ["workspace:packages/backend"]],\ + ["client", ["workspace:packages/client"]],\ + ["iceshrimp", ["workspace:."]],\ + ["iceshrimp-sdk", ["workspace:packages/iceshrimp-sdk"]],\ + ["sw", ["workspace:packages/sw"]]\ + ],\ + "fallbackPool": [\ + ],\ + "packageRegistryData": [\ + [null, [\ + [null, {\ + "packageLocation": "./",\ + "packageDependencies": [\ + ["@biomejs/biome", "npm:2.4.14"],\ + ["@bull-board/api", "virtual:6d3c013820dba430e71ebb352cb5205445a13ea3c7a848f57a7ff58fb0d6469fe4d374280277dac42cb77a6dbf8e924e64f2f0b3413c28a02da9d890c199e6d7#npm:5.6.0"],\ + ["@bull-board/ui", "npm:5.6.0"],\ + ["@types/node", "npm:22.19.18"],\ + ["chokidar", "npm:3.5.3"],\ + ["cross-env", "npm:7.0.3"],\ + ["esbuild", "npm:0.28.0"],\ + ["execa", "npm:5.1.1"],\ + ["glob", "npm:13.0.6"],\ + ["iceshrimp", "workspace:."],\ + ["install-peers", "npm:1.0.4"],\ + ["js-yaml", "npm:4.1.0"],\ + ["seedrandom", "npm:3.0.5"],\ + ["typescript", "patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5"],\ + ["yaml", "npm:2.3.4"],\ + ["yoctocolors", "npm:2.1.2"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ + ["@asamuzakjp/css-color", [\ + ["npm:3.2.0", {\ + "packageLocation": "./.yarn/cache/@asamuzakjp-css-color-npm-3.2.0-ed5b7465ba-870f661460.zip/node_modules/@asamuzakjp/css-color/",\ + "packageDependencies": [\ + ["@asamuzakjp/css-color", "npm:3.2.0"],\ + ["@csstools/css-calc", "virtual:ed5b7465ba8cf0eb21975dec62bfcf6d291ea8fcead25822592225aae1675a11a9ab5730181ed5da294a62f7379a3e67d78ef9ef50d04ba4802b6770c14cecdd#npm:2.1.4"],\ + ["@csstools/css-color-parser", "virtual:ed5b7465ba8cf0eb21975dec62bfcf6d291ea8fcead25822592225aae1675a11a9ab5730181ed5da294a62f7379a3e67d78ef9ef50d04ba4802b6770c14cecdd#npm:3.1.0"],\ + ["@csstools/css-parser-algorithms", "virtual:ed5b7465ba8cf0eb21975dec62bfcf6d291ea8fcead25822592225aae1675a11a9ab5730181ed5da294a62f7379a3e67d78ef9ef50d04ba4802b6770c14cecdd#npm:3.0.5"],\ + ["@csstools/css-tokenizer", "npm:3.0.4"],\ + ["lru-cache", "npm:10.4.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-crypto/crc32", [\ + ["npm:5.2.0", {\ + "packageLocation": "./.yarn/cache/@aws-crypto-crc32-npm-5.2.0-a834040f6d-1b0a56ad4c.zip/node_modules/@aws-crypto/crc32/",\ + "packageDependencies": [\ + ["@aws-crypto/crc32", "npm:5.2.0"],\ + ["@aws-crypto/util", "npm:5.2.0"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-crypto/crc32c", [\ + ["npm:5.2.0", {\ + "packageLocation": "./.yarn/cache/@aws-crypto-crc32c-npm-5.2.0-e4a77c7012-08bd1db17d.zip/node_modules/@aws-crypto/crc32c/",\ + "packageDependencies": [\ + ["@aws-crypto/crc32c", "npm:5.2.0"],\ + ["@aws-crypto/util", "npm:5.2.0"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-crypto/sha1-browser", [\ + ["npm:5.2.0", {\ + "packageLocation": "./.yarn/cache/@aws-crypto-sha1-browser-npm-5.2.0-1973da1a70-239f4c59cc.zip/node_modules/@aws-crypto/sha1-browser/",\ + "packageDependencies": [\ + ["@aws-crypto/sha1-browser", "npm:5.2.0"],\ + ["@aws-crypto/supports-web-crypto", "npm:5.2.0"],\ + ["@aws-crypto/util", "npm:5.2.0"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@aws-sdk/util-locate-window", "npm:3.965.5"],\ + ["@smithy/util-utf8", "npm:2.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-crypto/sha256-browser", [\ + ["npm:5.2.0", {\ + "packageLocation": "./.yarn/cache/@aws-crypto-sha256-browser-npm-5.2.0-5e8b02b82a-2b1b701ca6.zip/node_modules/@aws-crypto/sha256-browser/",\ + "packageDependencies": [\ + ["@aws-crypto/sha256-browser", "npm:5.2.0"],\ + ["@aws-crypto/sha256-js", "npm:5.2.0"],\ + ["@aws-crypto/supports-web-crypto", "npm:5.2.0"],\ + ["@aws-crypto/util", "npm:5.2.0"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@aws-sdk/util-locate-window", "npm:3.965.5"],\ + ["@smithy/util-utf8", "npm:2.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-crypto/sha256-js", [\ + ["npm:5.2.0", {\ + "packageLocation": "./.yarn/cache/@aws-crypto-sha256-js-npm-5.2.0-fbe0f9fbf6-f46aace7b8.zip/node_modules/@aws-crypto/sha256-js/",\ + "packageDependencies": [\ + ["@aws-crypto/sha256-js", "npm:5.2.0"],\ + ["@aws-crypto/util", "npm:5.2.0"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-crypto/supports-web-crypto", [\ + ["npm:5.2.0", {\ + "packageLocation": "./.yarn/cache/@aws-crypto-supports-web-crypto-npm-5.2.0-37acf6e569-6ed0c7e17f.zip/node_modules/@aws-crypto/supports-web-crypto/",\ + "packageDependencies": [\ + ["@aws-crypto/supports-web-crypto", "npm:5.2.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-crypto/util", [\ + ["npm:5.2.0", {\ + "packageLocation": "./.yarn/cache/@aws-crypto-util-npm-5.2.0-67e90fb04c-f80a174c40.zip/node_modules/@aws-crypto/util/",\ + "packageDependencies": [\ + ["@aws-crypto/util", "npm:5.2.0"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/util-utf8", "npm:2.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/client-s3", [\ + ["npm:3.1045.0", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-client-s3-npm-3.1045.0-9355b157a3-082c7b1ce2.zip/node_modules/@aws-sdk/client-s3/",\ + "packageDependencies": [\ + ["@aws-crypto/sha1-browser", "npm:5.2.0"],\ + ["@aws-crypto/sha256-browser", "npm:5.2.0"],\ + ["@aws-crypto/sha256-js", "npm:5.2.0"],\ + ["@aws-sdk/client-s3", "npm:3.1045.0"],\ + ["@aws-sdk/core", "npm:3.974.8"],\ + ["@aws-sdk/credential-provider-node", "npm:3.972.39"],\ + ["@aws-sdk/middleware-bucket-endpoint", "npm:3.972.10"],\ + ["@aws-sdk/middleware-expect-continue", "npm:3.972.10"],\ + ["@aws-sdk/middleware-flexible-checksums", "npm:3.974.16"],\ + ["@aws-sdk/middleware-host-header", "npm:3.972.10"],\ + ["@aws-sdk/middleware-location-constraint", "npm:3.972.10"],\ + ["@aws-sdk/middleware-logger", "npm:3.972.10"],\ + ["@aws-sdk/middleware-recursion-detection", "npm:3.972.11"],\ + ["@aws-sdk/middleware-sdk-s3", "npm:3.972.37"],\ + ["@aws-sdk/middleware-ssec", "npm:3.972.10"],\ + ["@aws-sdk/middleware-user-agent", "npm:3.972.38"],\ + ["@aws-sdk/region-config-resolver", "npm:3.972.13"],\ + ["@aws-sdk/signature-v4-multi-region", "npm:3.996.25"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@aws-sdk/util-endpoints", "npm:3.996.8"],\ + ["@aws-sdk/util-user-agent-browser", "npm:3.972.10"],\ + ["@aws-sdk/util-user-agent-node", "virtual:9e53ea4e3c0d6b0468dc86c2f329598942e398415103d45ac61ac4ee3e9545649af1366441864e204479bf3c10db119be10dbfc7766e2ea8441bf814b21041b4#npm:3.973.24"],\ + ["@smithy/config-resolver", "npm:4.5.0"],\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/eventstream-serde-browser", "npm:4.3.0"],\ + ["@smithy/eventstream-serde-config-resolver", "npm:4.4.0"],\ + ["@smithy/eventstream-serde-node", "npm:4.3.0"],\ + ["@smithy/fetch-http-handler", "npm:5.4.0"],\ + ["@smithy/hash-blob-browser", "npm:4.3.0"],\ + ["@smithy/hash-node", "npm:4.3.0"],\ + ["@smithy/hash-stream-node", "npm:4.3.0"],\ + ["@smithy/invalid-dependency", "npm:4.3.0"],\ + ["@smithy/md5-js", "npm:4.3.0"],\ + ["@smithy/middleware-content-length", "npm:4.3.0"],\ + ["@smithy/middleware-endpoint", "npm:4.5.0"],\ + ["@smithy/middleware-retry", "npm:4.6.0"],\ + ["@smithy/middleware-serde", "npm:4.3.0"],\ + ["@smithy/middleware-stack", "npm:4.3.0"],\ + ["@smithy/node-config-provider", "npm:4.4.0"],\ + ["@smithy/node-http-handler", "npm:4.7.0"],\ + ["@smithy/protocol-http", "npm:5.4.0"],\ + ["@smithy/smithy-client", "npm:4.13.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["@smithy/url-parser", "npm:4.3.0"],\ + ["@smithy/util-base64", "npm:4.4.0"],\ + ["@smithy/util-body-length-browser", "npm:4.3.0"],\ + ["@smithy/util-body-length-node", "npm:4.3.0"],\ + ["@smithy/util-defaults-mode-browser", "npm:4.4.0"],\ + ["@smithy/util-defaults-mode-node", "npm:4.3.0"],\ + ["@smithy/util-endpoints", "npm:3.5.0"],\ + ["@smithy/util-middleware", "npm:4.3.0"],\ + ["@smithy/util-retry", "npm:4.4.0"],\ + ["@smithy/util-stream", "npm:4.6.0"],\ + ["@smithy/util-utf8", "npm:4.3.0"],\ + ["@smithy/util-waiter", "npm:4.4.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/core", [\ + ["npm:3.974.8", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-core-npm-3.974.8-34c2457225-7371738ba9.zip/node_modules/@aws-sdk/core/",\ + "packageDependencies": [\ + ["@aws-sdk/core", "npm:3.974.8"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@aws-sdk/xml-builder", "npm:3.972.22"],\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/node-config-provider", "npm:4.4.0"],\ + ["@smithy/property-provider", "npm:4.3.0"],\ + ["@smithy/protocol-http", "npm:5.4.0"],\ + ["@smithy/signature-v4", "npm:5.4.0"],\ + ["@smithy/smithy-client", "npm:4.13.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["@smithy/util-base64", "npm:4.4.0"],\ + ["@smithy/util-middleware", "npm:4.3.0"],\ + ["@smithy/util-retry", "npm:4.4.0"],\ + ["@smithy/util-utf8", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/crc64-nvme", [\ + ["npm:3.972.7", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-crc64-nvme-npm-3.972.7-cb2031635c-c836743d58.zip/node_modules/@aws-sdk/crc64-nvme/",\ + "packageDependencies": [\ + ["@aws-sdk/crc64-nvme", "npm:3.972.7"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/credential-provider-env", [\ + ["npm:3.972.34", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-credential-provider-env-npm-3.972.34-4fa21f644b-764a8accd6.zip/node_modules/@aws-sdk/credential-provider-env/",\ + "packageDependencies": [\ + ["@aws-sdk/core", "npm:3.974.8"],\ + ["@aws-sdk/credential-provider-env", "npm:3.972.34"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/property-provider", "npm:4.3.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/credential-provider-http", [\ + ["npm:3.972.36", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-credential-provider-http-npm-3.972.36-89253a4824-832699bb70.zip/node_modules/@aws-sdk/credential-provider-http/",\ + "packageDependencies": [\ + ["@aws-sdk/core", "npm:3.974.8"],\ + ["@aws-sdk/credential-provider-http", "npm:3.972.36"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/fetch-http-handler", "npm:5.4.0"],\ + ["@smithy/node-http-handler", "npm:4.7.0"],\ + ["@smithy/property-provider", "npm:4.3.0"],\ + ["@smithy/protocol-http", "npm:5.4.0"],\ + ["@smithy/smithy-client", "npm:4.13.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["@smithy/util-stream", "npm:4.6.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/credential-provider-ini", [\ + ["npm:3.972.38", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-credential-provider-ini-npm-3.972.38-b7d47f7c2a-49c7faa65d.zip/node_modules/@aws-sdk/credential-provider-ini/",\ + "packageDependencies": [\ + ["@aws-sdk/core", "npm:3.974.8"],\ + ["@aws-sdk/credential-provider-env", "npm:3.972.34"],\ + ["@aws-sdk/credential-provider-http", "npm:3.972.36"],\ + ["@aws-sdk/credential-provider-ini", "npm:3.972.38"],\ + ["@aws-sdk/credential-provider-login", "npm:3.972.38"],\ + ["@aws-sdk/credential-provider-process", "npm:3.972.34"],\ + ["@aws-sdk/credential-provider-sso", "npm:3.972.38"],\ + ["@aws-sdk/credential-provider-web-identity", "npm:3.972.38"],\ + ["@aws-sdk/nested-clients", "npm:3.997.6"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/credential-provider-imds", "npm:4.3.0"],\ + ["@smithy/property-provider", "npm:4.3.0"],\ + ["@smithy/shared-ini-file-loader", "npm:4.5.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/credential-provider-login", [\ + ["npm:3.972.38", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-credential-provider-login-npm-3.972.38-847a8a57ab-b03c35546b.zip/node_modules/@aws-sdk/credential-provider-login/",\ + "packageDependencies": [\ + ["@aws-sdk/core", "npm:3.974.8"],\ + ["@aws-sdk/credential-provider-login", "npm:3.972.38"],\ + ["@aws-sdk/nested-clients", "npm:3.997.6"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/property-provider", "npm:4.3.0"],\ + ["@smithy/protocol-http", "npm:5.4.0"],\ + ["@smithy/shared-ini-file-loader", "npm:4.5.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/credential-provider-node", [\ + ["npm:3.972.39", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-credential-provider-node-npm-3.972.39-827bb65816-b577b77075.zip/node_modules/@aws-sdk/credential-provider-node/",\ + "packageDependencies": [\ + ["@aws-sdk/credential-provider-env", "npm:3.972.34"],\ + ["@aws-sdk/credential-provider-http", "npm:3.972.36"],\ + ["@aws-sdk/credential-provider-ini", "npm:3.972.38"],\ + ["@aws-sdk/credential-provider-node", "npm:3.972.39"],\ + ["@aws-sdk/credential-provider-process", "npm:3.972.34"],\ + ["@aws-sdk/credential-provider-sso", "npm:3.972.38"],\ + ["@aws-sdk/credential-provider-web-identity", "npm:3.972.38"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/credential-provider-imds", "npm:4.3.0"],\ + ["@smithy/property-provider", "npm:4.3.0"],\ + ["@smithy/shared-ini-file-loader", "npm:4.5.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/credential-provider-process", [\ + ["npm:3.972.34", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-credential-provider-process-npm-3.972.34-e7d38b67a9-184a830040.zip/node_modules/@aws-sdk/credential-provider-process/",\ + "packageDependencies": [\ + ["@aws-sdk/core", "npm:3.974.8"],\ + ["@aws-sdk/credential-provider-process", "npm:3.972.34"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/property-provider", "npm:4.3.0"],\ + ["@smithy/shared-ini-file-loader", "npm:4.5.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/credential-provider-sso", [\ + ["npm:3.972.38", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-credential-provider-sso-npm-3.972.38-40360bb4ae-2f1bd86399.zip/node_modules/@aws-sdk/credential-provider-sso/",\ + "packageDependencies": [\ + ["@aws-sdk/core", "npm:3.974.8"],\ + ["@aws-sdk/credential-provider-sso", "npm:3.972.38"],\ + ["@aws-sdk/nested-clients", "npm:3.997.6"],\ + ["@aws-sdk/token-providers", "npm:3.1041.0"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/property-provider", "npm:4.3.0"],\ + ["@smithy/shared-ini-file-loader", "npm:4.5.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/credential-provider-web-identity", [\ + ["npm:3.972.38", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-credential-provider-web-identity-npm-3.972.38-82a3355b1b-9975bc20ab.zip/node_modules/@aws-sdk/credential-provider-web-identity/",\ + "packageDependencies": [\ + ["@aws-sdk/core", "npm:3.974.8"],\ + ["@aws-sdk/credential-provider-web-identity", "npm:3.972.38"],\ + ["@aws-sdk/nested-clients", "npm:3.997.6"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/property-provider", "npm:4.3.0"],\ + ["@smithy/shared-ini-file-loader", "npm:4.5.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/lib-storage", [\ + ["npm:3.1045.0", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-lib-storage-npm-3.1045.0-308fe380e1-53d06e9bf0.zip/node_modules/@aws-sdk/lib-storage/",\ + "packageDependencies": [\ + ["@aws-sdk/lib-storage", "npm:3.1045.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:3.1045.0", {\ + "packageLocation": "./.yarn/__virtual__/@aws-sdk-lib-storage-virtual-0a0a222e54/0/cache/@aws-sdk-lib-storage-npm-3.1045.0-308fe380e1-53d06e9bf0.zip/node_modules/@aws-sdk/lib-storage/",\ + "packageDependencies": [\ + ["@aws-sdk/client-s3", "npm:3.1045.0"],\ + ["@aws-sdk/lib-storage", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:3.1045.0"],\ + ["@smithy/middleware-endpoint", "npm:4.5.0"],\ + ["@smithy/protocol-http", "npm:5.4.0"],\ + ["@smithy/smithy-client", "npm:4.13.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["@types/aws-sdk__client-s3", null],\ + ["buffer", "npm:5.6.0"],\ + ["events", "npm:3.3.0"],\ + ["stream-browserify", "npm:3.0.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "packagePeers": [\ + "@aws-sdk/client-s3",\ + "@types/aws-sdk__client-s3"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/middleware-bucket-endpoint", [\ + ["npm:3.972.10", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-middleware-bucket-endpoint-npm-3.972.10-f1f2f7dcbd-980203cf37.zip/node_modules/@aws-sdk/middleware-bucket-endpoint/",\ + "packageDependencies": [\ + ["@aws-sdk/middleware-bucket-endpoint", "npm:3.972.10"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@aws-sdk/util-arn-parser", "npm:3.972.3"],\ + ["@smithy/node-config-provider", "npm:4.4.0"],\ + ["@smithy/protocol-http", "npm:5.4.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["@smithy/util-config-provider", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/middleware-expect-continue", [\ + ["npm:3.972.10", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-middleware-expect-continue-npm-3.972.10-469756f12a-a2cb2389dd.zip/node_modules/@aws-sdk/middleware-expect-continue/",\ + "packageDependencies": [\ + ["@aws-sdk/middleware-expect-continue", "npm:3.972.10"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/protocol-http", "npm:5.4.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/middleware-flexible-checksums", [\ + ["npm:3.974.16", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-middleware-flexible-checksums-npm-3.974.16-c4f13ce2ab-694f78b532.zip/node_modules/@aws-sdk/middleware-flexible-checksums/",\ + "packageDependencies": [\ + ["@aws-crypto/crc32", "npm:5.2.0"],\ + ["@aws-crypto/crc32c", "npm:5.2.0"],\ + ["@aws-crypto/util", "npm:5.2.0"],\ + ["@aws-sdk/core", "npm:3.974.8"],\ + ["@aws-sdk/crc64-nvme", "npm:3.972.7"],\ + ["@aws-sdk/middleware-flexible-checksums", "npm:3.974.16"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/is-array-buffer", "npm:4.3.0"],\ + ["@smithy/node-config-provider", "npm:4.4.0"],\ + ["@smithy/protocol-http", "npm:5.4.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["@smithy/util-middleware", "npm:4.3.0"],\ + ["@smithy/util-stream", "npm:4.6.0"],\ + ["@smithy/util-utf8", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/middleware-host-header", [\ + ["npm:3.972.10", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-middleware-host-header-npm-3.972.10-33e19af987-4098fa5f6d.zip/node_modules/@aws-sdk/middleware-host-header/",\ + "packageDependencies": [\ + ["@aws-sdk/middleware-host-header", "npm:3.972.10"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/protocol-http", "npm:5.4.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/middleware-location-constraint", [\ + ["npm:3.972.10", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-middleware-location-constraint-npm-3.972.10-a1fe0f5253-fdea046ce7.zip/node_modules/@aws-sdk/middleware-location-constraint/",\ + "packageDependencies": [\ + ["@aws-sdk/middleware-location-constraint", "npm:3.972.10"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/middleware-logger", [\ + ["npm:3.972.10", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-middleware-logger-npm-3.972.10-d2f8a69c40-a5ccf69d05.zip/node_modules/@aws-sdk/middleware-logger/",\ + "packageDependencies": [\ + ["@aws-sdk/middleware-logger", "npm:3.972.10"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/middleware-recursion-detection", [\ + ["npm:3.972.11", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-middleware-recursion-detection-npm-3.972.11-a7192def07-3ce52895d9.zip/node_modules/@aws-sdk/middleware-recursion-detection/",\ + "packageDependencies": [\ + ["@aws-sdk/middleware-recursion-detection", "npm:3.972.11"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@aws/lambda-invoke-store", "npm:0.2.4"],\ + ["@smithy/protocol-http", "npm:5.4.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/middleware-sdk-s3", [\ + ["npm:3.972.37", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-middleware-sdk-s3-npm-3.972.37-38e4c5e256-8b5f71c1a6.zip/node_modules/@aws-sdk/middleware-sdk-s3/",\ + "packageDependencies": [\ + ["@aws-sdk/core", "npm:3.974.8"],\ + ["@aws-sdk/middleware-sdk-s3", "npm:3.972.37"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@aws-sdk/util-arn-parser", "npm:3.972.3"],\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/node-config-provider", "npm:4.4.0"],\ + ["@smithy/protocol-http", "npm:5.4.0"],\ + ["@smithy/signature-v4", "npm:5.4.0"],\ + ["@smithy/smithy-client", "npm:4.13.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["@smithy/util-config-provider", "npm:4.3.0"],\ + ["@smithy/util-middleware", "npm:4.3.0"],\ + ["@smithy/util-stream", "npm:4.6.0"],\ + ["@smithy/util-utf8", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/middleware-ssec", [\ + ["npm:3.972.10", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-middleware-ssec-npm-3.972.10-e7b4d500cf-43f6238dca.zip/node_modules/@aws-sdk/middleware-ssec/",\ + "packageDependencies": [\ + ["@aws-sdk/middleware-ssec", "npm:3.972.10"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/middleware-user-agent", [\ + ["npm:3.972.38", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-middleware-user-agent-npm-3.972.38-dcd14431d2-f1bfd65d5a.zip/node_modules/@aws-sdk/middleware-user-agent/",\ + "packageDependencies": [\ + ["@aws-sdk/core", "npm:3.974.8"],\ + ["@aws-sdk/middleware-user-agent", "npm:3.972.38"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@aws-sdk/util-endpoints", "npm:3.996.8"],\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/protocol-http", "npm:5.4.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["@smithy/util-retry", "npm:4.4.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/nested-clients", [\ + ["npm:3.997.6", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-nested-clients-npm-3.997.6-9e53ea4e3c-8467df064e.zip/node_modules/@aws-sdk/nested-clients/",\ + "packageDependencies": [\ + ["@aws-crypto/sha256-browser", "npm:5.2.0"],\ + ["@aws-crypto/sha256-js", "npm:5.2.0"],\ + ["@aws-sdk/core", "npm:3.974.8"],\ + ["@aws-sdk/middleware-host-header", "npm:3.972.10"],\ + ["@aws-sdk/middleware-logger", "npm:3.972.10"],\ + ["@aws-sdk/middleware-recursion-detection", "npm:3.972.11"],\ + ["@aws-sdk/middleware-user-agent", "npm:3.972.38"],\ + ["@aws-sdk/nested-clients", "npm:3.997.6"],\ + ["@aws-sdk/region-config-resolver", "npm:3.972.13"],\ + ["@aws-sdk/signature-v4-multi-region", "npm:3.996.25"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@aws-sdk/util-endpoints", "npm:3.996.8"],\ + ["@aws-sdk/util-user-agent-browser", "npm:3.972.10"],\ + ["@aws-sdk/util-user-agent-node", "virtual:9e53ea4e3c0d6b0468dc86c2f329598942e398415103d45ac61ac4ee3e9545649af1366441864e204479bf3c10db119be10dbfc7766e2ea8441bf814b21041b4#npm:3.973.24"],\ + ["@smithy/config-resolver", "npm:4.5.0"],\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/fetch-http-handler", "npm:5.4.0"],\ + ["@smithy/hash-node", "npm:4.3.0"],\ + ["@smithy/invalid-dependency", "npm:4.3.0"],\ + ["@smithy/middleware-content-length", "npm:4.3.0"],\ + ["@smithy/middleware-endpoint", "npm:4.5.0"],\ + ["@smithy/middleware-retry", "npm:4.6.0"],\ + ["@smithy/middleware-serde", "npm:4.3.0"],\ + ["@smithy/middleware-stack", "npm:4.3.0"],\ + ["@smithy/node-config-provider", "npm:4.4.0"],\ + ["@smithy/node-http-handler", "npm:4.7.0"],\ + ["@smithy/protocol-http", "npm:5.4.0"],\ + ["@smithy/smithy-client", "npm:4.13.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["@smithy/url-parser", "npm:4.3.0"],\ + ["@smithy/util-base64", "npm:4.4.0"],\ + ["@smithy/util-body-length-browser", "npm:4.3.0"],\ + ["@smithy/util-body-length-node", "npm:4.3.0"],\ + ["@smithy/util-defaults-mode-browser", "npm:4.4.0"],\ + ["@smithy/util-defaults-mode-node", "npm:4.3.0"],\ + ["@smithy/util-endpoints", "npm:3.5.0"],\ + ["@smithy/util-middleware", "npm:4.3.0"],\ + ["@smithy/util-retry", "npm:4.4.0"],\ + ["@smithy/util-utf8", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/region-config-resolver", [\ + ["npm:3.972.13", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-region-config-resolver-npm-3.972.13-1c1d5a0d8b-f80c26ecd5.zip/node_modules/@aws-sdk/region-config-resolver/",\ + "packageDependencies": [\ + ["@aws-sdk/region-config-resolver", "npm:3.972.13"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/config-resolver", "npm:4.5.0"],\ + ["@smithy/node-config-provider", "npm:4.4.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/signature-v4-multi-region", [\ + ["npm:3.996.25", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-signature-v4-multi-region-npm-3.996.25-954abea546-1139b78872.zip/node_modules/@aws-sdk/signature-v4-multi-region/",\ + "packageDependencies": [\ + ["@aws-sdk/middleware-sdk-s3", "npm:3.972.37"],\ + ["@aws-sdk/signature-v4-multi-region", "npm:3.996.25"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/protocol-http", "npm:5.4.0"],\ + ["@smithy/signature-v4", "npm:5.4.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/token-providers", [\ + ["npm:3.1041.0", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-token-providers-npm-3.1041.0-b9fc4d731a-bf101933d1.zip/node_modules/@aws-sdk/token-providers/",\ + "packageDependencies": [\ + ["@aws-sdk/core", "npm:3.974.8"],\ + ["@aws-sdk/nested-clients", "npm:3.997.6"],\ + ["@aws-sdk/token-providers", "npm:3.1041.0"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/property-provider", "npm:4.3.0"],\ + ["@smithy/shared-ini-file-loader", "npm:4.5.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/types", [\ + ["npm:3.973.8", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-types-npm-3.973.8-050aff576f-76f613d0df.zip/node_modules/@aws-sdk/types/",\ + "packageDependencies": [\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/util-arn-parser", [\ + ["npm:3.972.3", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-util-arn-parser-npm-3.972.3-9c10b3df57-140a30615c.zip/node_modules/@aws-sdk/util-arn-parser/",\ + "packageDependencies": [\ + ["@aws-sdk/util-arn-parser", "npm:3.972.3"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/util-endpoints", [\ + ["npm:3.996.8", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-util-endpoints-npm-3.996.8-a4df17c689-9b95fbe217.zip/node_modules/@aws-sdk/util-endpoints/",\ + "packageDependencies": [\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@aws-sdk/util-endpoints", "npm:3.996.8"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["@smithy/url-parser", "npm:4.3.0"],\ + ["@smithy/util-endpoints", "npm:3.5.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/util-locate-window", [\ + ["npm:3.965.5", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-util-locate-window-npm-3.965.5-b2804bc47e-66391a7f6d.zip/node_modules/@aws-sdk/util-locate-window/",\ + "packageDependencies": [\ + ["@aws-sdk/util-locate-window", "npm:3.965.5"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/util-user-agent-browser", [\ + ["npm:3.972.10", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-util-user-agent-browser-npm-3.972.10-c167939b1d-dc76c0ede5.zip/node_modules/@aws-sdk/util-user-agent-browser/",\ + "packageDependencies": [\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@aws-sdk/util-user-agent-browser", "npm:3.972.10"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["bowser", "npm:2.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/util-user-agent-node", [\ + ["npm:3.973.24", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-util-user-agent-node-npm-3.973.24-20a272bded-e94edde07b.zip/node_modules/@aws-sdk/util-user-agent-node/",\ + "packageDependencies": [\ + ["@aws-sdk/util-user-agent-node", "npm:3.973.24"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:9e53ea4e3c0d6b0468dc86c2f329598942e398415103d45ac61ac4ee3e9545649af1366441864e204479bf3c10db119be10dbfc7766e2ea8441bf814b21041b4#npm:3.973.24", {\ + "packageLocation": "./.yarn/__virtual__/@aws-sdk-util-user-agent-node-virtual-9b6ccf985b/0/cache/@aws-sdk-util-user-agent-node-npm-3.973.24-20a272bded-e94edde07b.zip/node_modules/@aws-sdk/util-user-agent-node/",\ + "packageDependencies": [\ + ["@aws-sdk/middleware-user-agent", "npm:3.972.38"],\ + ["@aws-sdk/types", "npm:3.973.8"],\ + ["@aws-sdk/util-user-agent-node", "virtual:9e53ea4e3c0d6b0468dc86c2f329598942e398415103d45ac61ac4ee3e9545649af1366441864e204479bf3c10db119be10dbfc7766e2ea8441bf814b21041b4#npm:3.973.24"],\ + ["@smithy/node-config-provider", "npm:4.4.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["@smithy/util-config-provider", "npm:4.3.0"],\ + ["@types/aws-crt", null],\ + ["aws-crt", null],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "packagePeers": [\ + "@types/aws-crt",\ + "aws-crt"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws-sdk/xml-builder", [\ + ["npm:3.972.22", {\ + "packageLocation": "./.yarn/cache/@aws-sdk-xml-builder-npm-3.972.22-3704d39daf-54032fdf33.zip/node_modules/@aws-sdk/xml-builder/",\ + "packageDependencies": [\ + ["@aws-sdk/xml-builder", "npm:3.972.22"],\ + ["@nodable/entities", "npm:2.1.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["fast-xml-parser", "npm:5.7.2"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@aws/lambda-invoke-store", [\ + ["npm:0.2.4", {\ + "packageLocation": "./.yarn/cache/@aws-lambda-invoke-store-npm-0.2.4-505056f392-47e73cf731.zip/node_modules/@aws/lambda-invoke-store/",\ + "packageDependencies": [\ + ["@aws/lambda-invoke-store", "npm:0.2.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@babel/helper-string-parser", [\ + ["npm:7.22.5", {\ + "packageLocation": "./.yarn/cache/@babel-helper-string-parser-npm-7.22.5-448ff0e489-7f275a7f1a.zip/node_modules/@babel/helper-string-parser/",\ + "packageDependencies": [\ + ["@babel/helper-string-parser", "npm:7.22.5"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.27.1", {\ + "packageLocation": "./.yarn/cache/@babel-helper-string-parser-npm-7.27.1-d1471e0598-0ae29cc200.zip/node_modules/@babel/helper-string-parser/",\ + "packageDependencies": [\ + ["@babel/helper-string-parser", "npm:7.27.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@babel/helper-validator-identifier", [\ + ["npm:7.22.20", {\ + "packageLocation": "./.yarn/cache/@babel-helper-validator-identifier-npm-7.22.20-18305bb306-df882d2675.zip/node_modules/@babel/helper-validator-identifier/",\ + "packageDependencies": [\ + ["@babel/helper-validator-identifier", "npm:7.22.20"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.28.5", {\ + "packageLocation": "./.yarn/cache/@babel-helper-validator-identifier-npm-7.28.5-1953d49d2b-8e5d9b0133.zip/node_modules/@babel/helper-validator-identifier/",\ + "packageDependencies": [\ + ["@babel/helper-validator-identifier", "npm:7.28.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@babel/parser", [\ + ["npm:7.22.7", {\ + "packageLocation": "./.yarn/cache/@babel-parser-npm-7.22.7-7fbdf28552-f420f89ea8.zip/node_modules/@babel/parser/",\ + "packageDependencies": [\ + ["@babel/parser", "npm:7.22.7"],\ + ["@babel/types", "npm:7.22.5"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.29.3", {\ + "packageLocation": "./.yarn/cache/@babel-parser-npm-7.29.3-1f668babfe-10e8f34e0f.zip/node_modules/@babel/parser/",\ + "packageDependencies": [\ + ["@babel/parser", "npm:7.29.3"],\ + ["@babel/types", "npm:7.29.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@babel/runtime", [\ + ["npm:7.22.6", {\ + "packageLocation": "./.yarn/cache/@babel-runtime-npm-7.22.6-493f6b7ad0-1d2f56797f.zip/node_modules/@babel/runtime/",\ + "packageDependencies": [\ + ["@babel/runtime", "npm:7.22.6"],\ + ["regenerator-runtime", "npm:0.13.11"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@babel/types", [\ + ["npm:7.22.5", {\ + "packageLocation": "./.yarn/cache/@babel-types-npm-7.22.5-d1e4264bef-7f7edffe7e.zip/node_modules/@babel/types/",\ + "packageDependencies": [\ + ["@babel/helper-string-parser", "npm:7.22.5"],\ + ["@babel/helper-validator-identifier", "npm:7.22.20"],\ + ["@babel/types", "npm:7.22.5"],\ + ["to-fast-properties", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.29.0", {\ + "packageLocation": "./.yarn/cache/@babel-types-npm-7.29.0-6c2fa77581-bfc2b21121.zip/node_modules/@babel/types/",\ + "packageDependencies": [\ + ["@babel/helper-string-parser", "npm:7.27.1"],\ + ["@babel/helper-validator-identifier", "npm:7.28.5"],\ + ["@babel/types", "npm:7.29.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@badrap/result", [\ + ["npm:0.3.1", {\ + "packageLocation": "./.yarn/cache/@badrap-result-npm-0.3.1-ddcdf6ca41-0162a86a08.zip/node_modules/@badrap/result/",\ + "packageDependencies": [\ + ["@badrap/result", "npm:0.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@biomejs/biome", [\ + ["npm:2.4.14", {\ + "packageLocation": "./.yarn/cache/@biomejs-biome-npm-2.4.14-aee6659695-9beee50af2.zip/node_modules/@biomejs/biome/",\ + "packageDependencies": [\ + ["@biomejs/biome", "npm:2.4.14"],\ + ["@biomejs/cli-darwin-arm64", "npm:2.4.14"],\ + ["@biomejs/cli-darwin-x64", "npm:2.4.14"],\ + ["@biomejs/cli-linux-arm64", "npm:2.4.14"],\ + ["@biomejs/cli-linux-arm64-musl", "npm:2.4.14"],\ + ["@biomejs/cli-linux-x64", "npm:2.4.14"],\ + ["@biomejs/cli-linux-x64-musl", "npm:2.4.14"],\ + ["@biomejs/cli-win32-arm64", "npm:2.4.14"],\ + ["@biomejs/cli-win32-x64", "npm:2.4.14"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@biomejs/cli-darwin-arm64", [\ + ["npm:2.4.14", {\ + "packageLocation": "./.yarn/unplugged/@biomejs-cli-darwin-arm64-npm-2.4.14-1eb14ca0ce/node_modules/@biomejs/cli-darwin-arm64/",\ + "packageDependencies": [\ + ["@biomejs/cli-darwin-arm64", "npm:2.4.14"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@biomejs/cli-darwin-x64", [\ + ["npm:2.4.14", {\ + "packageLocation": "./.yarn/unplugged/@biomejs-cli-darwin-x64-npm-2.4.14-5c64709dca/node_modules/@biomejs/cli-darwin-x64/",\ + "packageDependencies": [\ + ["@biomejs/cli-darwin-x64", "npm:2.4.14"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@biomejs/cli-linux-arm64", [\ + ["npm:2.4.14", {\ + "packageLocation": "./.yarn/unplugged/@biomejs-cli-linux-arm64-npm-2.4.14-4b5caf22e6/node_modules/@biomejs/cli-linux-arm64/",\ + "packageDependencies": [\ + ["@biomejs/cli-linux-arm64", "npm:2.4.14"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@biomejs/cli-linux-arm64-musl", [\ + ["npm:2.4.14", {\ + "packageLocation": "./.yarn/unplugged/@biomejs-cli-linux-arm64-musl-npm-2.4.14-964acafdab/node_modules/@biomejs/cli-linux-arm64-musl/",\ + "packageDependencies": [\ + ["@biomejs/cli-linux-arm64-musl", "npm:2.4.14"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@biomejs/cli-linux-x64", [\ + ["npm:2.4.14", {\ + "packageLocation": "./.yarn/unplugged/@biomejs-cli-linux-x64-npm-2.4.14-c19129a361/node_modules/@biomejs/cli-linux-x64/",\ + "packageDependencies": [\ + ["@biomejs/cli-linux-x64", "npm:2.4.14"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@biomejs/cli-linux-x64-musl", [\ + ["npm:2.4.14", {\ + "packageLocation": "./.yarn/unplugged/@biomejs-cli-linux-x64-musl-npm-2.4.14-06980d02f6/node_modules/@biomejs/cli-linux-x64-musl/",\ + "packageDependencies": [\ + ["@biomejs/cli-linux-x64-musl", "npm:2.4.14"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@biomejs/cli-win32-arm64", [\ + ["npm:2.4.14", {\ + "packageLocation": "./.yarn/unplugged/@biomejs-cli-win32-arm64-npm-2.4.14-eca71f9ab4/node_modules/@biomejs/cli-win32-arm64/",\ + "packageDependencies": [\ + ["@biomejs/cli-win32-arm64", "npm:2.4.14"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@biomejs/cli-win32-x64", [\ + ["npm:2.4.14", {\ + "packageLocation": "./.yarn/unplugged/@biomejs-cli-win32-x64-npm-2.4.14-997f887261/node_modules/@biomejs/cli-win32-x64/",\ + "packageDependencies": [\ + ["@biomejs/cli-win32-x64", "npm:2.4.14"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@borewit/text-codec", [\ + ["npm:0.2.2", {\ + "packageLocation": "./.yarn/cache/@borewit-text-codec-npm-0.2.2-11871252cc-c971790a72.zip/node_modules/@borewit/text-codec/",\ + "packageDependencies": [\ + ["@borewit/text-codec", "npm:0.2.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@bull-board/api", [\ + ["npm:5.6.0", {\ + "packageLocation": "./.yarn/cache/@bull-board-api-npm-5.6.0-a1466ed4c8-951b27f057.zip/node_modules/@bull-board/api/",\ + "packageDependencies": [\ + ["@bull-board/api", "npm:5.6.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["npm:6.0.0", {\ + "packageLocation": "./.yarn/cache/@bull-board-api-npm-6.0.0-78af39dbec-922e46f2c5.zip/node_modules/@bull-board/api/",\ + "packageDependencies": [\ + ["@bull-board/api", "npm:6.0.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:1a204444c80de4bcde4565c72a3b0d6571f4eb3f918d7d48d8cd7ddf8392bb94fcf59c7a20ab1f49717ca9715358d5e7cac35936b84d89d5579286a3dc50f306#npm:6.0.0", {\ + "packageLocation": "./.yarn/__virtual__/@bull-board-api-virtual-0339be2ffb/0/cache/@bull-board-api-npm-6.0.0-78af39dbec-922e46f2c5.zip/node_modules/@bull-board/api/",\ + "packageDependencies": [\ + ["@bull-board/api", "virtual:1a204444c80de4bcde4565c72a3b0d6571f4eb3f918d7d48d8cd7ddf8392bb94fcf59c7a20ab1f49717ca9715358d5e7cac35936b84d89d5579286a3dc50f306#npm:6.0.0"],\ + ["@bull-board/ui", "npm:6.0.0"],\ + ["@types/bull-board__ui", null],\ + ["redis-info", "npm:3.1.0"]\ + ],\ + "packagePeers": [\ + "@bull-board/ui",\ + "@types/bull-board__ui"\ + ],\ + "linkType": "HARD"\ + }],\ + ["virtual:6d3c013820dba430e71ebb352cb5205445a13ea3c7a848f57a7ff58fb0d6469fe4d374280277dac42cb77a6dbf8e924e64f2f0b3413c28a02da9d890c199e6d7#npm:5.6.0", {\ + "packageLocation": "./.yarn/__virtual__/@bull-board-api-virtual-7db979021f/0/cache/@bull-board-api-npm-5.6.0-a1466ed4c8-951b27f057.zip/node_modules/@bull-board/api/",\ + "packageDependencies": [\ + ["@bull-board/api", "virtual:6d3c013820dba430e71ebb352cb5205445a13ea3c7a848f57a7ff58fb0d6469fe4d374280277dac42cb77a6dbf8e924e64f2f0b3413c28a02da9d890c199e6d7#npm:5.6.0"],\ + ["@bull-board/ui", "npm:5.6.0"],\ + ["@types/bull-board__ui", null],\ + ["redis-info", "npm:3.1.0"]\ + ],\ + "packagePeers": [\ + "@bull-board/ui",\ + "@types/bull-board__ui"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@bull-board/koa", [\ + ["npm:6.0.0", {\ + "packageLocation": "./.yarn/cache/@bull-board-koa-npm-6.0.0-47e7552797-0d1bac34e8.zip/node_modules/@bull-board/koa/",\ + "packageDependencies": [\ + ["@bull-board/api", "virtual:1a204444c80de4bcde4565c72a3b0d6571f4eb3f918d7d48d8cd7ddf8392bb94fcf59c7a20ab1f49717ca9715358d5e7cac35936b84d89d5579286a3dc50f306#npm:6.0.0"],\ + ["@bull-board/koa", "npm:6.0.0"],\ + ["@bull-board/ui", "npm:6.0.0"],\ + ["ejs", "npm:3.1.10"],\ + ["koa", "npm:2.14.2"],\ + ["koa-mount", "npm:4.0.0"],\ + ["koa-router", "npm:10.1.1"],\ + ["koa-static", "npm:5.0.0"],\ + ["koa-views", "virtual:47e7552797ac54a9c2afddff4b4e04d9d332a7c4ae9063d663783ce2e037b701b60b632afdcf95d0a9161f0abbca22d39846d9260e4899a8593e974d45bbf1e0#npm:7.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@bull-board/ui", [\ + ["npm:5.6.0", {\ + "packageLocation": "./.yarn/cache/@bull-board-ui-npm-5.6.0-6d3c013820-b7b982a983.zip/node_modules/@bull-board/ui/",\ + "packageDependencies": [\ + ["@bull-board/api", "virtual:6d3c013820dba430e71ebb352cb5205445a13ea3c7a848f57a7ff58fb0d6469fe4d374280277dac42cb77a6dbf8e924e64f2f0b3413c28a02da9d890c199e6d7#npm:5.6.0"],\ + ["@bull-board/ui", "npm:5.6.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:6.0.0", {\ + "packageLocation": "./.yarn/cache/@bull-board-ui-npm-6.0.0-1a204444c8-6d083063a3.zip/node_modules/@bull-board/ui/",\ + "packageDependencies": [\ + ["@bull-board/api", "virtual:1a204444c80de4bcde4565c72a3b0d6571f4eb3f918d7d48d8cd7ddf8392bb94fcf59c7a20ab1f49717ca9715358d5e7cac35936b84d89d5579286a3dc50f306#npm:6.0.0"],\ + ["@bull-board/ui", "npm:6.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@cropper/element", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/@cropper-element-npm-2.1.1-d9cb6f0715-c12c2c6c78.zip/node_modules/@cropper/element/",\ + "packageDependencies": [\ + ["@cropper/element", "npm:2.1.1"],\ + ["@cropper/utils", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@cropper/element-canvas", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/@cropper-element-canvas-npm-2.1.1-8927f3f203-53f9a0f3e5.zip/node_modules/@cropper/element-canvas/",\ + "packageDependencies": [\ + ["@cropper/element", "npm:2.1.1"],\ + ["@cropper/element-canvas", "npm:2.1.1"],\ + ["@cropper/utils", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@cropper/element-crosshair", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/@cropper-element-crosshair-npm-2.1.1-62d8945963-3e8786db0f.zip/node_modules/@cropper/element-crosshair/",\ + "packageDependencies": [\ + ["@cropper/element", "npm:2.1.1"],\ + ["@cropper/element-crosshair", "npm:2.1.1"],\ + ["@cropper/utils", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@cropper/element-grid", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/@cropper-element-grid-npm-2.1.1-70df4cda5e-6ba46a7a26.zip/node_modules/@cropper/element-grid/",\ + "packageDependencies": [\ + ["@cropper/element", "npm:2.1.1"],\ + ["@cropper/element-grid", "npm:2.1.1"],\ + ["@cropper/utils", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@cropper/element-handle", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/@cropper-element-handle-npm-2.1.1-838e9b5d6b-0f3b475298.zip/node_modules/@cropper/element-handle/",\ + "packageDependencies": [\ + ["@cropper/element", "npm:2.1.1"],\ + ["@cropper/element-handle", "npm:2.1.1"],\ + ["@cropper/utils", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@cropper/element-image", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/@cropper-element-image-npm-2.1.1-c9e3b1ea1e-d351a09815.zip/node_modules/@cropper/element-image/",\ + "packageDependencies": [\ + ["@cropper/element", "npm:2.1.1"],\ + ["@cropper/element-canvas", "npm:2.1.1"],\ + ["@cropper/element-image", "npm:2.1.1"],\ + ["@cropper/utils", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@cropper/element-selection", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/@cropper-element-selection-npm-2.1.1-1fbdcd01a0-abc0599cdb.zip/node_modules/@cropper/element-selection/",\ + "packageDependencies": [\ + ["@cropper/element", "npm:2.1.1"],\ + ["@cropper/element-canvas", "npm:2.1.1"],\ + ["@cropper/element-image", "npm:2.1.1"],\ + ["@cropper/element-selection", "npm:2.1.1"],\ + ["@cropper/utils", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@cropper/element-shade", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/@cropper-element-shade-npm-2.1.1-76a3e7edaf-3dcdc793de.zip/node_modules/@cropper/element-shade/",\ + "packageDependencies": [\ + ["@cropper/element", "npm:2.1.1"],\ + ["@cropper/element-canvas", "npm:2.1.1"],\ + ["@cropper/element-selection", "npm:2.1.1"],\ + ["@cropper/element-shade", "npm:2.1.1"],\ + ["@cropper/utils", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@cropper/element-viewer", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/@cropper-element-viewer-npm-2.1.1-e3c973981f-593dd5ab83.zip/node_modules/@cropper/element-viewer/",\ + "packageDependencies": [\ + ["@cropper/element", "npm:2.1.1"],\ + ["@cropper/element-canvas", "npm:2.1.1"],\ + ["@cropper/element-image", "npm:2.1.1"],\ + ["@cropper/element-selection", "npm:2.1.1"],\ + ["@cropper/element-viewer", "npm:2.1.1"],\ + ["@cropper/utils", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@cropper/elements", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/@cropper-elements-npm-2.1.1-67d8218137-2ff9e549ba.zip/node_modules/@cropper/elements/",\ + "packageDependencies": [\ + ["@cropper/element", "npm:2.1.1"],\ + ["@cropper/element-canvas", "npm:2.1.1"],\ + ["@cropper/element-crosshair", "npm:2.1.1"],\ + ["@cropper/element-grid", "npm:2.1.1"],\ + ["@cropper/element-handle", "npm:2.1.1"],\ + ["@cropper/element-image", "npm:2.1.1"],\ + ["@cropper/element-selection", "npm:2.1.1"],\ + ["@cropper/element-shade", "npm:2.1.1"],\ + ["@cropper/element-viewer", "npm:2.1.1"],\ + ["@cropper/elements", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@cropper/utils", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/@cropper-utils-npm-2.1.1-74473afb2c-43f902ee28.zip/node_modules/@cropper/utils/",\ + "packageDependencies": [\ + ["@cropper/utils", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@csstools/color-helpers", [\ + ["npm:5.1.0", {\ + "packageLocation": "./.yarn/cache/@csstools-color-helpers-npm-5.1.0-d44a2a6134-0138b3d5cc.zip/node_modules/@csstools/color-helpers/",\ + "packageDependencies": [\ + ["@csstools/color-helpers", "npm:5.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@csstools/css-calc", [\ + ["npm:2.1.4", {\ + "packageLocation": "./.yarn/cache/@csstools-css-calc-npm-2.1.4-81c74a3511-06975b650c.zip/node_modules/@csstools/css-calc/",\ + "packageDependencies": [\ + ["@csstools/css-calc", "npm:2.1.4"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:ed5b7465ba8cf0eb21975dec62bfcf6d291ea8fcead25822592225aae1675a11a9ab5730181ed5da294a62f7379a3e67d78ef9ef50d04ba4802b6770c14cecdd#npm:2.1.4", {\ + "packageLocation": "./.yarn/__virtual__/@csstools-css-calc-virtual-768d5baeff/0/cache/@csstools-css-calc-npm-2.1.4-81c74a3511-06975b650c.zip/node_modules/@csstools/css-calc/",\ + "packageDependencies": [\ + ["@csstools/css-calc", "virtual:ed5b7465ba8cf0eb21975dec62bfcf6d291ea8fcead25822592225aae1675a11a9ab5730181ed5da294a62f7379a3e67d78ef9ef50d04ba4802b6770c14cecdd#npm:2.1.4"],\ + ["@csstools/css-parser-algorithms", "virtual:ed5b7465ba8cf0eb21975dec62bfcf6d291ea8fcead25822592225aae1675a11a9ab5730181ed5da294a62f7379a3e67d78ef9ef50d04ba4802b6770c14cecdd#npm:3.0.5"],\ + ["@csstools/css-tokenizer", "npm:3.0.4"],\ + ["@types/csstools__css-parser-algorithms", null],\ + ["@types/csstools__css-tokenizer", null]\ + ],\ + "packagePeers": [\ + "@csstools/css-parser-algorithms",\ + "@csstools/css-tokenizer",\ + "@types/csstools__css-parser-algorithms",\ + "@types/csstools__css-tokenizer"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@csstools/css-color-parser", [\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/@csstools-css-color-parser-npm-3.1.0-005f6b530f-4741095fdc.zip/node_modules/@csstools/css-color-parser/",\ + "packageDependencies": [\ + ["@csstools/css-color-parser", "npm:3.1.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:ed5b7465ba8cf0eb21975dec62bfcf6d291ea8fcead25822592225aae1675a11a9ab5730181ed5da294a62f7379a3e67d78ef9ef50d04ba4802b6770c14cecdd#npm:3.1.0", {\ + "packageLocation": "./.yarn/__virtual__/@csstools-css-color-parser-virtual-67160b3f38/0/cache/@csstools-css-color-parser-npm-3.1.0-005f6b530f-4741095fdc.zip/node_modules/@csstools/css-color-parser/",\ + "packageDependencies": [\ + ["@csstools/color-helpers", "npm:5.1.0"],\ + ["@csstools/css-calc", "virtual:ed5b7465ba8cf0eb21975dec62bfcf6d291ea8fcead25822592225aae1675a11a9ab5730181ed5da294a62f7379a3e67d78ef9ef50d04ba4802b6770c14cecdd#npm:2.1.4"],\ + ["@csstools/css-color-parser", "virtual:ed5b7465ba8cf0eb21975dec62bfcf6d291ea8fcead25822592225aae1675a11a9ab5730181ed5da294a62f7379a3e67d78ef9ef50d04ba4802b6770c14cecdd#npm:3.1.0"],\ + ["@csstools/css-parser-algorithms", "virtual:ed5b7465ba8cf0eb21975dec62bfcf6d291ea8fcead25822592225aae1675a11a9ab5730181ed5da294a62f7379a3e67d78ef9ef50d04ba4802b6770c14cecdd#npm:3.0.5"],\ + ["@csstools/css-tokenizer", "npm:3.0.4"],\ + ["@types/csstools__css-parser-algorithms", null],\ + ["@types/csstools__css-tokenizer", null]\ + ],\ + "packagePeers": [\ + "@csstools/css-parser-algorithms",\ + "@csstools/css-tokenizer",\ + "@types/csstools__css-parser-algorithms",\ + "@types/csstools__css-tokenizer"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@csstools/css-parser-algorithms", [\ + ["npm:3.0.5", {\ + "packageLocation": "./.yarn/cache/@csstools-css-parser-algorithms-npm-3.0.5-a0aa2fe05e-e93083b5cb.zip/node_modules/@csstools/css-parser-algorithms/",\ + "packageDependencies": [\ + ["@csstools/css-parser-algorithms", "npm:3.0.5"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:ed5b7465ba8cf0eb21975dec62bfcf6d291ea8fcead25822592225aae1675a11a9ab5730181ed5da294a62f7379a3e67d78ef9ef50d04ba4802b6770c14cecdd#npm:3.0.5", {\ + "packageLocation": "./.yarn/__virtual__/@csstools-css-parser-algorithms-virtual-acabe76f5f/0/cache/@csstools-css-parser-algorithms-npm-3.0.5-a0aa2fe05e-e93083b5cb.zip/node_modules/@csstools/css-parser-algorithms/",\ + "packageDependencies": [\ + ["@csstools/css-parser-algorithms", "virtual:ed5b7465ba8cf0eb21975dec62bfcf6d291ea8fcead25822592225aae1675a11a9ab5730181ed5da294a62f7379a3e67d78ef9ef50d04ba4802b6770c14cecdd#npm:3.0.5"],\ + ["@csstools/css-tokenizer", "npm:3.0.4"],\ + ["@types/csstools__css-tokenizer", null]\ + ],\ + "packagePeers": [\ + "@csstools/css-tokenizer",\ + "@types/csstools__css-tokenizer"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@csstools/css-tokenizer", [\ + ["npm:3.0.4", {\ + "packageLocation": "./.yarn/cache/@csstools-css-tokenizer-npm-3.0.4-342d48d326-eb6c84c086.zip/node_modules/@csstools/css-tokenizer/",\ + "packageDependencies": [\ + ["@csstools/css-tokenizer", "npm:3.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@digitalbazaar/http-client", [\ + ["npm:3.4.1", {\ + "packageLocation": "./.yarn/cache/@digitalbazaar-http-client-npm-3.4.1-8f7eac0be7-a819dcbb20.zip/node_modules/@digitalbazaar/http-client/",\ + "packageDependencies": [\ + ["@digitalbazaar/http-client", "npm:3.4.1"],\ + ["ky", "npm:0.33.3"],\ + ["ky-universal", "virtual:8f7eac0be74664d725f06767d8b014c5441ef91d67e5f5b4d48b1eb4b262e4666123cac7be8dcbb99a7798fb4a21e6f4d9693a3957796c4a48c23e26af86514f#npm:0.11.0"],\ + ["undici", "npm:5.22.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@discordapp/twemoji", [\ + ["npm:16.0.1", {\ + "packageLocation": "./.yarn/unplugged/@discordapp-twemoji-npm-16.0.1-50d71ee905/node_modules/@discordapp/twemoji/",\ + "packageDependencies": [\ + ["@discordapp/twemoji", "npm:16.0.1"],\ + ["@twemoji/parser", "npm:16.0.0"],\ + ["fs-extra", "npm:8.1.0"],\ + ["jsonfile", "npm:5.0.0"],\ + ["universalify", "npm:0.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@emnapi/runtime", [\ + ["npm:1.3.1", {\ + "packageLocation": "./.yarn/cache/@emnapi-runtime-npm-1.3.1-64fd359241-619915ee44.zip/node_modules/@emnapi/runtime/",\ + "packageDependencies": [\ + ["@emnapi/runtime", "npm:1.3.1"],\ + ["tslib", "npm:2.6.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@epic-web/invariant", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/@epic-web-invariant-npm-1.0.0-a4ac7c9c5e-28b36a7447.zip/node_modules/@epic-web/invariant/",\ + "packageDependencies": [\ + ["@epic-web/invariant", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/aix-ppc64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-aix-ppc64-npm-0.27.7-772b4c7734/node_modules/@esbuild/aix-ppc64/",\ + "packageDependencies": [\ + ["@esbuild/aix-ppc64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-aix-ppc64-npm-0.28.0-f6acc91844/node_modules/@esbuild/aix-ppc64/",\ + "packageDependencies": [\ + ["@esbuild/aix-ppc64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/android-arm", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-android-arm-npm-0.27.7-429fa41d7b/node_modules/@esbuild/android-arm/",\ + "packageDependencies": [\ + ["@esbuild/android-arm", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-android-arm-npm-0.28.0-ad57e515c6/node_modules/@esbuild/android-arm/",\ + "packageDependencies": [\ + ["@esbuild/android-arm", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/android-arm64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-android-arm64-npm-0.27.7-07c052612f/node_modules/@esbuild/android-arm64/",\ + "packageDependencies": [\ + ["@esbuild/android-arm64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-android-arm64-npm-0.28.0-6d91cc5aac/node_modules/@esbuild/android-arm64/",\ + "packageDependencies": [\ + ["@esbuild/android-arm64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/android-x64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-android-x64-npm-0.27.7-7a2378f303/node_modules/@esbuild/android-x64/",\ + "packageDependencies": [\ + ["@esbuild/android-x64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-android-x64-npm-0.28.0-660b77d2b6/node_modules/@esbuild/android-x64/",\ + "packageDependencies": [\ + ["@esbuild/android-x64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/darwin-arm64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-darwin-arm64-npm-0.27.7-a0319000e7/node_modules/@esbuild/darwin-arm64/",\ + "packageDependencies": [\ + ["@esbuild/darwin-arm64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-darwin-arm64-npm-0.28.0-5b2f673123/node_modules/@esbuild/darwin-arm64/",\ + "packageDependencies": [\ + ["@esbuild/darwin-arm64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/darwin-x64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-darwin-x64-npm-0.27.7-2966d80d22/node_modules/@esbuild/darwin-x64/",\ + "packageDependencies": [\ + ["@esbuild/darwin-x64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-darwin-x64-npm-0.28.0-8f8f986b3e/node_modules/@esbuild/darwin-x64/",\ + "packageDependencies": [\ + ["@esbuild/darwin-x64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/freebsd-arm64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-freebsd-arm64-npm-0.27.7-a93faacd83/node_modules/@esbuild/freebsd-arm64/",\ + "packageDependencies": [\ + ["@esbuild/freebsd-arm64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-freebsd-arm64-npm-0.28.0-a6ff95e0e1/node_modules/@esbuild/freebsd-arm64/",\ + "packageDependencies": [\ + ["@esbuild/freebsd-arm64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/freebsd-x64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-freebsd-x64-npm-0.27.7-8e5cda0dbb/node_modules/@esbuild/freebsd-x64/",\ + "packageDependencies": [\ + ["@esbuild/freebsd-x64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-freebsd-x64-npm-0.28.0-0c54f30254/node_modules/@esbuild/freebsd-x64/",\ + "packageDependencies": [\ + ["@esbuild/freebsd-x64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/linux-arm", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-arm-npm-0.27.7-c957dca1fe/node_modules/@esbuild/linux-arm/",\ + "packageDependencies": [\ + ["@esbuild/linux-arm", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-arm-npm-0.28.0-033d644548/node_modules/@esbuild/linux-arm/",\ + "packageDependencies": [\ + ["@esbuild/linux-arm", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/linux-arm64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-arm64-npm-0.27.7-ed035ebcb5/node_modules/@esbuild/linux-arm64/",\ + "packageDependencies": [\ + ["@esbuild/linux-arm64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-arm64-npm-0.28.0-4042417272/node_modules/@esbuild/linux-arm64/",\ + "packageDependencies": [\ + ["@esbuild/linux-arm64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/linux-ia32", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-ia32-npm-0.27.7-3962a363f8/node_modules/@esbuild/linux-ia32/",\ + "packageDependencies": [\ + ["@esbuild/linux-ia32", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-ia32-npm-0.28.0-29e62efd53/node_modules/@esbuild/linux-ia32/",\ + "packageDependencies": [\ + ["@esbuild/linux-ia32", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/linux-loong64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-loong64-npm-0.27.7-52cfe4bbe3/node_modules/@esbuild/linux-loong64/",\ + "packageDependencies": [\ + ["@esbuild/linux-loong64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-loong64-npm-0.28.0-0a218ff526/node_modules/@esbuild/linux-loong64/",\ + "packageDependencies": [\ + ["@esbuild/linux-loong64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/linux-mips64el", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-mips64el-npm-0.27.7-1c5fd336b5/node_modules/@esbuild/linux-mips64el/",\ + "packageDependencies": [\ + ["@esbuild/linux-mips64el", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-mips64el-npm-0.28.0-209ac1f0eb/node_modules/@esbuild/linux-mips64el/",\ + "packageDependencies": [\ + ["@esbuild/linux-mips64el", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/linux-ppc64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-ppc64-npm-0.27.7-61f7028345/node_modules/@esbuild/linux-ppc64/",\ + "packageDependencies": [\ + ["@esbuild/linux-ppc64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-ppc64-npm-0.28.0-52e8165024/node_modules/@esbuild/linux-ppc64/",\ + "packageDependencies": [\ + ["@esbuild/linux-ppc64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/linux-riscv64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-riscv64-npm-0.27.7-c1a96aac7e/node_modules/@esbuild/linux-riscv64/",\ + "packageDependencies": [\ + ["@esbuild/linux-riscv64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-riscv64-npm-0.28.0-5c87eb4e54/node_modules/@esbuild/linux-riscv64/",\ + "packageDependencies": [\ + ["@esbuild/linux-riscv64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/linux-s390x", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-s390x-npm-0.27.7-f5831733de/node_modules/@esbuild/linux-s390x/",\ + "packageDependencies": [\ + ["@esbuild/linux-s390x", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-s390x-npm-0.28.0-2987061487/node_modules/@esbuild/linux-s390x/",\ + "packageDependencies": [\ + ["@esbuild/linux-s390x", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/linux-x64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-x64-npm-0.27.7-565545a421/node_modules/@esbuild/linux-x64/",\ + "packageDependencies": [\ + ["@esbuild/linux-x64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-linux-x64-npm-0.28.0-b9b390819a/node_modules/@esbuild/linux-x64/",\ + "packageDependencies": [\ + ["@esbuild/linux-x64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/netbsd-arm64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-netbsd-arm64-npm-0.27.7-558ef9c574/node_modules/@esbuild/netbsd-arm64/",\ + "packageDependencies": [\ + ["@esbuild/netbsd-arm64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-netbsd-arm64-npm-0.28.0-35029917ba/node_modules/@esbuild/netbsd-arm64/",\ + "packageDependencies": [\ + ["@esbuild/netbsd-arm64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/netbsd-x64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-netbsd-x64-npm-0.27.7-4c7f4037fd/node_modules/@esbuild/netbsd-x64/",\ + "packageDependencies": [\ + ["@esbuild/netbsd-x64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-netbsd-x64-npm-0.28.0-28061fb933/node_modules/@esbuild/netbsd-x64/",\ + "packageDependencies": [\ + ["@esbuild/netbsd-x64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/openbsd-arm64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-openbsd-arm64-npm-0.27.7-3abf23c14d/node_modules/@esbuild/openbsd-arm64/",\ + "packageDependencies": [\ + ["@esbuild/openbsd-arm64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-openbsd-arm64-npm-0.28.0-1c04ad499a/node_modules/@esbuild/openbsd-arm64/",\ + "packageDependencies": [\ + ["@esbuild/openbsd-arm64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/openbsd-x64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-openbsd-x64-npm-0.27.7-29291c7ad9/node_modules/@esbuild/openbsd-x64/",\ + "packageDependencies": [\ + ["@esbuild/openbsd-x64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-openbsd-x64-npm-0.28.0-514ec76c32/node_modules/@esbuild/openbsd-x64/",\ + "packageDependencies": [\ + ["@esbuild/openbsd-x64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/openharmony-arm64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-openharmony-arm64-npm-0.27.7-f9fb3d2c0b/node_modules/@esbuild/openharmony-arm64/",\ + "packageDependencies": [\ + ["@esbuild/openharmony-arm64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-openharmony-arm64-npm-0.28.0-d9650fc1f8/node_modules/@esbuild/openharmony-arm64/",\ + "packageDependencies": [\ + ["@esbuild/openharmony-arm64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/sunos-x64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-sunos-x64-npm-0.27.7-e0c917a507/node_modules/@esbuild/sunos-x64/",\ + "packageDependencies": [\ + ["@esbuild/sunos-x64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-sunos-x64-npm-0.28.0-fd35202fcd/node_modules/@esbuild/sunos-x64/",\ + "packageDependencies": [\ + ["@esbuild/sunos-x64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/win32-arm64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-win32-arm64-npm-0.27.7-7394ad2c25/node_modules/@esbuild/win32-arm64/",\ + "packageDependencies": [\ + ["@esbuild/win32-arm64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-win32-arm64-npm-0.28.0-77c6272795/node_modules/@esbuild/win32-arm64/",\ + "packageDependencies": [\ + ["@esbuild/win32-arm64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/win32-ia32", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-win32-ia32-npm-0.27.7-c720e2c640/node_modules/@esbuild/win32-ia32/",\ + "packageDependencies": [\ + ["@esbuild/win32-ia32", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-win32-ia32-npm-0.28.0-94f8464af9/node_modules/@esbuild/win32-ia32/",\ + "packageDependencies": [\ + ["@esbuild/win32-ia32", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@esbuild/win32-x64", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-win32-x64-npm-0.27.7-18b847315a/node_modules/@esbuild/win32-x64/",\ + "packageDependencies": [\ + ["@esbuild/win32-x64", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/@esbuild-win32-x64-npm-0.28.0-a4f1e967cb/node_modules/@esbuild/win32-x64/",\ + "packageDependencies": [\ + ["@esbuild/win32-x64", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@iceshrimp/summaly", [\ + ["npm:2.7.3::__archiveUrl=https%3A%2F%2Ficeshrimp.dev%2Fapi%2Fpackages%2Ficeshrimp%2Fnpm%2F%2540iceshrimp%252Fsummaly%2F-%2F2.7.3%2Fsummaly-2.7.3.tgz", {\ + "packageLocation": "./.yarn/cache/@iceshrimp-summaly-npm-2.7.3-74d1fab517-a8f0d0617b.zip/node_modules/@iceshrimp/summaly/",\ + "packageDependencies": [\ + ["@iceshrimp/summaly", "npm:2.7.3::__archiveUrl=https%3A%2F%2Ficeshrimp.dev%2Fapi%2Fpackages%2Ficeshrimp%2Fnpm%2F%2540iceshrimp%252Fsummaly%2F-%2F2.7.3%2Fsummaly-2.7.3.tgz"],\ + ["cheerio", "npm:0.22.0"],\ + ["debug", "virtual:74d1fab5177fa3d9b5330713f9165a97e07bf791e6588c23ea17dde3e0cee950bb18c0366fe88ff7556f854762863ea192e2ad8db90eee046b5cb7c387ad9e72#npm:4.3.3"],\ + ["escape-regexp", "npm:0.0.1"],\ + ["got", "npm:11.8.5"],\ + ["html-entities", "npm:2.3.2"],\ + ["iconv-lite", "npm:0.6.3"],\ + ["jschardet", "npm:3.0.0"],\ + ["koa", "npm:2.13.4"],\ + ["private-ip", "npm:2.3.3"],\ + ["require-all", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-darwin-arm64", [\ + ["npm:0.33.5", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-darwin-arm64-npm-0.33.5-c319591c53/node_modules/@img/sharp-darwin-arm64/",\ + "packageDependencies": [\ + ["@img/sharp-darwin-arm64", "npm:0.33.5"],\ + ["@img/sharp-libvips-darwin-arm64", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-darwin-x64", [\ + ["npm:0.33.5", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-darwin-x64-npm-0.33.5-785c54564a/node_modules/@img/sharp-darwin-x64/",\ + "packageDependencies": [\ + ["@img/sharp-darwin-x64", "npm:0.33.5"],\ + ["@img/sharp-libvips-darwin-x64", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-libvips-darwin-arm64", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-libvips-darwin-arm64-npm-1.0.4-d0d063884a/node_modules/@img/sharp-libvips-darwin-arm64/",\ + "packageDependencies": [\ + ["@img/sharp-libvips-darwin-arm64", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-libvips-darwin-x64", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-libvips-darwin-x64-npm-1.0.4-6fde8e50e0/node_modules/@img/sharp-libvips-darwin-x64/",\ + "packageDependencies": [\ + ["@img/sharp-libvips-darwin-x64", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-libvips-linux-arm", [\ + ["npm:1.0.5", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-libvips-linux-arm-npm-1.0.5-99ec104f55/node_modules/@img/sharp-libvips-linux-arm/",\ + "packageDependencies": [\ + ["@img/sharp-libvips-linux-arm", "npm:1.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-libvips-linux-arm64", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-libvips-linux-arm64-npm-1.0.4-24a3d8b19a/node_modules/@img/sharp-libvips-linux-arm64/",\ + "packageDependencies": [\ + ["@img/sharp-libvips-linux-arm64", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-libvips-linux-s390x", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-libvips-linux-s390x-npm-1.0.4-c4ea54fdc1/node_modules/@img/sharp-libvips-linux-s390x/",\ + "packageDependencies": [\ + ["@img/sharp-libvips-linux-s390x", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-libvips-linux-x64", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-libvips-linux-x64-npm-1.0.4-0974f077b7/node_modules/@img/sharp-libvips-linux-x64/",\ + "packageDependencies": [\ + ["@img/sharp-libvips-linux-x64", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-libvips-linuxmusl-arm64", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-libvips-linuxmusl-arm64-npm-1.0.4-c63b2fb991/node_modules/@img/sharp-libvips-linuxmusl-arm64/",\ + "packageDependencies": [\ + ["@img/sharp-libvips-linuxmusl-arm64", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-libvips-linuxmusl-x64", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-libvips-linuxmusl-x64-npm-1.0.4-ea67a00cef/node_modules/@img/sharp-libvips-linuxmusl-x64/",\ + "packageDependencies": [\ + ["@img/sharp-libvips-linuxmusl-x64", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-linux-arm", [\ + ["npm:0.33.5", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-linux-arm-npm-0.33.5-2c7cd6ba15/node_modules/@img/sharp-linux-arm/",\ + "packageDependencies": [\ + ["@img/sharp-libvips-linux-arm", "npm:1.0.5"],\ + ["@img/sharp-linux-arm", "npm:0.33.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-linux-arm64", [\ + ["npm:0.33.5", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-linux-arm64-npm-0.33.5-9d6c17ffc3/node_modules/@img/sharp-linux-arm64/",\ + "packageDependencies": [\ + ["@img/sharp-libvips-linux-arm64", "npm:1.0.4"],\ + ["@img/sharp-linux-arm64", "npm:0.33.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-linux-s390x", [\ + ["npm:0.33.5", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-linux-s390x-npm-0.33.5-e9edc1d1ea/node_modules/@img/sharp-linux-s390x/",\ + "packageDependencies": [\ + ["@img/sharp-libvips-linux-s390x", "npm:1.0.4"],\ + ["@img/sharp-linux-s390x", "npm:0.33.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-linux-x64", [\ + ["npm:0.33.5", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-linux-x64-npm-0.33.5-1b6c430eb4/node_modules/@img/sharp-linux-x64/",\ + "packageDependencies": [\ + ["@img/sharp-libvips-linux-x64", "npm:1.0.4"],\ + ["@img/sharp-linux-x64", "npm:0.33.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-linuxmusl-arm64", [\ + ["npm:0.33.5", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-linuxmusl-arm64-npm-0.33.5-686a8ec1a7/node_modules/@img/sharp-linuxmusl-arm64/",\ + "packageDependencies": [\ + ["@img/sharp-libvips-linuxmusl-arm64", "npm:1.0.4"],\ + ["@img/sharp-linuxmusl-arm64", "npm:0.33.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-linuxmusl-x64", [\ + ["npm:0.33.5", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-linuxmusl-x64-npm-0.33.5-b88b11869b/node_modules/@img/sharp-linuxmusl-x64/",\ + "packageDependencies": [\ + ["@img/sharp-libvips-linuxmusl-x64", "npm:1.0.4"],\ + ["@img/sharp-linuxmusl-x64", "npm:0.33.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-wasm32", [\ + ["npm:0.33.5", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-wasm32-npm-0.33.5-e49bff60db/node_modules/@img/sharp-wasm32/",\ + "packageDependencies": [\ + ["@emnapi/runtime", "npm:1.3.1"],\ + ["@img/sharp-wasm32", "npm:0.33.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-win32-ia32", [\ + ["npm:0.33.5", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-win32-ia32-npm-0.33.5-531493b2d4/node_modules/@img/sharp-win32-ia32/",\ + "packageDependencies": [\ + ["@img/sharp-win32-ia32", "npm:0.33.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@img/sharp-win32-x64", [\ + ["npm:0.33.5", {\ + "packageLocation": "./.yarn/unplugged/@img-sharp-win32-x64-npm-0.33.5-e9e45d0448/node_modules/@img/sharp-win32-x64/",\ + "packageDependencies": [\ + ["@img/sharp-win32-x64", "npm:0.33.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@ioredis/commands", [\ + ["npm:1.2.0", {\ + "packageLocation": "./.yarn/cache/@ioredis-commands-npm-1.2.0-47541de88b-a8253c9539.zip/node_modules/@ioredis/commands/",\ + "packageDependencies": [\ + ["@ioredis/commands", "npm:1.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@isaacs/cliui", [\ + ["npm:8.0.2", {\ + "packageLocation": "./.yarn/cache/@isaacs-cliui-npm-8.0.2-f4364666d5-e9ed5fd27c.zip/node_modules/@isaacs/cliui/",\ + "packageDependencies": [\ + ["@isaacs/cliui", "npm:8.0.2"],\ + ["string-width", "npm:5.1.2"],\ + ["string-width-cjs", [\ + "string-width",\ + "npm:4.2.3"\ + ]],\ + ["strip-ansi", "npm:7.1.0"],\ + ["strip-ansi-cjs", [\ + "strip-ansi",\ + "npm:6.0.1"\ + ]],\ + ["wrap-ansi", "npm:8.1.0"],\ + ["wrap-ansi-cjs", [\ + "wrap-ansi",\ + "npm:7.0.0"\ + ]]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@isaacs/fs-minipass", [\ + ["npm:4.0.1", {\ + "packageLocation": "./.yarn/cache/@isaacs-fs-minipass-npm-4.0.1-677026e841-4412e9e671.zip/node_modules/@isaacs/fs-minipass/",\ + "packageDependencies": [\ + ["@isaacs/fs-minipass", "npm:4.0.1"],\ + ["minipass", "npm:7.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@jridgewell/gen-mapping", [\ + ["npm:0.3.3", {\ + "packageLocation": "./.yarn/cache/@jridgewell-gen-mapping-npm-0.3.3-1815eba94c-072ace159c.zip/node_modules/@jridgewell/gen-mapping/",\ + "packageDependencies": [\ + ["@jridgewell/gen-mapping", "npm:0.3.3"],\ + ["@jridgewell/set-array", "npm:1.1.2"],\ + ["@jridgewell/sourcemap-codec", "npm:1.4.15"],\ + ["@jridgewell/trace-mapping", "npm:0.3.18"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@jridgewell/resolve-uri", [\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/@jridgewell-resolve-uri-npm-3.1.0-6ff2351e61-320ceb37af.zip/node_modules/@jridgewell/resolve-uri/",\ + "packageDependencies": [\ + ["@jridgewell/resolve-uri", "npm:3.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@jridgewell/set-array", [\ + ["npm:1.1.2", {\ + "packageLocation": "./.yarn/cache/@jridgewell-set-array-npm-1.1.2-45b82d7fb6-69a84d5980.zip/node_modules/@jridgewell/set-array/",\ + "packageDependencies": [\ + ["@jridgewell/set-array", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@jridgewell/sourcemap-codec", [\ + ["npm:1.4.14", {\ + "packageLocation": "./.yarn/cache/@jridgewell-sourcemap-codec-npm-1.4.14-f5f0630788-26e768fae6.zip/node_modules/@jridgewell/sourcemap-codec/",\ + "packageDependencies": [\ + ["@jridgewell/sourcemap-codec", "npm:1.4.14"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.4.15", {\ + "packageLocation": "./.yarn/cache/@jridgewell-sourcemap-codec-npm-1.4.15-a055fb62cf-89960ac087.zip/node_modules/@jridgewell/sourcemap-codec/",\ + "packageDependencies": [\ + ["@jridgewell/sourcemap-codec", "npm:1.4.15"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.5.5", {\ + "packageLocation": "./.yarn/cache/@jridgewell-sourcemap-codec-npm-1.5.5-5189d9fc79-5d9d207b46.zip/node_modules/@jridgewell/sourcemap-codec/",\ + "packageDependencies": [\ + ["@jridgewell/sourcemap-codec", "npm:1.5.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@jridgewell/trace-mapping", [\ + ["npm:0.3.18", {\ + "packageLocation": "./.yarn/cache/@jridgewell-trace-mapping-npm-0.3.18-cd96571385-f4fabdddf8.zip/node_modules/@jridgewell/trace-mapping/",\ + "packageDependencies": [\ + ["@jridgewell/resolve-uri", "npm:3.1.0"],\ + ["@jridgewell/sourcemap-codec", "npm:1.4.14"],\ + ["@jridgewell/trace-mapping", "npm:0.3.18"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@keyv/serialize", [\ + ["npm:1.1.1", {\ + "packageLocation": "./.yarn/cache/@keyv-serialize-npm-1.1.1-f3de0708ec-e3b2cb1377.zip/node_modules/@keyv/serialize/",\ + "packageDependencies": [\ + ["@keyv/serialize", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@koa/cors", [\ + ["npm:3.4.3", {\ + "packageLocation": "./.yarn/cache/@koa-cors-npm-3.4.3-2713c012f1-7e91b661a2.zip/node_modules/@koa/cors/",\ + "packageDependencies": [\ + ["@koa/cors", "npm:3.4.3"],\ + ["vary", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@koa/multer", [\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/@koa-multer-npm-4.0.0-c676a71633-aba7a71fab.zip/node_modules/@koa/multer/",\ + "packageDependencies": [\ + ["@koa/multer", "npm:4.0.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:4.0.0", {\ + "packageLocation": "./.yarn/__virtual__/@koa-multer-virtual-04fc6bc834/0/cache/@koa-multer-npm-4.0.0-c676a71633-aba7a71fab.zip/node_modules/@koa/multer/",\ + "packageDependencies": [\ + ["@koa/multer", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:4.0.0"],\ + ["@types/koa", "npm:2.13.6"],\ + ["@types/multer", null],\ + ["koa", "npm:2.16.4"],\ + ["multer", "npm:2.1.1"]\ + ],\ + "packagePeers": [\ + "@types/koa",\ + "@types/multer",\ + "koa",\ + "multer"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@koa/router", [\ + ["npm:15.5.0", {\ + "packageLocation": "./.yarn/cache/@koa-router-npm-15.5.0-e9960b43a6-4974c4e65c.zip/node_modules/@koa/router/",\ + "packageDependencies": [\ + ["@koa/router", "npm:15.5.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:15.5.0", {\ + "packageLocation": "./.yarn/__virtual__/@koa-router-virtual-c89911227f/0/cache/@koa-router-npm-15.5.0-e9960b43a6-4974c4e65c.zip/node_modules/@koa/router/",\ + "packageDependencies": [\ + ["@koa/router", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:15.5.0"],\ + ["@types/koa", "npm:2.13.6"],\ + ["debug", "virtual:90a0f1fb5c11f2caeade015df18a36b1fbdd43c7dd5da4b8fc27a92da9a256be3f461a218d644a2c25bbbb94dccf8169d67cc52a5c2857f0f996be9f75f65682#npm:4.4.3"],\ + ["http-errors", "npm:2.0.1"],\ + ["koa", "npm:2.16.4"],\ + ["koa-compose", "npm:4.1.0"],\ + ["path-to-regexp", "npm:8.4.2"]\ + ],\ + "packagePeers": [\ + "@types/koa",\ + "koa"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@kurkle/color", [\ + ["npm:0.3.2", {\ + "packageLocation": "./.yarn/cache/@kurkle-color-npm-0.3.2-98f2086013-079c4b7688.zip/node_modules/@kurkle/color/",\ + "packageDependencies": [\ + ["@kurkle/color", "npm:0.3.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@ladjs/consolidate", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/@ladjs-consolidate-npm-1.0.1-775ac6f627-ca401c7090.zip/node_modules/@ladjs/consolidate/",\ + "packageDependencies": [\ + ["@ladjs/consolidate", "npm:1.0.1"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:02775651b005880128e4f9b39ab8f78565ba46ef3e4875f91f1fb702ae5b7df99dc203b26ae54230cc4a7173329d2306ee63e4d9c8ac260f9af074e484d8f11e#npm:1.0.1", {\ + "packageLocation": "./.yarn/__virtual__/@ladjs-consolidate-virtual-4148de802d/0/cache/@ladjs-consolidate-npm-1.0.1-775ac6f627-ca401c7090.zip/node_modules/@ladjs/consolidate/",\ + "packageDependencies": [\ + ["@babel/core", null],\ + ["@ladjs/consolidate", "virtual:02775651b005880128e4f9b39ab8f78565ba46ef3e4875f91f1fb702ae5b7df99dc203b26ae54230cc4a7173329d2306ee63e4d9c8ac260f9af074e484d8f11e#npm:1.0.1"],\ + ["@types/arc-templates", null],\ + ["@types/atpl", null],\ + ["@types/babel__core", null],\ + ["@types/bracket-template", null],\ + ["@types/coffee-script", null],\ + ["@types/dot", null],\ + ["@types/dust", null],\ + ["@types/dustjs-helpers", null],\ + ["@types/dustjs-linkedin", null],\ + ["@types/eco", null],\ + ["@types/ect", null],\ + ["@types/ejs", null],\ + ["@types/haml-coffee", null],\ + ["@types/hamlet", null],\ + ["@types/hamljs", null],\ + ["@types/handlebars", null],\ + ["@types/hogan.js", null],\ + ["@types/htmling", null],\ + ["@types/jazz", null],\ + ["@types/jqtpl", null],\ + ["@types/just", null],\ + ["@types/liquid-node", null],\ + ["@types/liquor", null],\ + ["@types/lodash", null],\ + ["@types/mote", null],\ + ["@types/mustache", null],\ + ["@types/nunjucks", null],\ + ["@types/plates", null],\ + ["@types/pug", null],\ + ["@types/qejs", null],\ + ["@types/ractive", null],\ + ["@types/react", null],\ + ["@types/react-dom", null],\ + ["@types/slm", null],\ + ["@types/swig", null],\ + ["@types/swig-templates", null],\ + ["@types/teacup", null],\ + ["@types/templayed", null],\ + ["@types/then-pug", null],\ + ["@types/tinyliquid", null],\ + ["@types/toffee", null],\ + ["@types/twig", null],\ + ["@types/twing", null],\ + ["@types/underscore", null],\ + ["@types/vash", null],\ + ["@types/velocityjs", null],\ + ["@types/walrus", null],\ + ["@types/whiskers", null],\ + ["arc-templates", null],\ + ["atpl", null],\ + ["bracket-template", null],\ + ["coffee-script", null],\ + ["dot", null],\ + ["dust", null],\ + ["dustjs-helpers", null],\ + ["dustjs-linkedin", null],\ + ["eco", null],\ + ["ect", null],\ + ["ejs", null],\ + ["haml-coffee", null],\ + ["hamlet", null],\ + ["hamljs", null],\ + ["handlebars", null],\ + ["hogan.js", null],\ + ["htmling", null],\ + ["jazz", null],\ + ["jqtpl", null],\ + ["just", null],\ + ["liquid-node", null],\ + ["liquor", null],\ + ["lodash", null],\ + ["mote", null],\ + ["mustache", null],\ + ["nunjucks", null],\ + ["plates", null],\ + ["pug", null],\ + ["qejs", null],\ + ["ractive", null],\ + ["react", null],\ + ["react-dom", null],\ + ["slm", null],\ + ["swig", null],\ + ["swig-templates", null],\ + ["teacup", null],\ + ["templayed", null],\ + ["then-pug", null],\ + ["tinyliquid", null],\ + ["toffee", null],\ + ["twig", null],\ + ["twing", null],\ + ["underscore", null],\ + ["vash", null],\ + ["velocityjs", null],\ + ["walrus", null],\ + ["whiskers", null]\ + ],\ + "packagePeers": [\ + "@babel/core",\ + "@types/arc-templates",\ + "@types/atpl",\ + "@types/babel__core",\ + "@types/bracket-template",\ + "@types/coffee-script",\ + "@types/dot",\ + "@types/dust",\ + "@types/dustjs-helpers",\ + "@types/dustjs-linkedin",\ + "@types/eco",\ + "@types/ect",\ + "@types/ejs",\ + "@types/haml-coffee",\ + "@types/hamlet",\ + "@types/hamljs",\ + "@types/handlebars",\ + "@types/hogan.js",\ + "@types/htmling",\ + "@types/jazz",\ + "@types/jqtpl",\ + "@types/just",\ + "@types/liquid-node",\ + "@types/liquor",\ + "@types/lodash",\ + "@types/mote",\ + "@types/mustache",\ + "@types/nunjucks",\ + "@types/plates",\ + "@types/pug",\ + "@types/qejs",\ + "@types/ractive",\ + "@types/react-dom",\ + "@types/react",\ + "@types/slm",\ + "@types/swig-templates",\ + "@types/swig",\ + "@types/teacup",\ + "@types/templayed",\ + "@types/then-pug",\ + "@types/tinyliquid",\ + "@types/toffee",\ + "@types/twig",\ + "@types/twing",\ + "@types/underscore",\ + "@types/vash",\ + "@types/velocityjs",\ + "@types/walrus",\ + "@types/whiskers",\ + "arc-templates",\ + "atpl",\ + "bracket-template",\ + "coffee-script",\ + "dot",\ + "dust",\ + "dustjs-helpers",\ + "dustjs-linkedin",\ + "eco",\ + "ect",\ + "ejs",\ + "haml-coffee",\ + "hamlet",\ + "hamljs",\ + "handlebars",\ + "hogan.js",\ + "htmling",\ + "jazz",\ + "jqtpl",\ + "just",\ + "liquid-node",\ + "liquor",\ + "lodash",\ + "mote",\ + "mustache",\ + "nunjucks",\ + "plates",\ + "pug",\ + "qejs",\ + "ractive",\ + "react-dom",\ + "react",\ + "slm",\ + "swig-templates",\ + "swig",\ + "teacup",\ + "templayed",\ + "then-pug",\ + "tinyliquid",\ + "toffee",\ + "twig",\ + "twing",\ + "underscore",\ + "vash",\ + "velocityjs",\ + "walrus",\ + "whiskers"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@microsoft/api-documenter", [\ + ["npm:7.22.30", {\ + "packageLocation": "./.yarn/unplugged/@microsoft-api-documenter-npm-7.22.30-6bd13d02bb/node_modules/@microsoft/api-documenter/",\ + "packageDependencies": [\ + ["@microsoft/api-documenter", "npm:7.22.30"],\ + ["@microsoft/api-extractor-model", "npm:7.27.5"],\ + ["@microsoft/tsdoc", "npm:0.14.2"],\ + ["@rushstack/node-core-library", "virtual:e07ffcf28e4f5faed173552686c5c7136799d9c4e5fdc70ac4b6d44ac84116dbf10565cb2a5674fa37d7063aa70a909694636f515e9868b9bdaa9a073470e36a#npm:3.59.6"],\ + ["@rushstack/ts-command-line", "npm:4.15.1"],\ + ["colors", "npm:1.2.5"],\ + ["js-yaml", "npm:3.13.1"],\ + ["resolve", "patch:resolve@npm%3A1.22.2#optional!builtin::version=1.22.2&hash=c3c19d"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@microsoft/api-extractor", [\ + ["npm:7.36.3", {\ + "packageLocation": "./.yarn/unplugged/@microsoft-api-extractor-npm-7.36.3-f34bd66ce7/node_modules/@microsoft/api-extractor/",\ + "packageDependencies": [\ + ["@microsoft/api-extractor", "npm:7.36.3"],\ + ["@microsoft/api-extractor-model", "npm:7.27.5"],\ + ["@microsoft/tsdoc", "npm:0.14.2"],\ + ["@microsoft/tsdoc-config", "npm:0.16.2"],\ + ["@rushstack/node-core-library", "virtual:e07ffcf28e4f5faed173552686c5c7136799d9c4e5fdc70ac4b6d44ac84116dbf10565cb2a5674fa37d7063aa70a909694636f515e9868b9bdaa9a073470e36a#npm:3.59.6"],\ + ["@rushstack/rig-package", "npm:0.4.0"],\ + ["@rushstack/ts-command-line", "npm:4.15.1"],\ + ["colors", "npm:1.2.5"],\ + ["lodash", "npm:4.17.21"],\ + ["resolve", "patch:resolve@npm%3A1.22.2#optional!builtin::version=1.22.2&hash=c3c19d"],\ + ["semver", "npm:7.5.4"],\ + ["source-map", "npm:0.6.1"],\ + ["typescript", "patch:typescript@npm%3A5.0.4#optional!builtin::version=5.0.4&hash=b5f058"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@microsoft/api-extractor-model", [\ + ["npm:7.27.5", {\ + "packageLocation": "./.yarn/unplugged/@microsoft-api-extractor-model-npm-7.27.5-e07ffcf28e/node_modules/@microsoft/api-extractor-model/",\ + "packageDependencies": [\ + ["@microsoft/api-extractor-model", "npm:7.27.5"],\ + ["@microsoft/tsdoc", "npm:0.14.2"],\ + ["@microsoft/tsdoc-config", "npm:0.16.2"],\ + ["@rushstack/node-core-library", "virtual:e07ffcf28e4f5faed173552686c5c7136799d9c4e5fdc70ac4b6d44ac84116dbf10565cb2a5674fa37d7063aa70a909694636f515e9868b9bdaa9a073470e36a#npm:3.59.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@microsoft/tsdoc", [\ + ["npm:0.14.2", {\ + "packageLocation": "./.yarn/cache/@microsoft-tsdoc-npm-0.14.2-9988282153-00c3d4fc18.zip/node_modules/@microsoft/tsdoc/",\ + "packageDependencies": [\ + ["@microsoft/tsdoc", "npm:0.14.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@microsoft/tsdoc-config", [\ + ["npm:0.16.2", {\ + "packageLocation": "./.yarn/cache/@microsoft-tsdoc-config-npm-0.16.2-30fd115d09-37fc35d83d.zip/node_modules/@microsoft/tsdoc-config/",\ + "packageDependencies": [\ + ["@microsoft/tsdoc", "npm:0.14.2"],\ + ["@microsoft/tsdoc-config", "npm:0.16.2"],\ + ["ajv", "npm:6.12.6"],\ + ["jju", "npm:1.4.0"],\ + ["resolve", "patch:resolve@npm%3A1.19.0#optional!builtin::version=1.19.0&hash=c3c19d"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@mole-inc/bin-wrapper", [\ + ["npm:8.0.1", {\ + "packageLocation": "./.yarn/cache/@mole-inc-bin-wrapper-npm-8.0.1-c235286a84-565df38f6f.zip/node_modules/@mole-inc/bin-wrapper/",\ + "packageDependencies": [\ + ["@mole-inc/bin-wrapper", "npm:8.0.1"],\ + ["bin-check", "npm:4.1.0"],\ + ["bin-version-check", "npm:5.1.0"],\ + ["content-disposition", "npm:0.5.4"],\ + ["ext-name", "npm:5.0.0"],\ + ["file-type", "npm:17.1.6"],\ + ["filenamify", "npm:5.1.1"],\ + ["got", "npm:11.8.6"],\ + ["os-filter-obj", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@msgpackr-extract/msgpackr-extract-darwin-arm64", [\ + ["npm:3.0.2", {\ + "packageLocation": "./.yarn/unplugged/@msgpackr-extract-msgpackr-extract-darwin-arm64-npm-3.0.2-18ac236cc4/node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64/",\ + "packageDependencies": [\ + ["@msgpackr-extract/msgpackr-extract-darwin-arm64", "npm:3.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@msgpackr-extract/msgpackr-extract-darwin-x64", [\ + ["npm:3.0.2", {\ + "packageLocation": "./.yarn/unplugged/@msgpackr-extract-msgpackr-extract-darwin-x64-npm-3.0.2-39dd07082a/node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64/",\ + "packageDependencies": [\ + ["@msgpackr-extract/msgpackr-extract-darwin-x64", "npm:3.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@msgpackr-extract/msgpackr-extract-linux-arm", [\ + ["npm:3.0.2", {\ + "packageLocation": "./.yarn/unplugged/@msgpackr-extract-msgpackr-extract-linux-arm-npm-3.0.2-808a652e0b/node_modules/@msgpackr-extract/msgpackr-extract-linux-arm/",\ + "packageDependencies": [\ + ["@msgpackr-extract/msgpackr-extract-linux-arm", "npm:3.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@msgpackr-extract/msgpackr-extract-linux-arm64", [\ + ["npm:3.0.2", {\ + "packageLocation": "./.yarn/unplugged/@msgpackr-extract-msgpackr-extract-linux-arm64-npm-3.0.2-cfbf50d4c6/node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64/",\ + "packageDependencies": [\ + ["@msgpackr-extract/msgpackr-extract-linux-arm64", "npm:3.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@msgpackr-extract/msgpackr-extract-linux-x64", [\ + ["npm:3.0.2", {\ + "packageLocation": "./.yarn/unplugged/@msgpackr-extract-msgpackr-extract-linux-x64-npm-3.0.2-262fca760d/node_modules/@msgpackr-extract/msgpackr-extract-linux-x64/",\ + "packageDependencies": [\ + ["@msgpackr-extract/msgpackr-extract-linux-x64", "npm:3.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@msgpackr-extract/msgpackr-extract-win32-x64", [\ + ["npm:3.0.2", {\ + "packageLocation": "./.yarn/unplugged/@msgpackr-extract-msgpackr-extract-win32-x64-npm-3.0.2-c627beab89/node_modules/@msgpackr-extract/msgpackr-extract-win32-x64/",\ + "packageDependencies": [\ + ["@msgpackr-extract/msgpackr-extract-win32-x64", "npm:3.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@noble/hashes", [\ + ["npm:1.3.2", {\ + "packageLocation": "./.yarn/cache/@noble-hashes-npm-1.3.2-1e619f9da0-685f59d2d4.zip/node_modules/@noble/hashes/",\ + "packageDependencies": [\ + ["@noble/hashes", "npm:1.3.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@nodable/entities", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/@nodable-entities-npm-2.1.0-6eb9f06ac0-355c55e82a.zip/node_modules/@nodable/entities/",\ + "packageDependencies": [\ + ["@nodable/entities", "npm:2.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@nodelib/fs.scandir", [\ + ["npm:2.1.5", {\ + "packageLocation": "./.yarn/cache/@nodelib-fs.scandir-npm-2.1.5-89c67370dd-6ab2a9b8a1.zip/node_modules/@nodelib/fs.scandir/",\ + "packageDependencies": [\ + ["@nodelib/fs.scandir", "npm:2.1.5"],\ + ["@nodelib/fs.stat", "npm:2.0.5"],\ + ["run-parallel", "npm:1.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@nodelib/fs.stat", [\ + ["npm:2.0.5", {\ + "packageLocation": "./.yarn/cache/@nodelib-fs.stat-npm-2.0.5-01f4dd3030-012480b5ca.zip/node_modules/@nodelib/fs.stat/",\ + "packageDependencies": [\ + ["@nodelib/fs.stat", "npm:2.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@nodelib/fs.walk", [\ + ["npm:1.2.8", {\ + "packageLocation": "./.yarn/cache/@nodelib-fs.walk-npm-1.2.8-b4a89da548-40033e33e9.zip/node_modules/@nodelib/fs.walk/",\ + "packageDependencies": [\ + ["@nodelib/fs.scandir", "npm:2.1.5"],\ + ["@nodelib/fs.walk", "npm:1.2.8"],\ + ["fastq", "npm:1.15.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@npmcli/agent", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/@npmcli-agent-npm-3.0.0-169e79294f-775c9a7eb1.zip/node_modules/@npmcli/agent/",\ + "packageDependencies": [\ + ["@npmcli/agent", "npm:3.0.0"],\ + ["agent-base", "npm:7.1.0"],\ + ["http-proxy-agent", "npm:7.0.0"],\ + ["https-proxy-agent", "npm:7.0.2"],\ + ["lru-cache", "npm:10.0.2"],\ + ["socks-proxy-agent", "npm:8.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@npmcli/fs", [\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/@npmcli-fs-npm-3.1.0-0844a57978-f3a7ab3a31.zip/node_modules/@npmcli/fs/",\ + "packageDependencies": [\ + ["@npmcli/fs", "npm:3.1.0"],\ + ["semver", "npm:7.5.4"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/@npmcli-fs-npm-4.0.0-1d9cc8a27b-405c4490e1.zip/node_modules/@npmcli/fs/",\ + "packageDependencies": [\ + ["@npmcli/fs", "npm:4.0.0"],\ + ["semver", "npm:7.5.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@npmcli/promise-spawn", [\ + ["npm:6.0.2", {\ + "packageLocation": "./.yarn/cache/@npmcli-promise-spawn-npm-6.0.2-c9941b207c-cc94a83ff1.zip/node_modules/@npmcli/promise-spawn/",\ + "packageDependencies": [\ + ["@npmcli/promise-spawn", "npm:6.0.2"],\ + ["which", "npm:3.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@one-ini/wasm", [\ + ["npm:0.1.1", {\ + "packageLocation": "./.yarn/cache/@one-ini-wasm-npm-0.1.1-cee8120e33-673c11518d.zip/node_modules/@one-ini/wasm/",\ + "packageDependencies": [\ + ["@one-ini/wasm", "npm:0.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@opentelemetry/api", [\ + ["npm:1.7.0", {\ + "packageLocation": "./.yarn/cache/@opentelemetry-api-npm-1.7.0-6263fad98a-bcf7afa705.zip/node_modules/@opentelemetry/api/",\ + "packageDependencies": [\ + ["@opentelemetry/api", "npm:1.7.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@paralleldrive/cuid2", [\ + ["npm:2.2.2", {\ + "packageLocation": "./.yarn/cache/@paralleldrive-cuid2-npm-2.2.2-e6061749b2-40ee269d6e.zip/node_modules/@paralleldrive/cuid2/",\ + "packageDependencies": [\ + ["@noble/hashes", "npm:1.3.2"],\ + ["@paralleldrive/cuid2", "npm:2.2.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@parcel/watcher", [\ + ["npm:2.5.6", {\ + "packageLocation": "./.yarn/unplugged/@parcel-watcher-npm-2.5.6-aac795b349/node_modules/@parcel/watcher/",\ + "packageDependencies": [\ + ["@parcel/watcher", "npm:2.5.6"],\ + ["@parcel/watcher-android-arm64", "npm:2.5.6"],\ + ["@parcel/watcher-darwin-arm64", "npm:2.5.6"],\ + ["@parcel/watcher-darwin-x64", "npm:2.5.6"],\ + ["@parcel/watcher-freebsd-x64", "npm:2.5.6"],\ + ["@parcel/watcher-linux-arm-glibc", "npm:2.5.6"],\ + ["@parcel/watcher-linux-arm-musl", "npm:2.5.6"],\ + ["@parcel/watcher-linux-arm64-glibc", "npm:2.5.6"],\ + ["@parcel/watcher-linux-arm64-musl", "npm:2.5.6"],\ + ["@parcel/watcher-linux-x64-glibc", "npm:2.5.6"],\ + ["@parcel/watcher-linux-x64-musl", "npm:2.5.6"],\ + ["@parcel/watcher-win32-arm64", "npm:2.5.6"],\ + ["@parcel/watcher-win32-ia32", "npm:2.5.6"],\ + ["@parcel/watcher-win32-x64", "npm:2.5.6"],\ + ["detect-libc", "npm:2.0.3"],\ + ["is-glob", "npm:4.0.3"],\ + ["node-addon-api", "npm:7.1.1"],\ + ["node-gyp", "npm:9.4.0"],\ + ["picomatch", "npm:4.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@parcel/watcher-android-arm64", [\ + ["npm:2.5.6", {\ + "packageLocation": "./.yarn/unplugged/@parcel-watcher-android-arm64-npm-2.5.6-3af976e716/node_modules/@parcel/watcher-android-arm64/",\ + "packageDependencies": [\ + ["@parcel/watcher-android-arm64", "npm:2.5.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@parcel/watcher-darwin-arm64", [\ + ["npm:2.5.6", {\ + "packageLocation": "./.yarn/unplugged/@parcel-watcher-darwin-arm64-npm-2.5.6-12ffeb78ea/node_modules/@parcel/watcher-darwin-arm64/",\ + "packageDependencies": [\ + ["@parcel/watcher-darwin-arm64", "npm:2.5.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@parcel/watcher-darwin-x64", [\ + ["npm:2.5.6", {\ + "packageLocation": "./.yarn/unplugged/@parcel-watcher-darwin-x64-npm-2.5.6-a1f8899512/node_modules/@parcel/watcher-darwin-x64/",\ + "packageDependencies": [\ + ["@parcel/watcher-darwin-x64", "npm:2.5.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@parcel/watcher-freebsd-x64", [\ + ["npm:2.5.6", {\ + "packageLocation": "./.yarn/unplugged/@parcel-watcher-freebsd-x64-npm-2.5.6-ec01b811f2/node_modules/@parcel/watcher-freebsd-x64/",\ + "packageDependencies": [\ + ["@parcel/watcher-freebsd-x64", "npm:2.5.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@parcel/watcher-linux-arm-glibc", [\ + ["npm:2.5.6", {\ + "packageLocation": "./.yarn/unplugged/@parcel-watcher-linux-arm-glibc-npm-2.5.6-757fc05ca9/node_modules/@parcel/watcher-linux-arm-glibc/",\ + "packageDependencies": [\ + ["@parcel/watcher-linux-arm-glibc", "npm:2.5.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@parcel/watcher-linux-arm-musl", [\ + ["npm:2.5.6", {\ + "packageLocation": "./.yarn/unplugged/@parcel-watcher-linux-arm-musl-npm-2.5.6-250e1ed4c3/node_modules/@parcel/watcher-linux-arm-musl/",\ + "packageDependencies": [\ + ["@parcel/watcher-linux-arm-musl", "npm:2.5.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@parcel/watcher-linux-arm64-glibc", [\ + ["npm:2.5.6", {\ + "packageLocation": "./.yarn/unplugged/@parcel-watcher-linux-arm64-glibc-npm-2.5.6-975ed11a63/node_modules/@parcel/watcher-linux-arm64-glibc/",\ + "packageDependencies": [\ + ["@parcel/watcher-linux-arm64-glibc", "npm:2.5.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@parcel/watcher-linux-arm64-musl", [\ + ["npm:2.5.6", {\ + "packageLocation": "./.yarn/unplugged/@parcel-watcher-linux-arm64-musl-npm-2.5.6-26220f5490/node_modules/@parcel/watcher-linux-arm64-musl/",\ + "packageDependencies": [\ + ["@parcel/watcher-linux-arm64-musl", "npm:2.5.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@parcel/watcher-linux-x64-glibc", [\ + ["npm:2.5.6", {\ + "packageLocation": "./.yarn/unplugged/@parcel-watcher-linux-x64-glibc-npm-2.5.6-0ea9becf86/node_modules/@parcel/watcher-linux-x64-glibc/",\ + "packageDependencies": [\ + ["@parcel/watcher-linux-x64-glibc", "npm:2.5.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@parcel/watcher-linux-x64-musl", [\ + ["npm:2.5.6", {\ + "packageLocation": "./.yarn/unplugged/@parcel-watcher-linux-x64-musl-npm-2.5.6-9e855c68ef/node_modules/@parcel/watcher-linux-x64-musl/",\ + "packageDependencies": [\ + ["@parcel/watcher-linux-x64-musl", "npm:2.5.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@parcel/watcher-win32-arm64", [\ + ["npm:2.5.6", {\ + "packageLocation": "./.yarn/unplugged/@parcel-watcher-win32-arm64-npm-2.5.6-51d6df1f44/node_modules/@parcel/watcher-win32-arm64/",\ + "packageDependencies": [\ + ["@parcel/watcher-win32-arm64", "npm:2.5.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@parcel/watcher-win32-ia32", [\ + ["npm:2.5.6", {\ + "packageLocation": "./.yarn/unplugged/@parcel-watcher-win32-ia32-npm-2.5.6-0b11b69b4f/node_modules/@parcel/watcher-win32-ia32/",\ + "packageDependencies": [\ + ["@parcel/watcher-win32-ia32", "npm:2.5.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@parcel/watcher-win32-x64", [\ + ["npm:2.5.6", {\ + "packageLocation": "./.yarn/unplugged/@parcel-watcher-win32-x64-npm-2.5.6-08d55e6e54/node_modules/@parcel/watcher-win32-x64/",\ + "packageDependencies": [\ + ["@parcel/watcher-win32-x64", "npm:2.5.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@peertube/http-signature", [\ + ["npm:1.7.0", {\ + "packageLocation": "./.yarn/cache/@peertube-http-signature-npm-1.7.0-86ceb52221-a4dd004ad9.zip/node_modules/@peertube/http-signature/",\ + "packageDependencies": [\ + ["@peertube/http-signature", "npm:1.7.0"],\ + ["assert-plus", "npm:1.0.0"],\ + ["jsprim", "npm:1.4.2"],\ + ["sshpk", "npm:1.17.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@phc/format", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/@phc-format-npm-1.0.0-9663606e18-26c4feccdc.zip/node_modules/@phc/format/",\ + "packageDependencies": [\ + ["@phc/format", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@phosphor-icons/web", [\ + ["npm:2.0.3", {\ + "packageLocation": "./.yarn/cache/@phosphor-icons-web-npm-2.0.3-92e5ef0d58-319a1aa953.zip/node_modules/@phosphor-icons/web/",\ + "packageDependencies": [\ + ["@phosphor-icons/web", "npm:2.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@pkgjs/parseargs", [\ + ["npm:0.11.0", {\ + "packageLocation": "./.yarn/cache/@pkgjs-parseargs-npm-0.11.0-cd2a3fe948-115e8ceeec.zip/node_modules/@pkgjs/parseargs/",\ + "packageDependencies": [\ + ["@pkgjs/parseargs", "npm:0.11.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@redis/bloom", [\ + ["npm:1.2.0", {\ + "packageLocation": "./.yarn/cache/@redis-bloom-npm-1.2.0-c3ffcb341a-a16408f729.zip/node_modules/@redis/bloom/",\ + "packageDependencies": [\ + ["@redis/bloom", "npm:1.2.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:d942fc1f0bf76670da5df1cacb3a62b82829e31da6ef87f7962059db1525b7d7191333770989124fc769276f7faa4fcc2c8e31f97960ee5db217048dbc3e0b10#npm:1.2.0", {\ + "packageLocation": "./.yarn/__virtual__/@redis-bloom-virtual-8f64daf935/0/cache/@redis-bloom-npm-1.2.0-c3ffcb341a-a16408f729.zip/node_modules/@redis/bloom/",\ + "packageDependencies": [\ + ["@redis/bloom", "virtual:d942fc1f0bf76670da5df1cacb3a62b82829e31da6ef87f7962059db1525b7d7191333770989124fc769276f7faa4fcc2c8e31f97960ee5db217048dbc3e0b10#npm:1.2.0"],\ + ["@redis/client", "npm:1.5.8"],\ + ["@types/redis__client", null]\ + ],\ + "packagePeers": [\ + "@redis/client",\ + "@types/redis__client"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@redis/client", [\ + ["npm:1.5.8", {\ + "packageLocation": "./.yarn/cache/@redis-client-npm-1.5.8-179e730bf1-9eb7c2dfaa.zip/node_modules/@redis/client/",\ + "packageDependencies": [\ + ["@redis/client", "npm:1.5.8"],\ + ["cluster-key-slot", "npm:1.1.2"],\ + ["generic-pool", "npm:3.9.0"],\ + ["yallist", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@redis/graph", [\ + ["npm:1.1.0", {\ + "packageLocation": "./.yarn/cache/@redis-graph-npm-1.1.0-84c3d1d722-e1d4ee00df.zip/node_modules/@redis/graph/",\ + "packageDependencies": [\ + ["@redis/graph", "npm:1.1.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:d942fc1f0bf76670da5df1cacb3a62b82829e31da6ef87f7962059db1525b7d7191333770989124fc769276f7faa4fcc2c8e31f97960ee5db217048dbc3e0b10#npm:1.1.0", {\ + "packageLocation": "./.yarn/__virtual__/@redis-graph-virtual-23f52ea648/0/cache/@redis-graph-npm-1.1.0-84c3d1d722-e1d4ee00df.zip/node_modules/@redis/graph/",\ + "packageDependencies": [\ + ["@redis/client", "npm:1.5.8"],\ + ["@redis/graph", "virtual:d942fc1f0bf76670da5df1cacb3a62b82829e31da6ef87f7962059db1525b7d7191333770989124fc769276f7faa4fcc2c8e31f97960ee5db217048dbc3e0b10#npm:1.1.0"],\ + ["@types/redis__client", null]\ + ],\ + "packagePeers": [\ + "@redis/client",\ + "@types/redis__client"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@redis/json", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/cache/@redis-json-npm-1.0.4-213f95e10f-531179f204.zip/node_modules/@redis/json/",\ + "packageDependencies": [\ + ["@redis/json", "npm:1.0.4"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:d942fc1f0bf76670da5df1cacb3a62b82829e31da6ef87f7962059db1525b7d7191333770989124fc769276f7faa4fcc2c8e31f97960ee5db217048dbc3e0b10#npm:1.0.4", {\ + "packageLocation": "./.yarn/__virtual__/@redis-json-virtual-f0e7455e47/0/cache/@redis-json-npm-1.0.4-213f95e10f-531179f204.zip/node_modules/@redis/json/",\ + "packageDependencies": [\ + ["@redis/client", "npm:1.5.8"],\ + ["@redis/json", "virtual:d942fc1f0bf76670da5df1cacb3a62b82829e31da6ef87f7962059db1525b7d7191333770989124fc769276f7faa4fcc2c8e31f97960ee5db217048dbc3e0b10#npm:1.0.4"],\ + ["@types/redis__client", null]\ + ],\ + "packagePeers": [\ + "@redis/client",\ + "@types/redis__client"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@redis/search", [\ + ["npm:1.1.3", {\ + "packageLocation": "./.yarn/cache/@redis-search-npm-1.1.3-561deaf6a2-c6bc57a724.zip/node_modules/@redis/search/",\ + "packageDependencies": [\ + ["@redis/search", "npm:1.1.3"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:d942fc1f0bf76670da5df1cacb3a62b82829e31da6ef87f7962059db1525b7d7191333770989124fc769276f7faa4fcc2c8e31f97960ee5db217048dbc3e0b10#npm:1.1.3", {\ + "packageLocation": "./.yarn/__virtual__/@redis-search-virtual-496b9b6134/0/cache/@redis-search-npm-1.1.3-561deaf6a2-c6bc57a724.zip/node_modules/@redis/search/",\ + "packageDependencies": [\ + ["@redis/client", "npm:1.5.8"],\ + ["@redis/search", "virtual:d942fc1f0bf76670da5df1cacb3a62b82829e31da6ef87f7962059db1525b7d7191333770989124fc769276f7faa4fcc2c8e31f97960ee5db217048dbc3e0b10#npm:1.1.3"],\ + ["@types/redis__client", null]\ + ],\ + "packagePeers": [\ + "@redis/client",\ + "@types/redis__client"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@redis/time-series", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/cache/@redis-time-series-npm-1.0.4-1dfa6fdc7e-8fb19186e8.zip/node_modules/@redis/time-series/",\ + "packageDependencies": [\ + ["@redis/time-series", "npm:1.0.4"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:d942fc1f0bf76670da5df1cacb3a62b82829e31da6ef87f7962059db1525b7d7191333770989124fc769276f7faa4fcc2c8e31f97960ee5db217048dbc3e0b10#npm:1.0.4", {\ + "packageLocation": "./.yarn/__virtual__/@redis-time-series-virtual-41dd991986/0/cache/@redis-time-series-npm-1.0.4-1dfa6fdc7e-8fb19186e8.zip/node_modules/@redis/time-series/",\ + "packageDependencies": [\ + ["@redis/client", "npm:1.5.8"],\ + ["@redis/time-series", "virtual:d942fc1f0bf76670da5df1cacb3a62b82829e31da6ef87f7962059db1525b7d7191333770989124fc769276f7faa4fcc2c8e31f97960ee5db217048dbc3e0b10#npm:1.0.4"],\ + ["@types/redis__client", null]\ + ],\ + "packagePeers": [\ + "@redis/client",\ + "@types/redis__client"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rolldown/pluginutils", [\ + ["npm:1.0.0-rc.13", {\ + "packageLocation": "./.yarn/cache/@rolldown-pluginutils-npm-1.0.0-rc.13-255ed920ca-ffc6cdfac8.zip/node_modules/@rolldown/pluginutils/",\ + "packageDependencies": [\ + ["@rolldown/pluginutils", "npm:1.0.0-rc.13"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/plugin-json", [\ + ["npm:6.0.1", {\ + "packageLocation": "./.yarn/cache/@rollup-plugin-json-npm-6.0.1-9652c7ccf8-86995e3cee.zip/node_modules/@rollup/plugin-json/",\ + "packageDependencies": [\ + ["@rollup/plugin-json", "npm:6.0.1"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:6.0.1", {\ + "packageLocation": "./.yarn/__virtual__/@rollup-plugin-json-virtual-7db9869e38/0/cache/@rollup-plugin-json-npm-6.0.1-9652c7ccf8-86995e3cee.zip/node_modules/@rollup/plugin-json/",\ + "packageDependencies": [\ + ["@rollup/plugin-json", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:6.0.1"],\ + ["@rollup/pluginutils", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:5.1.0"],\ + ["@types/rollup", null],\ + ["rollup", "npm:4.6.1"]\ + ],\ + "packagePeers": [\ + "@types/rollup",\ + "rollup"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/pluginutils", [\ + ["npm:5.1.0", {\ + "packageLocation": "./.yarn/cache/@rollup-pluginutils-npm-5.1.0-6939820ef8-abb15eaec5.zip/node_modules/@rollup/pluginutils/",\ + "packageDependencies": [\ + ["@rollup/pluginutils", "npm:5.1.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:5.1.0", {\ + "packageLocation": "./.yarn/__virtual__/@rollup-pluginutils-virtual-bfea530047/0/cache/@rollup-pluginutils-npm-5.1.0-6939820ef8-abb15eaec5.zip/node_modules/@rollup/pluginutils/",\ + "packageDependencies": [\ + ["@rollup/pluginutils", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:5.1.0"],\ + ["@types/estree", "npm:1.0.1"],\ + ["@types/rollup", null],\ + ["estree-walker", "npm:2.0.2"],\ + ["picomatch", "npm:2.3.1"],\ + ["rollup", "npm:4.6.1"]\ + ],\ + "packagePeers": [\ + "@types/rollup",\ + "rollup"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-android-arm-eabi", [\ + ["npm:4.6.1", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-android-arm-eabi-npm-4.6.1-fbe3158bf3/node_modules/@rollup/rollup-android-arm-eabi/",\ + "packageDependencies": [\ + ["@rollup/rollup-android-arm-eabi", "npm:4.6.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-android-arm-eabi-npm-4.60.3-dbb543244a/node_modules/@rollup/rollup-android-arm-eabi/",\ + "packageDependencies": [\ + ["@rollup/rollup-android-arm-eabi", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-android-arm64", [\ + ["npm:4.6.1", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-android-arm64-npm-4.6.1-7f2314e6a5/node_modules/@rollup/rollup-android-arm64/",\ + "packageDependencies": [\ + ["@rollup/rollup-android-arm64", "npm:4.6.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-android-arm64-npm-4.60.3-c9829e8c34/node_modules/@rollup/rollup-android-arm64/",\ + "packageDependencies": [\ + ["@rollup/rollup-android-arm64", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-darwin-arm64", [\ + ["npm:4.6.1", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-darwin-arm64-npm-4.6.1-4e41987212/node_modules/@rollup/rollup-darwin-arm64/",\ + "packageDependencies": [\ + ["@rollup/rollup-darwin-arm64", "npm:4.6.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-darwin-arm64-npm-4.60.3-4df7906a5a/node_modules/@rollup/rollup-darwin-arm64/",\ + "packageDependencies": [\ + ["@rollup/rollup-darwin-arm64", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-darwin-x64", [\ + ["npm:4.6.1", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-darwin-x64-npm-4.6.1-73992302c1/node_modules/@rollup/rollup-darwin-x64/",\ + "packageDependencies": [\ + ["@rollup/rollup-darwin-x64", "npm:4.6.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-darwin-x64-npm-4.60.3-132489b997/node_modules/@rollup/rollup-darwin-x64/",\ + "packageDependencies": [\ + ["@rollup/rollup-darwin-x64", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-freebsd-arm64", [\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-freebsd-arm64-npm-4.60.3-75f3906097/node_modules/@rollup/rollup-freebsd-arm64/",\ + "packageDependencies": [\ + ["@rollup/rollup-freebsd-arm64", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-freebsd-x64", [\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-freebsd-x64-npm-4.60.3-549cc30b35/node_modules/@rollup/rollup-freebsd-x64/",\ + "packageDependencies": [\ + ["@rollup/rollup-freebsd-x64", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-linux-arm-gnueabihf", [\ + ["npm:4.6.1", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-arm-gnueabihf-npm-4.6.1-538b8bc0ad/node_modules/@rollup/rollup-linux-arm-gnueabihf/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-arm-gnueabihf", "npm:4.6.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-arm-gnueabihf-npm-4.60.3-02ac131d6d/node_modules/@rollup/rollup-linux-arm-gnueabihf/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-arm-gnueabihf", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-linux-arm-musleabihf", [\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-arm-musleabihf-npm-4.60.3-d5cf26c3e6/node_modules/@rollup/rollup-linux-arm-musleabihf/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-arm-musleabihf", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-linux-arm64-gnu", [\ + ["npm:4.6.1", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-arm64-gnu-npm-4.6.1-1f6c675d43/node_modules/@rollup/rollup-linux-arm64-gnu/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-arm64-gnu", "npm:4.6.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-arm64-gnu-npm-4.60.3-50c099fef1/node_modules/@rollup/rollup-linux-arm64-gnu/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-arm64-gnu", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-linux-arm64-musl", [\ + ["npm:4.6.1", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-arm64-musl-npm-4.6.1-6dfb17274d/node_modules/@rollup/rollup-linux-arm64-musl/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-arm64-musl", "npm:4.6.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-arm64-musl-npm-4.60.3-5724f7f981/node_modules/@rollup/rollup-linux-arm64-musl/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-arm64-musl", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-linux-loong64-gnu", [\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-loong64-gnu-npm-4.60.3-bbfb16d6a2/node_modules/@rollup/rollup-linux-loong64-gnu/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-loong64-gnu", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-linux-loong64-musl", [\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-loong64-musl-npm-4.60.3-be2630c276/node_modules/@rollup/rollup-linux-loong64-musl/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-loong64-musl", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-linux-ppc64-gnu", [\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-ppc64-gnu-npm-4.60.3-1184b50aed/node_modules/@rollup/rollup-linux-ppc64-gnu/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-ppc64-gnu", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-linux-ppc64-musl", [\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-ppc64-musl-npm-4.60.3-bb8e179b19/node_modules/@rollup/rollup-linux-ppc64-musl/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-ppc64-musl", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-linux-riscv64-gnu", [\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-riscv64-gnu-npm-4.60.3-9396b13223/node_modules/@rollup/rollup-linux-riscv64-gnu/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-riscv64-gnu", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-linux-riscv64-musl", [\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-riscv64-musl-npm-4.60.3-f155a35d72/node_modules/@rollup/rollup-linux-riscv64-musl/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-riscv64-musl", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-linux-s390x-gnu", [\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-s390x-gnu-npm-4.60.3-e68cca3bd5/node_modules/@rollup/rollup-linux-s390x-gnu/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-s390x-gnu", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-linux-x64-gnu", [\ + ["npm:4.6.1", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-x64-gnu-npm-4.6.1-dd3309cd92/node_modules/@rollup/rollup-linux-x64-gnu/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-x64-gnu", "npm:4.6.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-x64-gnu-npm-4.60.3-eea81f8a08/node_modules/@rollup/rollup-linux-x64-gnu/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-x64-gnu", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-linux-x64-musl", [\ + ["npm:4.6.1", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-x64-musl-npm-4.6.1-cfcfeca557/node_modules/@rollup/rollup-linux-x64-musl/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-x64-musl", "npm:4.6.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-linux-x64-musl-npm-4.60.3-2856182d59/node_modules/@rollup/rollup-linux-x64-musl/",\ + "packageDependencies": [\ + ["@rollup/rollup-linux-x64-musl", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-openbsd-x64", [\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-openbsd-x64-npm-4.60.3-176cd5a8f5/node_modules/@rollup/rollup-openbsd-x64/",\ + "packageDependencies": [\ + ["@rollup/rollup-openbsd-x64", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-openharmony-arm64", [\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-openharmony-arm64-npm-4.60.3-4758e04b88/node_modules/@rollup/rollup-openharmony-arm64/",\ + "packageDependencies": [\ + ["@rollup/rollup-openharmony-arm64", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-win32-arm64-msvc", [\ + ["npm:4.6.1", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-win32-arm64-msvc-npm-4.6.1-a591cfd356/node_modules/@rollup/rollup-win32-arm64-msvc/",\ + "packageDependencies": [\ + ["@rollup/rollup-win32-arm64-msvc", "npm:4.6.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-win32-arm64-msvc-npm-4.60.3-1898d8b7c7/node_modules/@rollup/rollup-win32-arm64-msvc/",\ + "packageDependencies": [\ + ["@rollup/rollup-win32-arm64-msvc", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-win32-ia32-msvc", [\ + ["npm:4.6.1", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-win32-ia32-msvc-npm-4.6.1-4d8055975f/node_modules/@rollup/rollup-win32-ia32-msvc/",\ + "packageDependencies": [\ + ["@rollup/rollup-win32-ia32-msvc", "npm:4.6.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-win32-ia32-msvc-npm-4.60.3-5de77c5f79/node_modules/@rollup/rollup-win32-ia32-msvc/",\ + "packageDependencies": [\ + ["@rollup/rollup-win32-ia32-msvc", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-win32-x64-gnu", [\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-win32-x64-gnu-npm-4.60.3-058f8be94b/node_modules/@rollup/rollup-win32-x64-gnu/",\ + "packageDependencies": [\ + ["@rollup/rollup-win32-x64-gnu", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rollup/rollup-win32-x64-msvc", [\ + ["npm:4.6.1", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-win32-x64-msvc-npm-4.6.1-a6bd23ed7e/node_modules/@rollup/rollup-win32-x64-msvc/",\ + "packageDependencies": [\ + ["@rollup/rollup-win32-x64-msvc", "npm:4.6.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/unplugged/@rollup-rollup-win32-x64-msvc-npm-4.60.3-22f1799acb/node_modules/@rollup/rollup-win32-x64-msvc/",\ + "packageDependencies": [\ + ["@rollup/rollup-win32-x64-msvc", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rushstack/node-core-library", [\ + ["npm:3.59.6", {\ + "packageLocation": "./.yarn/cache/@rushstack-node-core-library-npm-3.59.6-bf2f680876-0e0a64e57c.zip/node_modules/@rushstack/node-core-library/",\ + "packageDependencies": [\ + ["@rushstack/node-core-library", "npm:3.59.6"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:e07ffcf28e4f5faed173552686c5c7136799d9c4e5fdc70ac4b6d44ac84116dbf10565cb2a5674fa37d7063aa70a909694636f515e9868b9bdaa9a073470e36a#npm:3.59.6", {\ + "packageLocation": "./.yarn/__virtual__/@rushstack-node-core-library-virtual-bba7d9e971/0/cache/@rushstack-node-core-library-npm-3.59.6-bf2f680876-0e0a64e57c.zip/node_modules/@rushstack/node-core-library/",\ + "packageDependencies": [\ + ["@rushstack/node-core-library", "virtual:e07ffcf28e4f5faed173552686c5c7136799d9c4e5fdc70ac4b6d44ac84116dbf10565cb2a5674fa37d7063aa70a909694636f515e9868b9bdaa9a073470e36a#npm:3.59.6"],\ + ["@types/node", null],\ + ["colors", "npm:1.2.5"],\ + ["fs-extra", "npm:7.0.1"],\ + ["import-lazy", "npm:4.0.0"],\ + ["jju", "npm:1.4.0"],\ + ["resolve", "patch:resolve@npm%3A1.22.2#optional!builtin::version=1.22.2&hash=c3c19d"],\ + ["semver", "npm:7.5.4"],\ + ["z-schema", "npm:5.0.6"]\ + ],\ + "packagePeers": [\ + "@types/node"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rushstack/rig-package", [\ + ["npm:0.4.0", {\ + "packageLocation": "./.yarn/cache/@rushstack-rig-package-npm-0.4.0-add0565a57-0739e49bba.zip/node_modules/@rushstack/rig-package/",\ + "packageDependencies": [\ + ["@rushstack/rig-package", "npm:0.4.0"],\ + ["resolve", "patch:resolve@npm%3A1.22.2#optional!builtin::version=1.22.2&hash=c3c19d"],\ + ["strip-json-comments", "npm:3.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@rushstack/ts-command-line", [\ + ["npm:4.15.1", {\ + "packageLocation": "./.yarn/cache/@rushstack-ts-command-line-npm-4.15.1-a7d6b97275-7d1cedc2d4.zip/node_modules/@rushstack/ts-command-line/",\ + "packageDependencies": [\ + ["@rushstack/ts-command-line", "npm:4.15.1"],\ + ["@types/argparse", "npm:1.0.38"],\ + ["argparse", "npm:1.0.10"],\ + ["colors", "npm:1.2.5"],\ + ["string-argv", "npm:0.3.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@sec-ant/readable-stream", [\ + ["npm:0.4.1", {\ + "packageLocation": "./.yarn/cache/@sec-ant-readable-stream-npm-0.4.1-12d52145e0-aac8958165.zip/node_modules/@sec-ant/readable-stream/",\ + "packageDependencies": [\ + ["@sec-ant/readable-stream", "npm:0.4.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@sindresorhus/is", [\ + ["npm:4.6.0", {\ + "packageLocation": "./.yarn/cache/@sindresorhus-is-npm-4.6.0-7cad05c55e-e7f36ed72a.zip/node_modules/@sindresorhus/is/",\ + "packageDependencies": [\ + ["@sindresorhus/is", "npm:4.6.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:8.0.0", {\ + "packageLocation": "./.yarn/cache/@sindresorhus-is-npm-8.0.0-046387f324-108f092af1.zip/node_modules/@sindresorhus/is/",\ + "packageDependencies": [\ + ["@sindresorhus/is", "npm:8.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/config-resolver", [\ + ["npm:4.5.0", {\ + "packageLocation": "./.yarn/cache/@smithy-config-resolver-npm-4.5.0-0054acea49-a1015e8a99.zip/node_modules/@smithy/config-resolver/",\ + "packageDependencies": [\ + ["@smithy/config-resolver", "npm:4.5.0"],\ + ["@smithy/core", "npm:3.24.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/core", [\ + ["npm:3.24.0", {\ + "packageLocation": "./.yarn/cache/@smithy-core-npm-3.24.0-e9e7a51b5b-ee19a23edb.zip/node_modules/@smithy/core/",\ + "packageDependencies": [\ + ["@aws-crypto/crc32", "npm:5.2.0"],\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/credential-provider-imds", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-credential-provider-imds-npm-4.3.0-dc26456c2e-3ffcf6e2d2.zip/node_modules/@smithy/credential-provider-imds/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/credential-provider-imds", "npm:4.3.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/eventstream-serde-browser", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-eventstream-serde-browser-npm-4.3.0-396a02b6d2-9e9046455d.zip/node_modules/@smithy/eventstream-serde-browser/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/eventstream-serde-browser", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/eventstream-serde-config-resolver", [\ + ["npm:4.4.0", {\ + "packageLocation": "./.yarn/cache/@smithy-eventstream-serde-config-resolver-npm-4.4.0-b1c102e224-f4a19fae46.zip/node_modules/@smithy/eventstream-serde-config-resolver/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/eventstream-serde-config-resolver", "npm:4.4.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/eventstream-serde-node", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-eventstream-serde-node-npm-4.3.0-c0e347f8a9-3dd1a0be6a.zip/node_modules/@smithy/eventstream-serde-node/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/eventstream-serde-node", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/fetch-http-handler", [\ + ["npm:5.4.0", {\ + "packageLocation": "./.yarn/cache/@smithy-fetch-http-handler-npm-5.4.0-001bec891d-473a707636.zip/node_modules/@smithy/fetch-http-handler/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/fetch-http-handler", "npm:5.4.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/hash-blob-browser", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-hash-blob-browser-npm-4.3.0-b224e961bb-610514d01f.zip/node_modules/@smithy/hash-blob-browser/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/hash-blob-browser", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/hash-node", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-hash-node-npm-4.3.0-cd02c62916-c5cff841b5.zip/node_modules/@smithy/hash-node/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/hash-node", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/hash-stream-node", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-hash-stream-node-npm-4.3.0-c8abee5409-33250c824a.zip/node_modules/@smithy/hash-stream-node/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/hash-stream-node", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/invalid-dependency", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-invalid-dependency-npm-4.3.0-b3bfe48383-3c0c7dd0bf.zip/node_modules/@smithy/invalid-dependency/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/invalid-dependency", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/is-array-buffer", [\ + ["npm:2.2.0", {\ + "packageLocation": "./.yarn/cache/@smithy-is-array-buffer-npm-2.2.0-108320772d-d366743ecc.zip/node_modules/@smithy/is-array-buffer/",\ + "packageDependencies": [\ + ["@smithy/is-array-buffer", "npm:2.2.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-is-array-buffer-npm-4.3.0-430818538a-232be94c5f.zip/node_modules/@smithy/is-array-buffer/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/is-array-buffer", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/md5-js", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-md5-js-npm-4.3.0-bbab92ffc3-ab444700f0.zip/node_modules/@smithy/md5-js/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/md5-js", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/middleware-content-length", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-middleware-content-length-npm-4.3.0-6698637506-1c1050f1e6.zip/node_modules/@smithy/middleware-content-length/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/middleware-content-length", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/middleware-endpoint", [\ + ["npm:4.5.0", {\ + "packageLocation": "./.yarn/cache/@smithy-middleware-endpoint-npm-4.5.0-728ecfdbc4-5951a040dc.zip/node_modules/@smithy/middleware-endpoint/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/middleware-endpoint", "npm:4.5.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/middleware-retry", [\ + ["npm:4.6.0", {\ + "packageLocation": "./.yarn/cache/@smithy-middleware-retry-npm-4.6.0-2ad5fab018-acf14e0d3c.zip/node_modules/@smithy/middleware-retry/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/middleware-retry", "npm:4.6.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/middleware-serde", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-middleware-serde-npm-4.3.0-1451971f77-ef29f8fd66.zip/node_modules/@smithy/middleware-serde/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/middleware-serde", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/middleware-stack", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-middleware-stack-npm-4.3.0-616f7a2636-a7082612eb.zip/node_modules/@smithy/middleware-stack/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/middleware-stack", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/node-config-provider", [\ + ["npm:4.4.0", {\ + "packageLocation": "./.yarn/cache/@smithy-node-config-provider-npm-4.4.0-19b57130e2-0abc44190e.zip/node_modules/@smithy/node-config-provider/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/node-config-provider", "npm:4.4.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/node-http-handler", [\ + ["npm:4.6.1", {\ + "packageLocation": "./.yarn/cache/@smithy-node-http-handler-npm-4.6.1-706b313070-0dd0766f85.zip/node_modules/@smithy/node-http-handler/",\ + "packageDependencies": [\ + ["@smithy/node-http-handler", "npm:4.6.1"],\ + ["@smithy/protocol-http", "npm:5.4.0"],\ + ["@smithy/querystring-builder", "npm:4.3.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.7.0", {\ + "packageLocation": "./.yarn/cache/@smithy-node-http-handler-npm-4.7.0-28f5ef780f-beaea775be.zip/node_modules/@smithy/node-http-handler/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/node-http-handler", "npm:4.7.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/property-provider", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-property-provider-npm-4.3.0-c0b3805e0d-8b9ed36767.zip/node_modules/@smithy/property-provider/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/property-provider", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/protocol-http", [\ + ["npm:5.4.0", {\ + "packageLocation": "./.yarn/cache/@smithy-protocol-http-npm-5.4.0-2ceac8dcb3-01cd0548b3.zip/node_modules/@smithy/protocol-http/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/protocol-http", "npm:5.4.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/querystring-builder", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-querystring-builder-npm-4.3.0-38d71cf409-e5204a1d3e.zip/node_modules/@smithy/querystring-builder/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/querystring-builder", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/shared-ini-file-loader", [\ + ["npm:4.5.0", {\ + "packageLocation": "./.yarn/cache/@smithy-shared-ini-file-loader-npm-4.5.0-5cd3dac8f4-7b00874edb.zip/node_modules/@smithy/shared-ini-file-loader/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/shared-ini-file-loader", "npm:4.5.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/signature-v4", [\ + ["npm:5.4.0", {\ + "packageLocation": "./.yarn/cache/@smithy-signature-v4-npm-5.4.0-255857a77f-673ffa430b.zip/node_modules/@smithy/signature-v4/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/signature-v4", "npm:5.4.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/smithy-client", [\ + ["npm:4.13.0", {\ + "packageLocation": "./.yarn/cache/@smithy-smithy-client-npm-4.13.0-294604c105-8db6d1d6cf.zip/node_modules/@smithy/smithy-client/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/smithy-client", "npm:4.13.0"],\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/types", [\ + ["npm:4.14.1", {\ + "packageLocation": "./.yarn/cache/@smithy-types-npm-4.14.1-2aa5f2e270-45ee555075.zip/node_modules/@smithy/types/",\ + "packageDependencies": [\ + ["@smithy/types", "npm:4.14.1"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/url-parser", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-url-parser-npm-4.3.0-f7070e422f-c68ed72bdf.zip/node_modules/@smithy/url-parser/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/url-parser", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/util-base64", [\ + ["npm:4.4.0", {\ + "packageLocation": "./.yarn/cache/@smithy-util-base64-npm-4.4.0-e3874dc9d3-eed84519f3.zip/node_modules/@smithy/util-base64/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/util-base64", "npm:4.4.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/util-body-length-browser", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-util-body-length-browser-npm-4.3.0-da18d9a844-59de699050.zip/node_modules/@smithy/util-body-length-browser/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/util-body-length-browser", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/util-body-length-node", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-util-body-length-node-npm-4.3.0-b614b7d1b8-98ebb58304.zip/node_modules/@smithy/util-body-length-node/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/util-body-length-node", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/util-buffer-from", [\ + ["npm:2.2.0", {\ + "packageLocation": "./.yarn/cache/@smithy-util-buffer-from-npm-2.2.0-0ef5989125-53253e4e35.zip/node_modules/@smithy/util-buffer-from/",\ + "packageDependencies": [\ + ["@smithy/is-array-buffer", "npm:2.2.0"],\ + ["@smithy/util-buffer-from", "npm:2.2.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/util-config-provider", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-util-config-provider-npm-4.3.0-71df668034-7e91dc5933.zip/node_modules/@smithy/util-config-provider/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/util-config-provider", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/util-defaults-mode-browser", [\ + ["npm:4.4.0", {\ + "packageLocation": "./.yarn/cache/@smithy-util-defaults-mode-browser-npm-4.4.0-14c7e33216-ab3e5fb4be.zip/node_modules/@smithy/util-defaults-mode-browser/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/util-defaults-mode-browser", "npm:4.4.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/util-defaults-mode-node", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-util-defaults-mode-node-npm-4.3.0-50e58f337f-78f7a12da7.zip/node_modules/@smithy/util-defaults-mode-node/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/util-defaults-mode-node", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/util-endpoints", [\ + ["npm:3.5.0", {\ + "packageLocation": "./.yarn/cache/@smithy-util-endpoints-npm-3.5.0-ad7d0a7b7e-d888e8a1d5.zip/node_modules/@smithy/util-endpoints/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/util-endpoints", "npm:3.5.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/util-middleware", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-util-middleware-npm-4.3.0-70a017c1ac-08b5f2cca7.zip/node_modules/@smithy/util-middleware/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/util-middleware", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/util-retry", [\ + ["npm:4.4.0", {\ + "packageLocation": "./.yarn/cache/@smithy-util-retry-npm-4.4.0-9b9b030825-d5e8cc3754.zip/node_modules/@smithy/util-retry/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/util-retry", "npm:4.4.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/util-stream", [\ + ["npm:4.6.0", {\ + "packageLocation": "./.yarn/cache/@smithy-util-stream-npm-4.6.0-d4967dd703-59d38d9334.zip/node_modules/@smithy/util-stream/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/util-stream", "npm:4.6.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/util-utf8", [\ + ["npm:2.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-util-utf8-npm-2.3.0-9dcba0d35f-c766ead8da.zip/node_modules/@smithy/util-utf8/",\ + "packageDependencies": [\ + ["@smithy/util-buffer-from", "npm:2.2.0"],\ + ["@smithy/util-utf8", "npm:2.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/@smithy-util-utf8-npm-4.3.0-fad64872fd-35b6c285b6.zip/node_modules/@smithy/util-utf8/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/util-utf8", "npm:4.3.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@smithy/util-waiter", [\ + ["npm:4.4.0", {\ + "packageLocation": "./.yarn/cache/@smithy-util-waiter-npm-4.4.0-2ad985d844-d3982ac2ef.zip/node_modules/@smithy/util-waiter/",\ + "packageDependencies": [\ + ["@smithy/core", "npm:3.24.0"],\ + ["@smithy/util-waiter", "npm:4.4.0"],\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@sqltools/formatter", [\ + ["npm:1.2.5", {\ + "packageLocation": "./.yarn/cache/@sqltools-formatter-npm-1.2.5-709e7c0ab8-ce9335025c.zip/node_modules/@sqltools/formatter/",\ + "packageDependencies": [\ + ["@sqltools/formatter", "npm:1.2.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@swc/cli", [\ + ["npm:0.1.62", {\ + "packageLocation": "./.yarn/cache/@swc-cli-npm-0.1.62-a16e13398f-be1a44f8e9.zip/node_modules/@swc/cli/",\ + "packageDependencies": [\ + ["@swc/cli", "npm:0.1.62"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:0.1.62", {\ + "packageLocation": "./.yarn/__virtual__/@swc-cli-virtual-d41033c25e/0/cache/@swc-cli-npm-0.1.62-a16e13398f-be1a44f8e9.zip/node_modules/@swc/cli/",\ + "packageDependencies": [\ + ["@mole-inc/bin-wrapper", "npm:8.0.1"],\ + ["@swc/cli", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:0.1.62"],\ + ["@swc/core", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:1.3.71"],\ + ["@types/chokidar", null],\ + ["@types/swc__core", null],\ + ["chokidar", null],\ + ["commander", "npm:7.2.0"],\ + ["fast-glob", "npm:3.3.1"],\ + ["semver", "npm:7.5.4"],\ + ["slash", "npm:3.0.0"],\ + ["source-map", "npm:0.7.4"]\ + ],\ + "packagePeers": [\ + "@swc/core",\ + "@types/chokidar",\ + "@types/swc__core",\ + "chokidar"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@swc/core", [\ + ["npm:1.3.71", {\ + "packageLocation": "./.yarn/unplugged/@swc-core-virtual-6be5f948fc/node_modules/@swc/core/",\ + "packageDependencies": [\ + ["@swc/core", "npm:1.3.71"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:1.3.71", {\ + "packageLocation": "./.yarn/unplugged/@swc-core-virtual-6be5f948fc/node_modules/@swc/core/",\ + "packageDependencies": [\ + ["@swc/core", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:1.3.71"],\ + ["@swc/core-darwin-arm64", "npm:1.3.71"],\ + ["@swc/core-darwin-x64", "npm:1.3.71"],\ + ["@swc/core-linux-arm-gnueabihf", "npm:1.3.71"],\ + ["@swc/core-linux-arm64-gnu", "npm:1.3.71"],\ + ["@swc/core-linux-arm64-musl", "npm:1.3.71"],\ + ["@swc/core-linux-x64-gnu", "npm:1.3.71"],\ + ["@swc/core-linux-x64-musl", "npm:1.3.71"],\ + ["@swc/core-win32-arm64-msvc", "npm:1.3.71"],\ + ["@swc/core-win32-ia32-msvc", "npm:1.3.71"],\ + ["@swc/core-win32-x64-msvc", "npm:1.3.71"],\ + ["@swc/helpers", null],\ + ["@types/swc__helpers", null]\ + ],\ + "packagePeers": [\ + "@swc/helpers",\ + "@types/swc__helpers"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@swc/core-android-arm64", [\ + ["npm:1.3.11", {\ + "packageLocation": "./.yarn/unplugged/@swc-core-android-arm64-npm-1.3.11-907d2c4730/node_modules/@swc/core-android-arm64/",\ + "packageDependencies": [\ + ["@swc/core-android-arm64", "npm:1.3.11"],\ + ["@swc/wasm", "npm:1.2.130"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@swc/core-darwin-arm64", [\ + ["npm:1.3.71", {\ + "packageLocation": "./.yarn/unplugged/@swc-core-darwin-arm64-npm-1.3.71-5ea24a12db/node_modules/@swc/core-darwin-arm64/",\ + "packageDependencies": [\ + ["@swc/core-darwin-arm64", "npm:1.3.71"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@swc/core-darwin-x64", [\ + ["npm:1.3.71", {\ + "packageLocation": "./.yarn/unplugged/@swc-core-darwin-x64-npm-1.3.71-d2a330348d/node_modules/@swc/core-darwin-x64/",\ + "packageDependencies": [\ + ["@swc/core-darwin-x64", "npm:1.3.71"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@swc/core-linux-arm-gnueabihf", [\ + ["npm:1.3.71", {\ + "packageLocation": "./.yarn/unplugged/@swc-core-linux-arm-gnueabihf-npm-1.3.71-5a708ef467/node_modules/@swc/core-linux-arm-gnueabihf/",\ + "packageDependencies": [\ + ["@swc/core-linux-arm-gnueabihf", "npm:1.3.71"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@swc/core-linux-arm64-gnu", [\ + ["npm:1.3.71", {\ + "packageLocation": "./.yarn/unplugged/@swc-core-linux-arm64-gnu-npm-1.3.71-f44d6d186e/node_modules/@swc/core-linux-arm64-gnu/",\ + "packageDependencies": [\ + ["@swc/core-linux-arm64-gnu", "npm:1.3.71"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@swc/core-linux-arm64-musl", [\ + ["npm:1.3.71", {\ + "packageLocation": "./.yarn/unplugged/@swc-core-linux-arm64-musl-npm-1.3.71-27239ebfaa/node_modules/@swc/core-linux-arm64-musl/",\ + "packageDependencies": [\ + ["@swc/core-linux-arm64-musl", "npm:1.3.71"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@swc/core-linux-x64-gnu", [\ + ["npm:1.3.71", {\ + "packageLocation": "./.yarn/unplugged/@swc-core-linux-x64-gnu-npm-1.3.71-ac9ffba6fa/node_modules/@swc/core-linux-x64-gnu/",\ + "packageDependencies": [\ + ["@swc/core-linux-x64-gnu", "npm:1.3.71"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@swc/core-linux-x64-musl", [\ + ["npm:1.3.71", {\ + "packageLocation": "./.yarn/unplugged/@swc-core-linux-x64-musl-npm-1.3.71-5d63534e76/node_modules/@swc/core-linux-x64-musl/",\ + "packageDependencies": [\ + ["@swc/core-linux-x64-musl", "npm:1.3.71"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@swc/core-win32-arm64-msvc", [\ + ["npm:1.3.71", {\ + "packageLocation": "./.yarn/unplugged/@swc-core-win32-arm64-msvc-npm-1.3.71-b5a1901f83/node_modules/@swc/core-win32-arm64-msvc/",\ + "packageDependencies": [\ + ["@swc/core-win32-arm64-msvc", "npm:1.3.71"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@swc/core-win32-ia32-msvc", [\ + ["npm:1.3.71", {\ + "packageLocation": "./.yarn/unplugged/@swc-core-win32-ia32-msvc-npm-1.3.71-7a06f9ddb8/node_modules/@swc/core-win32-ia32-msvc/",\ + "packageDependencies": [\ + ["@swc/core-win32-ia32-msvc", "npm:1.3.71"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@swc/core-win32-x64-msvc", [\ + ["npm:1.3.71", {\ + "packageLocation": "./.yarn/unplugged/@swc-core-win32-x64-msvc-npm-1.3.71-06f942840b/node_modules/@swc/core-win32-x64-msvc/",\ + "packageDependencies": [\ + ["@swc/core-win32-x64-msvc", "npm:1.3.71"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@swc/wasm", [\ + ["npm:1.2.130", {\ + "packageLocation": "./.yarn/cache/@swc-wasm-npm-1.2.130-aa6df1a820-a0a12de133.zip/node_modules/@swc/wasm/",\ + "packageDependencies": [\ + ["@swc/wasm", "npm:1.2.130"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@syuilo/aiscript", [\ + ["npm:0.17.0", {\ + "packageLocation": "./.yarn/cache/@syuilo-aiscript-npm-0.17.0-005e068ae6-2a10b3d29a.zip/node_modules/@syuilo/aiscript/",\ + "packageDependencies": [\ + ["@syuilo/aiscript", "npm:0.17.0"],\ + ["seedrandom", "npm:3.0.5"],\ + ["stringz", "npm:2.1.0"],\ + ["uuid", "npm:9.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@szmarczak/http-timer", [\ + ["npm:4.0.6", {\ + "packageLocation": "./.yarn/cache/@szmarczak-http-timer-npm-4.0.6-6ace00d82d-c29df3bcec.zip/node_modules/@szmarczak/http-timer/",\ + "packageDependencies": [\ + ["@szmarczak/http-timer", "npm:4.0.6"],\ + ["defer-to-connect", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@tokenizer/inflate", [\ + ["npm:0.4.1", {\ + "packageLocation": "./.yarn/cache/@tokenizer-inflate-npm-0.4.1-90a0f1fb5c-27d58757e1.zip/node_modules/@tokenizer/inflate/",\ + "packageDependencies": [\ + ["@tokenizer/inflate", "npm:0.4.1"],\ + ["debug", "virtual:90a0f1fb5c11f2caeade015df18a36b1fbdd43c7dd5da4b8fc27a92da9a256be3f461a218d644a2c25bbbb94dccf8169d67cc52a5c2857f0f996be9f75f65682#npm:4.4.3"],\ + ["token-types", "npm:6.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@tokenizer/token", [\ + ["npm:0.3.0", {\ + "packageLocation": "./.yarn/cache/@tokenizer-token-npm-0.3.0-4441352cc5-889c1f1e63.zip/node_modules/@tokenizer/token/",\ + "packageDependencies": [\ + ["@tokenizer/token", "npm:0.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@tootallnate/once", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/@tootallnate-once-npm-2.0.0-e36cf4f140-ad87447820.zip/node_modules/@tootallnate/once/",\ + "packageDependencies": [\ + ["@tootallnate/once", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@twemoji/parser", [\ + ["npm:16.0.0", {\ + "packageLocation": "./.yarn/cache/@twemoji-parser-npm-16.0.0-4b53c6bc88-05bf357192.zip/node_modules/@twemoji/parser/",\ + "packageDependencies": [\ + ["@twemoji/parser", "npm:16.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:17.0.1", {\ + "packageLocation": "./.yarn/cache/@twemoji-parser-npm-17.0.1-a06a3acdab-814922e3e2.zip/node_modules/@twemoji/parser/",\ + "packageDependencies": [\ + ["@twemoji/parser", "npm:17.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/accepts", [\ + ["npm:1.3.5", {\ + "packageLocation": "./.yarn/cache/@types-accepts-npm-1.3.5-1d59cd2a7a-3984edd631.zip/node_modules/@types/accepts/",\ + "packageDependencies": [\ + ["@types/accepts", "npm:1.3.5"],\ + ["@types/node", "npm:20.4.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/adm-zip", [\ + ["npm:0.5.0", {\ + "packageLocation": "./.yarn/cache/@types-adm-zip-npm-0.5.0-c475a24319-59110171eb.zip/node_modules/@types/adm-zip/",\ + "packageDependencies": [\ + ["@types/adm-zip", "npm:0.5.0"],\ + ["@types/node", "npm:20.4.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/argparse", [\ + ["npm:1.0.38", {\ + "packageLocation": "./.yarn/cache/@types-argparse-npm-1.0.38-657c15204c-26ed7e3f1e.zip/node_modules/@types/argparse/",\ + "packageDependencies": [\ + ["@types/argparse", "npm:1.0.38"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/async-lock", [\ + ["npm:1.4.0", {\ + "packageLocation": "./.yarn/cache/@types-async-lock-npm-1.4.0-0fbe2519a5-3822d080b8.zip/node_modules/@types/async-lock/",\ + "packageDependencies": [\ + ["@types/async-lock", "npm:1.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/body-parser", [\ + ["npm:1.19.2", {\ + "packageLocation": "./.yarn/cache/@types-body-parser-npm-1.19.2-f845b7b538-e17840c7d7.zip/node_modules/@types/body-parser/",\ + "packageDependencies": [\ + ["@types/body-parser", "npm:1.19.2"],\ + ["@types/connect", "npm:3.4.35"],\ + ["@types/node", "npm:20.4.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/cacheable-request", [\ + ["npm:6.0.3", {\ + "packageLocation": "./.yarn/cache/@types-cacheable-request-npm-6.0.3-770619032a-159f9fdb2a.zip/node_modules/@types/cacheable-request/",\ + "packageDependencies": [\ + ["@types/cacheable-request", "npm:6.0.3"],\ + ["@types/http-cache-semantics", "npm:4.0.1"],\ + ["@types/keyv", "npm:3.1.4"],\ + ["@types/node", "npm:20.4.5"],\ + ["@types/responselike", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/co-body", [\ + ["npm:6.1.0", {\ + "packageLocation": "./.yarn/cache/@types-co-body-npm-6.1.0-9c84c63ec0-95de9fe423.zip/node_modules/@types/co-body/",\ + "packageDependencies": [\ + ["@types/co-body", "npm:6.1.0"],\ + ["@types/node", "npm:20.4.5"],\ + ["@types/qs", "npm:6.9.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/connect", [\ + ["npm:3.4.35", {\ + "packageLocation": "./.yarn/cache/@types-connect-npm-3.4.35-7337eee0a3-fe81351470.zip/node_modules/@types/connect/",\ + "packageDependencies": [\ + ["@types/connect", "npm:3.4.35"],\ + ["@types/node", "npm:20.4.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/content-disposition", [\ + ["npm:0.5.5", {\ + "packageLocation": "./.yarn/cache/@types-content-disposition-npm-0.5.5-2219aba782-fdf7379db1.zip/node_modules/@types/content-disposition/",\ + "packageDependencies": [\ + ["@types/content-disposition", "npm:0.5.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/cookies", [\ + ["npm:0.7.7", {\ + "packageLocation": "./.yarn/cache/@types-cookies-npm-0.7.7-0fbdd53be5-0571f0f1da.zip/node_modules/@types/cookies/",\ + "packageDependencies": [\ + ["@types/connect", "npm:3.4.35"],\ + ["@types/cookies", "npm:0.7.7"],\ + ["@types/express", "npm:4.17.17"],\ + ["@types/keygrip", "npm:1.0.2"],\ + ["@types/node", "npm:20.4.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/disposable-email-domains", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/cache/@types-disposable-email-domains-npm-1.0.4-07039890e8-cdcd6e04c3.zip/node_modules/@types/disposable-email-domains/",\ + "packageDependencies": [\ + ["@types/disposable-email-domains", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/escape-regexp", [\ + ["npm:0.0.1", {\ + "packageLocation": "./.yarn/cache/@types-escape-regexp-npm-0.0.1-61443a8017-4a57cab3d8.zip/node_modules/@types/escape-regexp/",\ + "packageDependencies": [\ + ["@types/escape-regexp", "npm:0.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/estree", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/@types-estree-npm-1.0.1-4c9469c165-f252569c00.zip/node_modules/@types/estree/",\ + "packageDependencies": [\ + ["@types/estree", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.0.8", {\ + "packageLocation": "./.yarn/cache/@types-estree-npm-1.0.8-2195bac6d6-25a4c16a67.zip/node_modules/@types/estree/",\ + "packageDependencies": [\ + ["@types/estree", "npm:1.0.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/express", [\ + ["npm:4.17.17", {\ + "packageLocation": "./.yarn/cache/@types-express-npm-4.17.17-46fe8173db-e2959a5fec.zip/node_modules/@types/express/",\ + "packageDependencies": [\ + ["@types/body-parser", "npm:1.19.2"],\ + ["@types/express", "npm:4.17.17"],\ + ["@types/express-serve-static-core", "npm:4.17.35"],\ + ["@types/qs", "npm:6.9.7"],\ + ["@types/serve-static", "npm:1.15.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/express-serve-static-core", [\ + ["npm:4.17.35", {\ + "packageLocation": "./.yarn/cache/@types-express-serve-static-core-npm-4.17.35-c86e5f6e4a-9f08212ac1.zip/node_modules/@types/express-serve-static-core/",\ + "packageDependencies": [\ + ["@types/express-serve-static-core", "npm:4.17.35"],\ + ["@types/node", "npm:20.4.5"],\ + ["@types/qs", "npm:6.9.7"],\ + ["@types/range-parser", "npm:1.2.4"],\ + ["@types/send", "npm:0.17.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/fluent-ffmpeg", [\ + ["npm:2.1.21", {\ + "packageLocation": "./.yarn/cache/@types-fluent-ffmpeg-npm-2.1.21-821a9f347a-3af5e5e28b.zip/node_modules/@types/fluent-ffmpeg/",\ + "packageDependencies": [\ + ["@types/fluent-ffmpeg", "npm:2.1.21"],\ + ["@types/node", "npm:20.4.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/formidable", [\ + ["npm:2.0.6", {\ + "packageLocation": "./.yarn/cache/@types-formidable-npm-2.0.6-94d53f71b6-808a9bc112.zip/node_modules/@types/formidable/",\ + "packageDependencies": [\ + ["@types/formidable", "npm:2.0.6"],\ + ["@types/node", "npm:20.4.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/glob", [\ + ["npm:8.1.0", {\ + "packageLocation": "./.yarn/cache/@types-glob-npm-8.1.0-bdb9d0520c-9101f3a906.zip/node_modules/@types/glob/",\ + "packageDependencies": [\ + ["@types/glob", "npm:8.1.0"],\ + ["@types/minimatch", "npm:5.1.2"],\ + ["@types/node", "npm:20.4.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/http-assert", [\ + ["npm:1.5.3", {\ + "packageLocation": "./.yarn/cache/@types-http-assert-npm-1.5.3-d45bf58309-9553e5a0b8.zip/node_modules/@types/http-assert/",\ + "packageDependencies": [\ + ["@types/http-assert", "npm:1.5.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/http-cache-semantics", [\ + ["npm:4.0.1", {\ + "packageLocation": "./.yarn/cache/@types-http-cache-semantics-npm-4.0.1-90863c7a3e-d059bf8a15.zip/node_modules/@types/http-cache-semantics/",\ + "packageDependencies": [\ + ["@types/http-cache-semantics", "npm:4.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.2.0", {\ + "packageLocation": "./.yarn/cache/@types-http-cache-semantics-npm-4.2.0-e5da51ac6d-01ea0dc9c1.zip/node_modules/@types/http-cache-semantics/",\ + "packageDependencies": [\ + ["@types/http-cache-semantics", "npm:4.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/http-errors", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/@types-http-errors-npm-2.0.1-c59d5079a7-3bb0c50b0a.zip/node_modules/@types/http-errors/",\ + "packageDependencies": [\ + ["@types/http-errors", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/js-yaml", [\ + ["npm:4.0.5", {\ + "packageLocation": "./.yarn/cache/@types-js-yaml-npm-4.0.5-bb64d71397-6fff5f47d9.zip/node_modules/@types/js-yaml/",\ + "packageDependencies": [\ + ["@types/js-yaml", "npm:4.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/jsonld", [\ + ["npm:1.5.9", {\ + "packageLocation": "./.yarn/cache/@types-jsonld-npm-1.5.9-ec3c8b2262-60deb68ca5.zip/node_modules/@types/jsonld/",\ + "packageDependencies": [\ + ["@types/jsonld", "npm:1.5.9"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/jsrsasign", [\ + ["npm:10.5.8", {\ + "packageLocation": "./.yarn/cache/@types-jsrsasign-npm-10.5.8-68189191c1-b7e85b4587.zip/node_modules/@types/jsrsasign/",\ + "packageDependencies": [\ + ["@types/jsrsasign", "npm:10.5.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/katex", [\ + ["npm:0.16.0", {\ + "packageLocation": "./.yarn/cache/@types-katex-npm-0.16.0-8ba8236f49-632f92f331.zip/node_modules/@types/katex/",\ + "packageDependencies": [\ + ["@types/katex", "npm:0.16.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/keygrip", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/@types-keygrip-npm-1.0.2-2e57be51bc-60bc2738a4.zip/node_modules/@types/keygrip/",\ + "packageDependencies": [\ + ["@types/keygrip", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/keyv", [\ + ["npm:3.1.4", {\ + "packageLocation": "./.yarn/cache/@types-keyv-npm-3.1.4-a8082ea56b-e009a2bfb5.zip/node_modules/@types/keyv/",\ + "packageDependencies": [\ + ["@types/keyv", "npm:3.1.4"],\ + ["@types/node", "npm:20.4.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/koa", [\ + ["npm:2.13.6", {\ + "packageLocation": "./.yarn/cache/@types-koa-npm-2.13.6-a8726b332e-cba52d2418.zip/node_modules/@types/koa/",\ + "packageDependencies": [\ + ["@types/accepts", "npm:1.3.5"],\ + ["@types/content-disposition", "npm:0.5.5"],\ + ["@types/cookies", "npm:0.7.7"],\ + ["@types/http-assert", "npm:1.5.3"],\ + ["@types/http-errors", "npm:2.0.1"],\ + ["@types/keygrip", "npm:1.0.2"],\ + ["@types/koa", "npm:2.13.6"],\ + ["@types/koa-compose", "npm:3.2.5"],\ + ["@types/node", "npm:20.4.5"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.13.7", {\ + "packageLocation": "./.yarn/cache/@types-koa-npm-2.13.7-240d3cdcf2-4856cb68f7.zip/node_modules/@types/koa/",\ + "packageDependencies": [\ + ["@types/accepts", "npm:1.3.5"],\ + ["@types/content-disposition", "npm:0.5.5"],\ + ["@types/cookies", "npm:0.7.7"],\ + ["@types/http-assert", "npm:1.5.3"],\ + ["@types/http-errors", "npm:2.0.1"],\ + ["@types/keygrip", "npm:1.0.2"],\ + ["@types/koa", "npm:2.13.7"],\ + ["@types/koa-compose", "npm:3.2.5"],\ + ["@types/node", "npm:20.4.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/koa-bodyparser", [\ + ["npm:4.3.10", {\ + "packageLocation": "./.yarn/cache/@types-koa-bodyparser-npm-4.3.10-281609ae41-4b4cd17681.zip/node_modules/@types/koa-bodyparser/",\ + "packageDependencies": [\ + ["@types/koa", "npm:2.13.7"],\ + ["@types/koa-bodyparser", "npm:4.3.10"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/koa-compose", [\ + ["npm:3.2.5", {\ + "packageLocation": "./.yarn/cache/@types-koa-compose-npm-3.2.5-b9ab25d904-0f46016227.zip/node_modules/@types/koa-compose/",\ + "packageDependencies": [\ + ["@types/koa", "npm:2.13.7"],\ + ["@types/koa-compose", "npm:3.2.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/koa-cors", [\ + ["npm:0.0.2", {\ + "packageLocation": "./.yarn/cache/@types-koa-cors-npm-0.0.2-c6764a76ab-7218bd8f46.zip/node_modules/@types/koa-cors/",\ + "packageDependencies": [\ + ["@types/koa", "npm:2.13.7"],\ + ["@types/koa-cors", "npm:0.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/koa-favicon", [\ + ["npm:2.0.21", {\ + "packageLocation": "./.yarn/cache/@types-koa-favicon-npm-2.0.21-b2ab466a7f-7e3da0dd43.zip/node_modules/@types/koa-favicon/",\ + "packageDependencies": [\ + ["@types/koa", "npm:2.13.7"],\ + ["@types/koa-favicon", "npm:2.0.21"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/koa-logger", [\ + ["npm:3.1.2", {\ + "packageLocation": "./.yarn/cache/@types-koa-logger-npm-3.1.2-6153de62e0-8e4cfcdb24.zip/node_modules/@types/koa-logger/",\ + "packageDependencies": [\ + ["@types/koa", "npm:2.13.7"],\ + ["@types/koa-logger", "npm:3.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/koa-mount", [\ + ["npm:4.0.2", {\ + "packageLocation": "./.yarn/cache/@types-koa-mount-npm-4.0.2-1f20290a94-6f376035ae.zip/node_modules/@types/koa-mount/",\ + "packageDependencies": [\ + ["@types/koa", "npm:2.13.7"],\ + ["@types/koa-mount", "npm:4.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/koa-send", [\ + ["npm:4.1.3", {\ + "packageLocation": "./.yarn/cache/@types-koa-send-npm-4.1.3-07a2282495-f20f6a0dcc.zip/node_modules/@types/koa-send/",\ + "packageDependencies": [\ + ["@types/koa", "npm:2.13.7"],\ + ["@types/koa-send", "npm:4.1.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/koa-views", [\ + ["npm:7.0.0", {\ + "packageLocation": "./.yarn/cache/@types-koa-views-npm-7.0.0-b4af58161f-0325338041.zip/node_modules/@types/koa-views/",\ + "packageDependencies": [\ + ["@types/koa-views", "npm:7.0.0"],\ + ["koa-views", "virtual:b4af58161fd183d4fbd2129fe4c22cc032d75bc29df35f30e106743a6b0753da95cbfe952c1cfa2afab5061506b95a6c48070f65c332e28defab4f16f6a6de0e#npm:8.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/koa__cors", [\ + ["npm:3.3.0", {\ + "packageLocation": "./.yarn/cache/@types-koa__cors-npm-3.3.0-d247b76272-c1aeb10b07.zip/node_modules/@types/koa__cors/",\ + "packageDependencies": [\ + ["@types/koa", "npm:2.13.7"],\ + ["@types/koa__cors", "npm:3.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/matter-js", [\ + ["npm:0.18.2", {\ + "packageLocation": "./.yarn/cache/@types-matter-js-npm-0.18.2-68ffbb7547-eedbc15dac.zip/node_modules/@types/matter-js/",\ + "packageDependencies": [\ + ["@types/matter-js", "npm:0.18.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/mime", [\ + ["npm:1.3.2", {\ + "packageLocation": "./.yarn/cache/@types-mime-npm-1.3.2-ea71878ab3-0493368244.zip/node_modules/@types/mime/",\ + "packageDependencies": [\ + ["@types/mime", "npm:1.3.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.1", {\ + "packageLocation": "./.yarn/cache/@types-mime-npm-3.0.1-dec03536dc-4040fac73f.zip/node_modules/@types/mime/",\ + "packageDependencies": [\ + ["@types/mime", "npm:3.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/minimatch", [\ + ["npm:5.1.2", {\ + "packageLocation": "./.yarn/cache/@types-minimatch-npm-5.1.2-aab9c394d3-94db5060d2.zip/node_modules/@types/minimatch/",\ + "packageDependencies": [\ + ["@types/minimatch", "npm:5.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/needle", [\ + ["npm:3.2.0", {\ + "packageLocation": "./.yarn/cache/@types-needle-npm-3.2.0-fe015ad7d7-bc06985436.zip/node_modules/@types/needle/",\ + "packageDependencies": [\ + ["@types/needle", "npm:3.2.0"],\ + ["@types/node", "npm:20.4.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/node", [\ + ["npm:20.4.5", {\ + "packageLocation": "./.yarn/cache/@types-node-npm-20.4.5-7555fad0d4-aa31081f82.zip/node_modules/@types/node/",\ + "packageDependencies": [\ + ["@types/node", "npm:20.4.5"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:22.19.18", {\ + "packageLocation": "./.yarn/cache/@types-node-npm-22.19.18-29e14a8654-464dbc4fb2.zip/node_modules/@types/node/",\ + "packageDependencies": [\ + ["@types/node", "npm:22.19.18"],\ + ["undici-types", "npm:6.21.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:25.6.2", {\ + "packageLocation": "./.yarn/cache/@types-node-npm-25.6.2-59b4df6216-a8afba633f.zip/node_modules/@types/node/",\ + "packageDependencies": [\ + ["@types/node", "npm:25.6.2"],\ + ["undici-types", "npm:7.19.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/node-fetch", [\ + ["npm:3.0.3", {\ + "packageLocation": "./.yarn/cache/@types-node-fetch-npm-3.0.3-d14cbff213-1d46abfda3.zip/node_modules/@types/node-fetch/",\ + "packageDependencies": [\ + ["@types/node-fetch", "npm:3.0.3"],\ + ["node-fetch", "npm:3.3.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/nodemailer", [\ + ["npm:6.4.8", {\ + "packageLocation": "./.yarn/cache/@types-nodemailer-npm-6.4.8-04975b93f9-d5afdd77ef.zip/node_modules/@types/nodemailer/",\ + "packageDependencies": [\ + ["@types/node", "npm:20.4.5"],\ + ["@types/nodemailer", "npm:6.4.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/oauth", [\ + ["npm:0.9.1", {\ + "packageLocation": "./.yarn/cache/@types-oauth-npm-0.9.1-94ae218a9b-cd83c34b2f.zip/node_modules/@types/oauth/",\ + "packageDependencies": [\ + ["@types/node", "npm:20.4.5"],\ + ["@types/oauth", "npm:0.9.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/pg", [\ + ["npm:8.10.5", {\ + "packageLocation": "./.yarn/cache/@types-pg-npm-8.10.5-9e5f4e8cf1-0268add3d0.zip/node_modules/@types/pg/",\ + "packageDependencies": [\ + ["@types/node", "npm:20.4.5"],\ + ["@types/pg", "npm:8.10.5"],\ + ["pg-protocol", "npm:1.6.0"],\ + ["pg-types", "npm:4.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/probe-image-size", [\ + ["npm:7.2.0", {\ + "packageLocation": "./.yarn/cache/@types-probe-image-size-npm-7.2.0-adc5f6b584-dea93064ca.zip/node_modules/@types/probe-image-size/",\ + "packageDependencies": [\ + ["@types/needle", "npm:3.2.0"],\ + ["@types/node", "npm:20.4.5"],\ + ["@types/probe-image-size", "npm:7.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/pug", [\ + ["npm:2.0.6", {\ + "packageLocation": "./.yarn/cache/@types-pug-npm-2.0.6-445b1c16dc-d47a9c63bc.zip/node_modules/@types/pug/",\ + "packageDependencies": [\ + ["@types/pug", "npm:2.0.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/punycode", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/@types-punycode-npm-2.1.0-43d6b09a89-bec8e467ad.zip/node_modules/@types/punycode/",\ + "packageDependencies": [\ + ["@types/punycode", "npm:2.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/qrcode", [\ + ["npm:1.5.1", {\ + "packageLocation": "./.yarn/cache/@types-qrcode-npm-1.5.1-132e9c5738-5c5b42c8fe.zip/node_modules/@types/qrcode/",\ + "packageDependencies": [\ + ["@types/node", "npm:20.4.5"],\ + ["@types/qrcode", "npm:1.5.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/qs", [\ + ["npm:6.9.7", {\ + "packageLocation": "./.yarn/cache/@types-qs-npm-6.9.7-4a3e6ca0d0-7fd6f9c250.zip/node_modules/@types/qs/",\ + "packageDependencies": [\ + ["@types/qs", "npm:6.9.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/random-seed", [\ + ["npm:0.3.3", {\ + "packageLocation": "./.yarn/cache/@types-random-seed-npm-0.3.3-c58758b80f-df09d84043.zip/node_modules/@types/random-seed/",\ + "packageDependencies": [\ + ["@types/random-seed", "npm:0.3.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/range-parser", [\ + ["npm:1.2.4", {\ + "packageLocation": "./.yarn/cache/@types-range-parser-npm-1.2.4-23d797fbde-b7c0dfd508.zip/node_modules/@types/range-parser/",\ + "packageDependencies": [\ + ["@types/range-parser", "npm:1.2.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/ratelimiter", [\ + ["npm:3.4.4", {\ + "packageLocation": "./.yarn/cache/@types-ratelimiter-npm-3.4.4-1c8060355c-38f6419c70.zip/node_modules/@types/ratelimiter/",\ + "packageDependencies": [\ + ["@types/ratelimiter", "npm:3.4.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/redis", [\ + ["npm:4.0.11", {\ + "packageLocation": "./.yarn/cache/@types-redis-npm-4.0.11-b44223a489-4b2d252368.zip/node_modules/@types/redis/",\ + "packageDependencies": [\ + ["@types/redis", "npm:4.0.11"],\ + ["redis", "npm:4.6.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/rename", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/cache/@types-rename-npm-1.0.4-928f448e84-259539f879.zip/node_modules/@types/rename/",\ + "packageDependencies": [\ + ["@types/rename", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/responselike", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/@types-responselike-npm-1.0.0-85dd08af42-e497238945.zip/node_modules/@types/responselike/",\ + "packageDependencies": [\ + ["@types/node", "npm:20.4.5"],\ + ["@types/responselike", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/sanitize-html", [\ + ["npm:2.9.0", {\ + "packageLocation": "./.yarn/cache/@types-sanitize-html-npm-2.9.0-2e719aedd2-b60f42b740.zip/node_modules/@types/sanitize-html/",\ + "packageDependencies": [\ + ["@types/sanitize-html", "npm:2.9.0"],\ + ["htmlparser2", "npm:8.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/seedrandom", [\ + ["npm:3.0.5", {\ + "packageLocation": "./.yarn/cache/@types-seedrandom-npm-3.0.5-b6a276228d-d63d56ebc6.zip/node_modules/@types/seedrandom/",\ + "packageDependencies": [\ + ["@types/seedrandom", "npm:3.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/semver", [\ + ["npm:7.5.0", {\ + "packageLocation": "./.yarn/cache/@types-semver-npm-7.5.0-4823ff34be-8fbfbf79e9.zip/node_modules/@types/semver/",\ + "packageDependencies": [\ + ["@types/semver", "npm:7.5.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/send", [\ + ["npm:0.17.1", {\ + "packageLocation": "./.yarn/cache/@types-send-npm-0.17.1-5f715ca966-6420837887.zip/node_modules/@types/send/",\ + "packageDependencies": [\ + ["@types/mime", "npm:1.3.2"],\ + ["@types/node", "npm:20.4.5"],\ + ["@types/send", "npm:0.17.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/serve-static", [\ + ["npm:1.15.2", {\ + "packageLocation": "./.yarn/cache/@types-serve-static-npm-1.15.2-fc398c0cea-d5f8f5aaa7.zip/node_modules/@types/serve-static/",\ + "packageDependencies": [\ + ["@types/http-errors", "npm:2.0.1"],\ + ["@types/mime", "npm:3.0.1"],\ + ["@types/node", "npm:20.4.5"],\ + ["@types/serve-static", "npm:1.15.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/throttle-debounce", [\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/@types-throttle-debounce-npm-5.0.0-c27fac6e7d-73d52e936d.zip/node_modules/@types/throttle-debounce/",\ + "packageDependencies": [\ + ["@types/throttle-debounce", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/tinycolor2", [\ + ["npm:1.4.3", {\ + "packageLocation": "./.yarn/cache/@types-tinycolor2-npm-1.4.3-90e6bf0ed8-abfdf558c4.zip/node_modules/@types/tinycolor2/",\ + "packageDependencies": [\ + ["@types/tinycolor2", "npm:1.4.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/tmp", [\ + ["npm:0.2.3", {\ + "packageLocation": "./.yarn/cache/@types-tmp-npm-0.2.3-ca9c2eba00-a8ddaa0456.zip/node_modules/@types/tmp/",\ + "packageDependencies": [\ + ["@types/tmp", "npm:0.2.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/uuid", [\ + ["npm:10.0.0", {\ + "packageLocation": "./.yarn/cache/@types-uuid-npm-10.0.0-9ac1066765-e3958f8b0f.zip/node_modules/@types/uuid/",\ + "packageDependencies": [\ + ["@types/uuid", "npm:10.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:8.3.4", {\ + "packageLocation": "./.yarn/cache/@types-uuid-npm-8.3.4-7547f4402c-6f11f3ff70.zip/node_modules/@types/uuid/",\ + "packageDependencies": [\ + ["@types/uuid", "npm:8.3.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/web-push", [\ + ["npm:3.3.2", {\ + "packageLocation": "./.yarn/cache/@types-web-push-npm-3.3.2-951f8ac8df-44ede4850c.zip/node_modules/@types/web-push/",\ + "packageDependencies": [\ + ["@types/node", "npm:20.4.5"],\ + ["@types/web-push", "npm:3.3.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/websocket", [\ + ["npm:1.0.5", {\ + "packageLocation": "./.yarn/cache/@types-websocket-npm-1.0.5-201ddc7023-5000bfaa63.zip/node_modules/@types/websocket/",\ + "packageDependencies": [\ + ["@types/node", "npm:20.4.5"],\ + ["@types/websocket", "npm:1.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/whatwg-mimetype", [\ + ["npm:3.0.2", {\ + "packageLocation": "./.yarn/cache/@types-whatwg-mimetype-npm-3.0.2-cf2bd6921c-609607beea.zip/node_modules/@types/whatwg-mimetype/",\ + "packageDependencies": [\ + ["@types/whatwg-mimetype", "npm:3.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@types/ws", [\ + ["npm:8.18.1", {\ + "packageLocation": "./.yarn/cache/@types-ws-npm-8.18.1-61dc106ff0-1ce05e3174.zip/node_modules/@types/ws/",\ + "packageDependencies": [\ + ["@types/node", "npm:20.4.5"],\ + ["@types/ws", "npm:8.18.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@vitejs/plugin-vue", [\ + ["npm:6.0.6", {\ + "packageLocation": "./.yarn/cache/@vitejs-plugin-vue-npm-6.0.6-e56fffa088-f1eac02fd8.zip/node_modules/@vitejs/plugin-vue/",\ + "packageDependencies": [\ + ["@vitejs/plugin-vue", "npm:6.0.6"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:6.0.6", {\ + "packageLocation": "./.yarn/__virtual__/@vitejs-plugin-vue-virtual-dc3a73ec76/0/cache/@vitejs-plugin-vue-npm-6.0.6-e56fffa088-f1eac02fd8.zip/node_modules/@vitejs/plugin-vue/",\ + "packageDependencies": [\ + ["@rolldown/pluginutils", "npm:1.0.0-rc.13"],\ + ["@types/vite", null],\ + ["@types/vue", null],\ + ["@vitejs/plugin-vue", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:6.0.6"],\ + ["vite", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:7.3.3"],\ + ["vue", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:3.5.34"]\ + ],\ + "packagePeers": [\ + "@types/vite",\ + "@types/vue",\ + "vite",\ + "vue"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@vue/compiler-core", [\ + ["npm:3.5.34", {\ + "packageLocation": "./.yarn/cache/@vue-compiler-core-npm-3.5.34-6f12bd8e43-95439f8773.zip/node_modules/@vue/compiler-core/",\ + "packageDependencies": [\ + ["@babel/parser", "npm:7.29.3"],\ + ["@vue/compiler-core", "npm:3.5.34"],\ + ["@vue/shared", "npm:3.5.34"],\ + ["entities", "npm:7.0.1"],\ + ["estree-walker", "npm:2.0.2"],\ + ["source-map-js", "npm:1.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@vue/compiler-dom", [\ + ["npm:3.5.34", {\ + "packageLocation": "./.yarn/cache/@vue-compiler-dom-npm-3.5.34-b210d03297-d4318f56b3.zip/node_modules/@vue/compiler-dom/",\ + "packageDependencies": [\ + ["@vue/compiler-core", "npm:3.5.34"],\ + ["@vue/compiler-dom", "npm:3.5.34"],\ + ["@vue/shared", "npm:3.5.34"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@vue/compiler-sfc", [\ + ["npm:3.5.34", {\ + "packageLocation": "./.yarn/cache/@vue-compiler-sfc-npm-3.5.34-65e0e4e475-9962715375.zip/node_modules/@vue/compiler-sfc/",\ + "packageDependencies": [\ + ["@babel/parser", "npm:7.29.3"],\ + ["@vue/compiler-core", "npm:3.5.34"],\ + ["@vue/compiler-dom", "npm:3.5.34"],\ + ["@vue/compiler-sfc", "npm:3.5.34"],\ + ["@vue/compiler-ssr", "npm:3.5.34"],\ + ["@vue/shared", "npm:3.5.34"],\ + ["estree-walker", "npm:2.0.2"],\ + ["magic-string", "npm:0.30.21"],\ + ["postcss", "npm:8.5.14"],\ + ["source-map-js", "npm:1.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@vue/compiler-ssr", [\ + ["npm:3.5.34", {\ + "packageLocation": "./.yarn/cache/@vue-compiler-ssr-npm-3.5.34-219fca6245-7d5d8a9d8f.zip/node_modules/@vue/compiler-ssr/",\ + "packageDependencies": [\ + ["@vue/compiler-dom", "npm:3.5.34"],\ + ["@vue/compiler-ssr", "npm:3.5.34"],\ + ["@vue/shared", "npm:3.5.34"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@vue/reactivity", [\ + ["npm:3.5.34", {\ + "packageLocation": "./.yarn/cache/@vue-reactivity-npm-3.5.34-3ea80cb645-2dc38667ba.zip/node_modules/@vue/reactivity/",\ + "packageDependencies": [\ + ["@vue/reactivity", "npm:3.5.34"],\ + ["@vue/shared", "npm:3.5.34"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@vue/runtime-core", [\ + ["npm:3.5.34", {\ + "packageLocation": "./.yarn/cache/@vue-runtime-core-npm-3.5.34-b72f6bf01b-4ba5b51e4d.zip/node_modules/@vue/runtime-core/",\ + "packageDependencies": [\ + ["@vue/reactivity", "npm:3.5.34"],\ + ["@vue/runtime-core", "npm:3.5.34"],\ + ["@vue/shared", "npm:3.5.34"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@vue/runtime-dom", [\ + ["npm:3.5.34", {\ + "packageLocation": "./.yarn/cache/@vue-runtime-dom-npm-3.5.34-1d615f8756-3f489d8d79.zip/node_modules/@vue/runtime-dom/",\ + "packageDependencies": [\ + ["@vue/reactivity", "npm:3.5.34"],\ + ["@vue/runtime-core", "npm:3.5.34"],\ + ["@vue/runtime-dom", "npm:3.5.34"],\ + ["@vue/shared", "npm:3.5.34"],\ + ["csstype", "npm:3.2.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@vue/server-renderer", [\ + ["npm:3.5.34", {\ + "packageLocation": "./.yarn/cache/@vue-server-renderer-npm-3.5.34-df9d577619-815bbee6ad.zip/node_modules/@vue/server-renderer/",\ + "packageDependencies": [\ + ["@vue/server-renderer", "npm:3.5.34"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:8fb0bb5e3429bf317458b89e57cf60d21419aa43a9e47d4164e4e90443a816c22614c0c4d180e24dc0203d64f80714ea03fad637b2550e20a4b2ca92d495c552#npm:3.5.34", {\ + "packageLocation": "./.yarn/__virtual__/@vue-server-renderer-virtual-207cdd9030/0/cache/@vue-server-renderer-npm-3.5.34-df9d577619-815bbee6ad.zip/node_modules/@vue/server-renderer/",\ + "packageDependencies": [\ + ["@types/vue", null],\ + ["@vue/compiler-ssr", "npm:3.5.34"],\ + ["@vue/server-renderer", "virtual:8fb0bb5e3429bf317458b89e57cf60d21419aa43a9e47d4164e4e90443a816c22614c0c4d180e24dc0203d64f80714ea03fad637b2550e20a4b2ca92d495c552#npm:3.5.34"],\ + ["@vue/shared", "npm:3.5.34"],\ + ["vue", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:3.5.34"]\ + ],\ + "packagePeers": [\ + "@types/vue",\ + "vue"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["@vue/shared", [\ + ["npm:3.5.34", {\ + "packageLocation": "./.yarn/cache/@vue-shared-npm-3.5.34-40d3253649-1c932c7ced.zip/node_modules/@vue/shared/",\ + "packageDependencies": [\ + ["@vue/shared", "npm:3.5.34"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["abbrev", [\ + ["npm:1.1.1", {\ + "packageLocation": "./.yarn/cache/abbrev-npm-1.1.1-3659247eab-2d88294118.zip/node_modules/abbrev/",\ + "packageDependencies": [\ + ["abbrev", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.1", {\ + "packageLocation": "./.yarn/cache/abbrev-npm-3.0.1-a34d600e50-ebd2c149dd.zip/node_modules/abbrev/",\ + "packageDependencies": [\ + ["abbrev", "npm:3.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["abort-controller", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/abort-controller-npm-3.0.0-2f3a9a2bcb-ed84af329f.zip/node_modules/abort-controller/",\ + "packageDependencies": [\ + ["abort-controller", "npm:3.0.0"],\ + ["event-target-shim", "npm:5.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["accepts", [\ + ["npm:1.3.8", {\ + "packageLocation": "./.yarn/cache/accepts-npm-1.3.8-9a812371c9-67eaaa90e2.zip/node_modules/accepts/",\ + "packageDependencies": [\ + ["accepts", "npm:1.3.8"],\ + ["mime-types", "npm:2.1.35"],\ + ["negotiator", "npm:0.6.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["acorn", [\ + ["npm:7.4.1", {\ + "packageLocation": "./.yarn/cache/acorn-npm-7.4.1-f450b4646c-8be2a40714.zip/node_modules/acorn/",\ + "packageDependencies": [\ + ["acorn", "npm:7.4.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:8.16.0", {\ + "packageLocation": "./.yarn/cache/acorn-npm-8.16.0-b2096bf83f-690c673bb4.zip/node_modules/acorn/",\ + "packageDependencies": [\ + ["acorn", "npm:8.16.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["adm-zip", [\ + ["npm:0.5.10", {\ + "packageLocation": "./.yarn/cache/adm-zip-npm-0.5.10-17a872f2fd-c5ab79b771.zip/node_modules/adm-zip/",\ + "packageDependencies": [\ + ["adm-zip", "npm:0.5.10"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["agent-base", [\ + ["npm:6.0.2", {\ + "packageLocation": "./.yarn/cache/agent-base-npm-6.0.2-428f325a93-21fb903e09.zip/node_modules/agent-base/",\ + "packageDependencies": [\ + ["agent-base", "npm:6.0.2"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.1.0", {\ + "packageLocation": "./.yarn/cache/agent-base-npm-7.1.0-4b12ba5111-f7828f9914.zip/node_modules/agent-base/",\ + "packageDependencies": [\ + ["agent-base", "npm:7.1.0"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.1.3", {\ + "packageLocation": "./.yarn/cache/agent-base-npm-7.1.3-b2c16e72fb-3db6d8d465.zip/node_modules/agent-base/",\ + "packageDependencies": [\ + ["agent-base", "npm:7.1.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["agentkeepalive", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/agentkeepalive-npm-4.3.0-ac3d8e6807-f791317eb4.zip/node_modules/agentkeepalive/",\ + "packageDependencies": [\ + ["agentkeepalive", "npm:4.3.0"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["depd", "npm:2.0.0"],\ + ["humanize-ms", "npm:1.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["aggregate-error", [\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/aggregate-error-npm-3.1.0-415a406f4e-1101a33f21.zip/node_modules/aggregate-error/",\ + "packageDependencies": [\ + ["aggregate-error", "npm:3.1.0"],\ + ["clean-stack", "npm:2.2.0"],\ + ["indent-string", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ajv", [\ + ["npm:6.12.6", {\ + "packageLocation": "./.yarn/cache/ajv-npm-6.12.6-4b5105e2b2-48d6ad2113.zip/node_modules/ajv/",\ + "packageDependencies": [\ + ["ajv", "npm:6.12.6"],\ + ["fast-deep-equal", "npm:3.1.3"],\ + ["fast-json-stable-stringify", "npm:2.1.0"],\ + ["json-schema-traverse", "npm:0.4.1"],\ + ["uri-js", "npm:4.4.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:8.12.0", {\ + "packageLocation": "./.yarn/cache/ajv-npm-8.12.0-3bf6e30741-b406f3b79b.zip/node_modules/ajv/",\ + "packageDependencies": [\ + ["ajv", "npm:8.12.0"],\ + ["fast-deep-equal", "npm:3.1.3"],\ + ["json-schema-traverse", "npm:1.0.0"],\ + ["require-from-string", "npm:2.0.2"],\ + ["uri-js", "npm:4.4.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ansi-regex", [\ + ["npm:5.0.1", {\ + "packageLocation": "./.yarn/cache/ansi-regex-npm-5.0.1-c963a48615-2aa4bb54ca.zip/node_modules/ansi-regex/",\ + "packageDependencies": [\ + ["ansi-regex", "npm:5.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:6.0.1", {\ + "packageLocation": "./.yarn/cache/ansi-regex-npm-6.0.1-8d663a607d-1ff8b7667c.zip/node_modules/ansi-regex/",\ + "packageDependencies": [\ + ["ansi-regex", "npm:6.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ansi-styles", [\ + ["npm:3.2.1", {\ + "packageLocation": "./.yarn/cache/ansi-styles-npm-3.2.1-8cb8107983-d85ade01c1.zip/node_modules/ansi-styles/",\ + "packageDependencies": [\ + ["ansi-styles", "npm:3.2.1"],\ + ["color-convert", "npm:1.9.3"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/ansi-styles-npm-4.3.0-245c7d42c7-b4494dfbfc.zip/node_modules/ansi-styles/",\ + "packageDependencies": [\ + ["ansi-styles", "npm:4.3.0"],\ + ["color-convert", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:6.2.1", {\ + "packageLocation": "./.yarn/cache/ansi-styles-npm-6.2.1-d43647018c-70fdf883b7.zip/node_modules/ansi-styles/",\ + "packageDependencies": [\ + ["ansi-styles", "npm:6.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["any-promise", [\ + ["npm:1.3.0", {\ + "packageLocation": "./.yarn/cache/any-promise-npm-1.3.0-f34eeaa7e7-6737469ba3.zip/node_modules/any-promise/",\ + "packageDependencies": [\ + ["any-promise", "npm:1.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["anymatch", [\ + ["npm:3.1.3", {\ + "packageLocation": "./.yarn/cache/anymatch-npm-3.1.3-bc81d103b1-3e044fd6d1.zip/node_modules/anymatch/",\ + "packageDependencies": [\ + ["anymatch", "npm:3.1.3"],\ + ["normalize-path", "npm:3.0.0"],\ + ["picomatch", "npm:2.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["app-root-path", [\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/app-root-path-npm-3.1.0-9822bb2a96-b4cdab5f7e.zip/node_modules/app-root-path/",\ + "packageDependencies": [\ + ["app-root-path", "npm:3.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["append-field", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/append-field-npm-1.0.0-bb98be199c-afb50f5ff6.zip/node_modules/append-field/",\ + "packageDependencies": [\ + ["append-field", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["aproba", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/aproba-npm-2.0.0-8716bcfde6-c2b9a63129.zip/node_modules/aproba/",\ + "packageDependencies": [\ + ["aproba", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["arch", [\ + ["npm:2.2.0", {\ + "packageLocation": "./.yarn/cache/arch-npm-2.2.0-34797684d8-e35dbc6d36.zip/node_modules/arch/",\ + "packageDependencies": [\ + ["arch", "npm:2.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["archiver", [\ + ["npm:5.3.1", {\ + "packageLocation": "./.yarn/cache/archiver-npm-5.3.1-db84171f80-f77b575694.zip/node_modules/archiver/",\ + "packageDependencies": [\ + ["archiver", "npm:5.3.1"],\ + ["archiver-utils", "npm:2.1.0"],\ + ["async", "npm:3.2.4"],\ + ["buffer-crc32", "npm:0.2.13"],\ + ["readable-stream", "npm:3.6.2"],\ + ["readdir-glob", "npm:1.1.3"],\ + ["tar-stream", "npm:2.2.0"],\ + ["zip-stream", "npm:4.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["archiver-utils", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/archiver-utils-npm-2.1.0-c06ce16cc3-4df493c0e6.zip/node_modules/archiver-utils/",\ + "packageDependencies": [\ + ["archiver-utils", "npm:2.1.0"],\ + ["glob", "npm:7.2.3"],\ + ["graceful-fs", "npm:4.2.11"],\ + ["lazystream", "npm:1.0.1"],\ + ["lodash.defaults", "npm:4.2.0"],\ + ["lodash.difference", "npm:4.5.0"],\ + ["lodash.flatten", "npm:4.4.0"],\ + ["lodash.isplainobject", "npm:4.0.6"],\ + ["lodash.union", "npm:4.6.0"],\ + ["normalize-path", "npm:3.0.0"],\ + ["readable-stream", "npm:2.3.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["are-we-there-yet", [\ + ["npm:3.0.1", {\ + "packageLocation": "./.yarn/cache/are-we-there-yet-npm-3.0.1-3395b1512f-390731720e.zip/node_modules/are-we-there-yet/",\ + "packageDependencies": [\ + ["are-we-there-yet", "npm:3.0.1"],\ + ["delegates", "npm:1.0.0"],\ + ["readable-stream", "npm:3.6.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["argon2", [\ + ["npm:0.44.0", {\ + "packageLocation": "./.yarn/unplugged/argon2-npm-0.44.0-c350d5952d/node_modules/argon2/",\ + "packageDependencies": [\ + ["@phc/format", "npm:1.0.0"],\ + ["argon2", "npm:0.44.0"],\ + ["cross-env", "npm:10.1.0"],\ + ["node-addon-api", "npm:8.7.0"],\ + ["node-gyp", "npm:9.4.0"],\ + ["node-gyp-build", "npm:4.8.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["argparse", [\ + ["npm:1.0.10", {\ + "packageLocation": "./.yarn/cache/argparse-npm-1.0.10-528934e59d-c6a621343a.zip/node_modules/argparse/",\ + "packageDependencies": [\ + ["argparse", "npm:1.0.10"],\ + ["sprintf-js", "npm:1.0.3"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/argparse-npm-2.0.1-faff7999e6-18640244e6.zip/node_modules/argparse/",\ + "packageDependencies": [\ + ["argparse", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["asap", [\ + ["npm:2.0.6", {\ + "packageLocation": "./.yarn/cache/asap-npm-2.0.6-36714d439d-b244c0458c.zip/node_modules/asap/",\ + "packageDependencies": [\ + ["asap", "npm:2.0.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["asn1", [\ + ["npm:0.2.6", {\ + "packageLocation": "./.yarn/cache/asn1-npm-0.2.6-bdd07356c4-cf629291fe.zip/node_modules/asn1/",\ + "packageDependencies": [\ + ["asn1", "npm:0.2.6"],\ + ["safer-buffer", "npm:2.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["asn1.js", [\ + ["npm:5.4.1", {\ + "packageLocation": "./.yarn/cache/asn1.js-npm-5.4.1-37c7edbcb0-63d57c766f.zip/node_modules/asn1.js/",\ + "packageDependencies": [\ + ["asn1.js", "npm:5.4.1"],\ + ["bn.js", "npm:4.12.0"],\ + ["inherits", "npm:2.0.4"],\ + ["minimalistic-assert", "npm:1.0.1"],\ + ["safer-buffer", "npm:2.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["assert-never", [\ + ["npm:1.2.1", {\ + "packageLocation": "./.yarn/cache/assert-never-npm-1.2.1-d423b480cd-ea4f1756d9.zip/node_modules/assert-never/",\ + "packageDependencies": [\ + ["assert-never", "npm:1.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["assert-plus", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/assert-plus-npm-1.0.0-cac95ef098-f4f991ae2d.zip/node_modules/assert-plus/",\ + "packageDependencies": [\ + ["assert-plus", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["async", [\ + ["npm:3.2.4", {\ + "packageLocation": "./.yarn/cache/async-npm-3.2.4-aba13508f9-bebb5dc225.zip/node_modules/async/",\ + "packageDependencies": [\ + ["async", "npm:3.2.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["async-lock", [\ + ["npm:1.4.0", {\ + "packageLocation": "./.yarn/cache/async-lock-npm-1.4.0-7a29f925c4-c57d5e741f.zip/node_modules/async-lock/",\ + "packageDependencies": [\ + ["async-lock", "npm:1.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["async-mutex", [\ + ["npm:0.4.0", {\ + "packageLocation": "./.yarn/cache/async-mutex-npm-0.4.0-f5a25d4255-4a55065aae.zip/node_modules/async-mutex/",\ + "packageDependencies": [\ + ["async-mutex", "npm:0.4.0"],\ + ["tslib", "npm:2.6.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["autobind-decorator", [\ + ["npm:2.4.0", {\ + "packageLocation": "./.yarn/cache/autobind-decorator-npm-2.4.0-07ffeb9afd-9e24cb18f2.zip/node_modules/autobind-decorator/",\ + "packageDependencies": [\ + ["autobind-decorator", "npm:2.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["autosize", [\ + ["npm:5.0.2", {\ + "packageLocation": "./.yarn/cache/autosize-npm-5.0.2-b10b6ea512-8e608a3f8b.zip/node_modules/autosize/",\ + "packageDependencies": [\ + ["autosize", "npm:5.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["axios", [\ + ["npm:0.24.0", {\ + "packageLocation": "./.yarn/cache/axios-npm-0.24.0-39e5c1e79e-4c5a7a5a45.zip/node_modules/axios/",\ + "packageDependencies": [\ + ["axios", "npm:0.24.0"],\ + ["follow-redirects", "virtual:39e5c1e79ea63134f0cf339f4463df92854aaf708a45210afd29a0b4b9f67f95b34a1abbcabaae6d0033ad99a1d5f690ab51ed8e5d3283b87ccbc3a9ab3ec05f#npm:1.15.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["b4a", [\ + ["npm:1.6.4", {\ + "packageLocation": "./.yarn/cache/b4a-npm-1.6.4-080bcba845-223158e626.zip/node_modules/b4a/",\ + "packageDependencies": [\ + ["b4a", "npm:1.6.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["babel-walk", [\ + ["npm:3.0.0-canary-5", {\ + "packageLocation": "./.yarn/cache/babel-walk-npm-3.0.0-canary-5-61b07ed745-f4cea17303.zip/node_modules/babel-walk/",\ + "packageDependencies": [\ + ["@babel/types", "npm:7.22.5"],\ + ["babel-walk", "npm:3.0.0-canary-5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["backend", [\ + ["workspace:packages/backend", {\ + "packageLocation": "./packages/backend/",\ + "packageDependencies": [\ + ["@aws-sdk/client-s3", "npm:3.1045.0"],\ + ["@aws-sdk/lib-storage", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:3.1045.0"],\ + ["@bull-board/api", "virtual:1a204444c80de4bcde4565c72a3b0d6571f4eb3f918d7d48d8cd7ddf8392bb94fcf59c7a20ab1f49717ca9715358d5e7cac35936b84d89d5579286a3dc50f306#npm:6.0.0"],\ + ["@bull-board/koa", "npm:6.0.0"],\ + ["@bull-board/ui", "npm:6.0.0"],\ + ["@discordapp/twemoji", "npm:16.0.1"],\ + ["@iceshrimp/summaly", "npm:2.7.3::__archiveUrl=https%3A%2F%2Ficeshrimp.dev%2Fapi%2Fpackages%2Ficeshrimp%2Fnpm%2F%2540iceshrimp%252Fsummaly%2F-%2F2.7.3%2Fsummaly-2.7.3.tgz"],\ + ["@koa/cors", "npm:3.4.3"],\ + ["@koa/multer", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:4.0.0"],\ + ["@koa/router", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:15.5.0"],\ + ["@paralleldrive/cuid2", "npm:2.2.2"],\ + ["@peertube/http-signature", "npm:1.7.0"],\ + ["@smithy/node-http-handler", "npm:4.6.1"],\ + ["@swc/cli", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:0.1.62"],\ + ["@swc/core", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:1.3.71"],\ + ["@swc/core-android-arm64", "npm:1.3.11"],\ + ["@twemoji/parser", "npm:17.0.1"],\ + ["@types/adm-zip", "npm:0.5.0"],\ + ["@types/async-lock", "npm:1.4.0"],\ + ["@types/escape-regexp", "npm:0.0.1"],\ + ["@types/fluent-ffmpeg", "npm:2.1.21"],\ + ["@types/formidable", "npm:2.0.6"],\ + ["@types/js-yaml", "npm:4.0.5"],\ + ["@types/jsonld", "npm:1.5.9"],\ + ["@types/jsrsasign", "npm:10.5.8"],\ + ["@types/koa", "npm:2.13.6"],\ + ["@types/koa-bodyparser", "npm:4.3.10"],\ + ["@types/koa-cors", "npm:0.0.2"],\ + ["@types/koa-favicon", "npm:2.0.21"],\ + ["@types/koa-logger", "npm:3.1.2"],\ + ["@types/koa-mount", "npm:4.0.2"],\ + ["@types/koa-send", "npm:4.1.3"],\ + ["@types/koa-views", "npm:7.0.0"],\ + ["@types/koa__cors", "npm:3.3.0"],\ + ["@types/node", "npm:22.19.18"],\ + ["@types/node-fetch", "npm:3.0.3"],\ + ["@types/nodemailer", "npm:6.4.8"],\ + ["@types/oauth", "npm:0.9.1"],\ + ["@types/pg", "npm:8.10.5"],\ + ["@types/probe-image-size", "npm:7.2.0"],\ + ["@types/pug", "npm:2.0.6"],\ + ["@types/punycode", "npm:2.1.0"],\ + ["@types/qrcode", "npm:1.5.1"],\ + ["@types/random-seed", "npm:0.3.3"],\ + ["@types/ratelimiter", "npm:3.4.4"],\ + ["@types/redis", "npm:4.0.11"],\ + ["@types/rename", "npm:1.0.4"],\ + ["@types/sanitize-html", "npm:2.9.0"],\ + ["@types/semver", "npm:7.5.0"],\ + ["@types/tinycolor2", "npm:1.4.3"],\ + ["@types/tmp", "npm:0.2.3"],\ + ["@types/uuid", "npm:10.0.0"],\ + ["@types/web-push", "npm:3.3.2"],\ + ["@types/websocket", "npm:1.0.5"],\ + ["adm-zip", "npm:0.5.10"],\ + ["ajv", "npm:8.12.0"],\ + ["archiver", "npm:5.3.1"],\ + ["argon2", "npm:0.44.0"],\ + ["async-lock", "npm:1.4.0"],\ + ["async-mutex", "npm:0.4.0"],\ + ["backend", "workspace:packages/backend"],\ + ["bcryptjs", "npm:3.0.3"],\ + ["blurhash", "npm:2.0.5"],\ + ["bullmq", "npm:5.16.0"],\ + ["cacheable-lookup", "npm:7.0.0"],\ + ["cbor", "npm:10.0.12"],\ + ["chalk", "npm:5.6.2"],\ + ["chalk-template", "npm:0.4.0"],\ + ["cli-highlight", "npm:2.1.11"],\ + ["color-convert", "npm:2.0.1"],\ + ["content-disposition", "npm:0.5.4"],\ + ["date-fns", "npm:4.1.0"],\ + ["decompress", "npm:4.2.1"],\ + ["deep-email-validator", "npm:0.1.21"],\ + ["escape-regexp", "npm:0.0.1"],\ + ["execa", "npm:6.1.0"],\ + ["fast-xml-parser", "npm:4.2.7"],\ + ["feed", "npm:4.2.2"],\ + ["file-type", "npm:22.0.1"],\ + ["fluent-ffmpeg", "npm:2.1.2"],\ + ["got", "npm:15.0.5"],\ + ["gunzip-maybe", "npm:1.4.2"],\ + ["happy-dom", "npm:20.9.0"],\ + ["hpagent", "npm:0.1.2"],\ + ["iceshrimp-sdk", "workspace:packages/iceshrimp-sdk"],\ + ["ioredis", "npm:5.4.1"],\ + ["ip-cidr", "npm:3.1.0"],\ + ["is-svg", "npm:4.3.2"],\ + ["js-yaml", "npm:4.1.0"],\ + ["jsdom", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:26.1.0"],\ + ["json5", "npm:2.2.3"],\ + ["jsonld", "npm:8.2.0"],\ + ["jsrsasign", "npm:10.8.6"],\ + ["koa", "npm:2.16.4"],\ + ["koa-body", "npm:6.0.1"],\ + ["koa-bodyparser", "npm:4.4.1"],\ + ["koa-favicon", "npm:2.1.0"],\ + ["koa-logger", "npm:3.2.1"],\ + ["koa-mount", "npm:4.0.0"],\ + ["koa-remove-trailing-slashes", "npm:2.0.3"],\ + ["koa-send", "npm:5.0.1"],\ + ["koa-slow", "npm:2.1.0"],\ + ["koa-views", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:7.0.2"],\ + ["mfm-js", "npm:0.25.0"],\ + ["mime-types", "npm:2.1.35"],\ + ["msgpackr", "npm:1.11.2"],\ + ["multer", "npm:2.1.1"],\ + ["nested-property", "npm:4.0.0"],\ + ["node-fetch", "npm:3.3.2"],\ + ["nodemailer", "npm:6.9.3"],\ + ["oauth", "npm:0.10.0"],\ + ["os-utils", "npm:0.0.14"],\ + ["otpauth", "npm:9.1.4"],\ + ["parse-duration", "npm:1.1.0"],\ + ["parse5", "npm:7.1.2"],\ + ["pg", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:8.11.1"],\ + ["private-ip", "npm:2.3.4"],\ + ["probe-image-size", "npm:7.2.3"],\ + ["prom-client", "npm:15.1.0"],\ + ["promise-limit", "npm:2.7.0"],\ + ["pug", "npm:3.0.2"],\ + ["punycode", "npm:2.3.0"],\ + ["pureimage", "npm:0.4.18"],\ + ["qrcode", "npm:1.5.3"],\ + ["random-seed", "npm:0.3.0"],\ + ["ratelimiter", "npm:3.4.1"],\ + ["re2", "npm:1.22.1"],\ + ["redis-lock", "npm:0.1.4"],\ + ["redis-semaphore", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:5.3.1"],\ + ["reflect-metadata", "npm:0.1.13"],\ + ["rename", "npm:1.0.4"],\ + ["rndstr", "npm:1.0.0"],\ + ["rss-parser", "npm:3.13.0"],\ + ["sanitize-html", "npm:2.10.0"],\ + ["semver", "npm:7.5.4"],\ + ["sharp", "npm:0.33.5"],\ + ["shogiops", "npm:0.21.0"],\ + ["strict-event-emitter-types", "npm:2.0.0"],\ + ["stringz", "npm:2.1.0"],\ + ["syslog-pro", "npm:1.0.0"],\ + ["systeminformation", "npm:5.21.12"],\ + ["tar-stream", "npm:3.1.6"],\ + ["tesseract.js", "npm:7.0.0"],\ + ["tinycolor2", "npm:1.5.2"],\ + ["tmp", "npm:0.2.1"],\ + ["tsconfig-paths", "npm:4.2.0"],\ + ["typeorm", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:0.3.17"],\ + ["typescript", "patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5"],\ + ["uuid", "npm:14.0.0"],\ + ["web-push", "npm:3.6.3"],\ + ["websocket", "npm:1.0.34"],\ + ["xev", "npm:3.0.2"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ + ["balanced-match", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/balanced-match-npm-1.0.2-a53c126459-9706c088a2.zip/node_modules/balanced-match/",\ + "packageDependencies": [\ + ["balanced-match", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.4", {\ + "packageLocation": "./.yarn/cache/balanced-match-npm-4.0.4-fd666b3c7f-fb07bb66a0.zip/node_modules/balanced-match/",\ + "packageDependencies": [\ + ["balanced-match", "npm:4.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["base64-js", [\ + ["npm:1.5.1", {\ + "packageLocation": "./.yarn/cache/base64-js-npm-1.5.1-b2f7275641-669632eb37.zip/node_modules/base64-js/",\ + "packageDependencies": [\ + ["base64-js", "npm:1.5.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["bcrypt-pbkdf", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/bcrypt-pbkdf-npm-1.0.2-80db8b16ed-13a4cde058.zip/node_modules/bcrypt-pbkdf/",\ + "packageDependencies": [\ + ["bcrypt-pbkdf", "npm:1.0.2"],\ + ["tweetnacl", "npm:0.14.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["bcryptjs", [\ + ["npm:3.0.3", {\ + "packageLocation": "./.yarn/cache/bcryptjs-npm-3.0.3-834b97d2ce-c24c2b02d2.zip/node_modules/bcryptjs/",\ + "packageDependencies": [\ + ["bcryptjs", "npm:3.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["bin-check", [\ + ["npm:4.1.0", {\ + "packageLocation": "./.yarn/cache/bin-check-npm-4.1.0-07dd85c765-16f6d5d86d.zip/node_modules/bin-check/",\ + "packageDependencies": [\ + ["bin-check", "npm:4.1.0"],\ + ["execa", "npm:0.7.0"],\ + ["executable", "npm:4.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["bin-version", [\ + ["npm:6.0.0", {\ + "packageLocation": "./.yarn/cache/bin-version-npm-6.0.0-21aa4ae30a-78c29422ea.zip/node_modules/bin-version/",\ + "packageDependencies": [\ + ["bin-version", "npm:6.0.0"],\ + ["execa", "npm:5.1.1"],\ + ["find-versions", "npm:5.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["bin-version-check", [\ + ["npm:5.1.0", {\ + "packageLocation": "./.yarn/cache/bin-version-check-npm-5.1.0-301a91fa83-d99679cfe0.zip/node_modules/bin-version-check/",\ + "packageDependencies": [\ + ["bin-version", "npm:6.0.0"],\ + ["bin-version-check", "npm:5.1.0"],\ + ["semver", "npm:7.5.4"],\ + ["semver-truncate", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["binary-extensions", [\ + ["npm:2.2.0", {\ + "packageLocation": "./.yarn/cache/binary-extensions-npm-2.2.0-180c33fec7-ccd267956c.zip/node_modules/binary-extensions/",\ + "packageDependencies": [\ + ["binary-extensions", "npm:2.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["bintrees", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/bintrees-npm-1.0.2-b28feeda03-071896cea5.zip/node_modules/bintrees/",\ + "packageDependencies": [\ + ["bintrees", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["bl", [\ + ["npm:1.2.3", {\ + "packageLocation": "./.yarn/cache/bl-npm-1.2.3-49c4213ca5-11d775b09e.zip/node_modules/bl/",\ + "packageDependencies": [\ + ["bl", "npm:1.2.3"],\ + ["readable-stream", "npm:2.3.8"],\ + ["safe-buffer", "npm:5.2.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.1.0", {\ + "packageLocation": "./.yarn/cache/bl-npm-4.1.0-7f94cdcf3f-b7904e66ed.zip/node_modules/bl/",\ + "packageDependencies": [\ + ["bl", "npm:4.1.0"],\ + ["buffer", "npm:5.7.1"],\ + ["inherits", "npm:2.0.4"],\ + ["readable-stream", "npm:3.6.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["bluebird", [\ + ["npm:3.7.2", {\ + "packageLocation": "./.yarn/cache/bluebird-npm-3.7.2-6a54136ee3-007c7bad22.zip/node_modules/bluebird/",\ + "packageDependencies": [\ + ["bluebird", "npm:3.7.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["blurhash", [\ + ["npm:2.0.5", {\ + "packageLocation": "./.yarn/cache/blurhash-npm-2.0.5-7648719b71-ff0e156c13.zip/node_modules/blurhash/",\ + "packageDependencies": [\ + ["blurhash", "npm:2.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["bmp-js", [\ + ["npm:0.1.0", {\ + "packageLocation": "./.yarn/cache/bmp-js-npm-0.1.0-5c9f284ca3-9597f41038.zip/node_modules/bmp-js/",\ + "packageDependencies": [\ + ["bmp-js", "npm:0.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["bn.js", [\ + ["npm:4.12.0", {\ + "packageLocation": "./.yarn/cache/bn.js-npm-4.12.0-3ec6c884f6-10f8db196d.zip/node_modules/bn.js/",\ + "packageDependencies": [\ + ["bn.js", "npm:4.12.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["boolbase", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/boolbase-npm-1.0.0-965fe9af6d-3e25c80ef6.zip/node_modules/boolbase/",\ + "packageDependencies": [\ + ["boolbase", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["bowser", [\ + ["npm:2.14.1", {\ + "packageLocation": "./.yarn/cache/bowser-npm-2.14.1-41eaeb0dd6-a002f0795e.zip/node_modules/bowser/",\ + "packageDependencies": [\ + ["bowser", "npm:2.14.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["brace-expansion", [\ + ["npm:1.1.11", {\ + "packageLocation": "./.yarn/cache/brace-expansion-npm-1.1.11-fb95eb05ad-faf34a7bb0.zip/node_modules/brace-expansion/",\ + "packageDependencies": [\ + ["balanced-match", "npm:1.0.2"],\ + ["brace-expansion", "npm:1.1.11"],\ + ["concat-map", "npm:0.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/brace-expansion-npm-2.0.1-17aa2616f9-a61e7cd2e8.zip/node_modules/brace-expansion/",\ + "packageDependencies": [\ + ["balanced-match", "npm:1.0.2"],\ + ["brace-expansion", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.0.6", {\ + "packageLocation": "./.yarn/cache/brace-expansion-npm-5.0.6-abf39a1281-a7acf120fe.zip/node_modules/brace-expansion/",\ + "packageDependencies": [\ + ["balanced-match", "npm:4.0.4"],\ + ["brace-expansion", "npm:5.0.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["braces", [\ + ["npm:3.0.2", {\ + "packageLocation": "./.yarn/cache/braces-npm-3.0.2-782240b28a-966b1fb48d.zip/node_modules/braces/",\ + "packageDependencies": [\ + ["braces", "npm:3.0.2"],\ + ["fill-range", "npm:7.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["broadcast-channel", [\ + ["npm:5.1.0", {\ + "packageLocation": "./.yarn/cache/broadcast-channel-npm-5.1.0-630e916f16-d022d97945.zip/node_modules/broadcast-channel/",\ + "packageDependencies": [\ + ["@babel/runtime", "npm:7.22.6"],\ + ["broadcast-channel", "npm:5.1.0"],\ + ["oblivious-set", "npm:1.1.1"],\ + ["p-queue", "npm:6.6.2"],\ + ["rimraf", "npm:3.0.2"],\ + ["unload", "npm:2.4.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["browser-image-resizer", [\ + ["https://iceshrimp.dev/iceshrimp/browser-image-resizer.git#commit=798c66aeb9e86a792e7408067ee3c5d7d8575f1a", {\ + "packageLocation": "./.yarn/cache/browser-image-resizer-https-c69f847146-6bfec4a7da.zip/node_modules/browser-image-resizer/",\ + "packageDependencies": [\ + ["browser-image-resizer", "https://iceshrimp.dev/iceshrimp/browser-image-resizer.git#commit=798c66aeb9e86a792e7408067ee3c5d7d8575f1a"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["browserify-zlib", [\ + ["npm:0.1.4", {\ + "packageLocation": "./.yarn/cache/browserify-zlib-npm-0.1.4-34d70ed43d-cd506a1ef9.zip/node_modules/browserify-zlib/",\ + "packageDependencies": [\ + ["browserify-zlib", "npm:0.1.4"],\ + ["pako", "npm:0.2.9"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["buffer", [\ + ["npm:5.6.0", {\ + "packageLocation": "./.yarn/cache/buffer-npm-5.6.0-e1494693bf-7874745b06.zip/node_modules/buffer/",\ + "packageDependencies": [\ + ["base64-js", "npm:1.5.1"],\ + ["buffer", "npm:5.6.0"],\ + ["ieee754", "npm:1.2.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.7.1", {\ + "packageLocation": "./.yarn/cache/buffer-npm-5.7.1-513ef8259e-997434d3c6.zip/node_modules/buffer/",\ + "packageDependencies": [\ + ["base64-js", "npm:1.5.1"],\ + ["buffer", "npm:5.7.1"],\ + ["ieee754", "npm:1.2.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:6.0.3", {\ + "packageLocation": "./.yarn/cache/buffer-npm-6.0.3-cd90dfedfe-b6bc68237e.zip/node_modules/buffer/",\ + "packageDependencies": [\ + ["base64-js", "npm:1.5.1"],\ + ["buffer", "npm:6.0.3"],\ + ["ieee754", "npm:1.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["buffer-alloc", [\ + ["npm:1.2.0", {\ + "packageLocation": "./.yarn/cache/buffer-alloc-npm-1.2.0-388beee0c7-560cd27f3c.zip/node_modules/buffer-alloc/",\ + "packageDependencies": [\ + ["buffer-alloc", "npm:1.2.0"],\ + ["buffer-alloc-unsafe", "npm:1.1.0"],\ + ["buffer-fill", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["buffer-alloc-unsafe", [\ + ["npm:1.1.0", {\ + "packageLocation": "./.yarn/cache/buffer-alloc-unsafe-npm-1.1.0-b5d7ccb44c-c5e18bf51f.zip/node_modules/buffer-alloc-unsafe/",\ + "packageDependencies": [\ + ["buffer-alloc-unsafe", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["buffer-crc32", [\ + ["npm:0.2.13", {\ + "packageLocation": "./.yarn/cache/buffer-crc32-npm-0.2.13-c4b6fceac1-06252347ae.zip/node_modules/buffer-crc32/",\ + "packageDependencies": [\ + ["buffer-crc32", "npm:0.2.13"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["buffer-equal-constant-time", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/buffer-equal-constant-time-npm-1.0.1-41826f3419-80bb945f5d.zip/node_modules/buffer-equal-constant-time/",\ + "packageDependencies": [\ + ["buffer-equal-constant-time", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["buffer-fill", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/buffer-fill-npm-1.0.0-915809118a-c29b4723dd.zip/node_modules/buffer-fill/",\ + "packageDependencies": [\ + ["buffer-fill", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["buffer-from", [\ + ["npm:1.1.2", {\ + "packageLocation": "./.yarn/cache/buffer-from-npm-1.1.2-03d2f20d7e-0448524a56.zip/node_modules/buffer-from/",\ + "packageDependencies": [\ + ["buffer-from", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["buffer-writer", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/buffer-writer-npm-2.0.0-5cd2ef55bc-fdca8e28c5.zip/node_modules/buffer-writer/",\ + "packageDependencies": [\ + ["buffer-writer", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["bufferutil", [\ + ["npm:4.0.7", {\ + "packageLocation": "./.yarn/unplugged/bufferutil-npm-4.0.7-77a45bb7a3/node_modules/bufferutil/",\ + "packageDependencies": [\ + ["bufferutil", "npm:4.0.7"],\ + ["node-gyp", "npm:9.4.0"],\ + ["node-gyp-build", "npm:4.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["bullmq", [\ + ["npm:5.16.0", {\ + "packageLocation": "./.yarn/cache/bullmq-npm-5.16.0-fe493a4098-39febf6e4a.zip/node_modules/bullmq/",\ + "packageDependencies": [\ + ["bullmq", "npm:5.16.0"],\ + ["cron-parser", "npm:4.9.0"],\ + ["ioredis", "npm:5.4.1"],\ + ["msgpackr", "npm:1.11.0"],\ + ["node-abort-controller", "npm:3.1.1"],\ + ["semver", "npm:7.5.4"],\ + ["tslib", "npm:2.7.0"],\ + ["uuid", "npm:9.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["bundle-require", [\ + ["npm:5.1.0", {\ + "packageLocation": "./.yarn/cache/bundle-require-npm-5.1.0-022b2c8e1b-735e022005.zip/node_modules/bundle-require/",\ + "packageDependencies": [\ + ["bundle-require", "npm:5.1.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:4b773fcf67e13422dc15c766b293a59543559d82cada458ec3b1e008dd59d4ec3aa9ee576dd293df519765cd62284cf280961ba91ee4f7468cb4f32aaf42ce24#npm:5.1.0", {\ + "packageLocation": "./.yarn/__virtual__/bundle-require-virtual-8ba72f1973/0/cache/bundle-require-npm-5.1.0-022b2c8e1b-735e022005.zip/node_modules/bundle-require/",\ + "packageDependencies": [\ + ["@types/esbuild", null],\ + ["bundle-require", "virtual:4b773fcf67e13422dc15c766b293a59543559d82cada458ec3b1e008dd59d4ec3aa9ee576dd293df519765cd62284cf280961ba91ee4f7468cb4f32aaf42ce24#npm:5.1.0"],\ + ["esbuild", "npm:0.27.7"],\ + ["load-tsconfig", "npm:0.2.5"]\ + ],\ + "packagePeers": [\ + "@types/esbuild",\ + "esbuild"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["busboy", [\ + ["npm:1.6.0", {\ + "packageLocation": "./.yarn/cache/busboy-npm-1.6.0-ebb5cbb04b-bee10fa10e.zip/node_modules/busboy/",\ + "packageDependencies": [\ + ["busboy", "npm:1.6.0"],\ + ["streamsearch", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["byte-counter", [\ + ["npm:0.1.0", {\ + "packageLocation": "./.yarn/cache/byte-counter-npm-0.1.0-e2c60d0da6-87eee53df7.zip/node_modules/byte-counter/",\ + "packageDependencies": [\ + ["byte-counter", "npm:0.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["bytes", [\ + ["npm:3.1.2", {\ + "packageLocation": "./.yarn/cache/bytes-npm-3.1.2-28b8643004-a10abf2ba7.zip/node_modules/bytes/",\ + "packageDependencies": [\ + ["bytes", "npm:3.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["cac", [\ + ["npm:6.7.14", {\ + "packageLocation": "./.yarn/cache/cac-npm-6.7.14-c46284e425-002769a0fb.zip/node_modules/cac/",\ + "packageDependencies": [\ + ["cac", "npm:6.7.14"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["cacache", [\ + ["npm:17.1.3", {\ + "packageLocation": "./.yarn/cache/cacache-npm-17.1.3-f75f768a29-216fb41c73.zip/node_modules/cacache/",\ + "packageDependencies": [\ + ["@npmcli/fs", "npm:3.1.0"],\ + ["cacache", "npm:17.1.3"],\ + ["fs-minipass", "npm:3.0.2"],\ + ["glob", "npm:10.3.3"],\ + ["lru-cache", "npm:7.18.3"],\ + ["minipass", "npm:5.0.0"],\ + ["minipass-collect", "npm:1.0.2"],\ + ["minipass-flush", "npm:1.0.5"],\ + ["minipass-pipeline", "npm:1.2.4"],\ + ["p-map", "npm:4.0.0"],\ + ["ssri", "npm:10.0.4"],\ + ["tar", "npm:6.1.15"],\ + ["unique-filename", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:19.0.1", {\ + "packageLocation": "./.yarn/cache/cacache-npm-19.0.1-395cba1936-ea026b27b1.zip/node_modules/cacache/",\ + "packageDependencies": [\ + ["@npmcli/fs", "npm:4.0.0"],\ + ["cacache", "npm:19.0.1"],\ + ["fs-minipass", "npm:3.0.2"],\ + ["glob", "npm:10.3.3"],\ + ["lru-cache", "npm:10.0.2"],\ + ["minipass", "npm:7.0.4"],\ + ["minipass-collect", "npm:2.0.1"],\ + ["minipass-flush", "npm:1.0.5"],\ + ["minipass-pipeline", "npm:1.2.4"],\ + ["p-map", "npm:7.0.3"],\ + ["ssri", "npm:12.0.0"],\ + ["tar", "npm:7.4.3"],\ + ["unique-filename", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["cache-content-type", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/cache-content-type-npm-1.0.1-f709f8c309-18db4d5945.zip/node_modules/cache-content-type/",\ + "packageDependencies": [\ + ["cache-content-type", "npm:1.0.1"],\ + ["mime-types", "npm:2.1.35"],\ + ["ylru", "npm:1.3.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["cacheable-lookup", [\ + ["npm:5.0.4", {\ + "packageLocation": "./.yarn/cache/cacheable-lookup-npm-5.0.4-8f13e8b44b-618a8b3eea.zip/node_modules/cacheable-lookup/",\ + "packageDependencies": [\ + ["cacheable-lookup", "npm:5.0.4"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.0", {\ + "packageLocation": "./.yarn/cache/cacheable-lookup-npm-7.0.0-b6cd95c14a-69ea78cd9f.zip/node_modules/cacheable-lookup/",\ + "packageDependencies": [\ + ["cacheable-lookup", "npm:7.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["cacheable-request", [\ + ["npm:13.0.19", {\ + "packageLocation": "./.yarn/cache/cacheable-request-npm-13.0.19-c9c7cffad8-d4a942cec5.zip/node_modules/cacheable-request/",\ + "packageDependencies": [\ + ["@types/http-cache-semantics", "npm:4.2.0"],\ + ["cacheable-request", "npm:13.0.19"],\ + ["get-stream", "npm:9.0.1"],\ + ["http-cache-semantics", "npm:4.2.0"],\ + ["keyv", "npm:5.6.0"],\ + ["mimic-response", "npm:4.0.0"],\ + ["normalize-url", "npm:8.1.1"],\ + ["responselike", "npm:4.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.4", {\ + "packageLocation": "./.yarn/cache/cacheable-request-npm-7.0.4-92bf077a92-0f4f200126.zip/node_modules/cacheable-request/",\ + "packageDependencies": [\ + ["cacheable-request", "npm:7.0.4"],\ + ["clone-response", "npm:1.0.3"],\ + ["get-stream", "npm:5.2.0"],\ + ["http-cache-semantics", "npm:4.1.1"],\ + ["keyv", "npm:4.5.3"],\ + ["lowercase-keys", "npm:2.0.0"],\ + ["normalize-url", "npm:6.1.0"],\ + ["responselike", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["call-bind", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/call-bind-npm-1.0.2-c957124861-ca787179c1.zip/node_modules/call-bind/",\ + "packageDependencies": [\ + ["call-bind", "npm:1.0.2"],\ + ["function-bind", "npm:1.1.1"],\ + ["get-intrinsic", "npm:1.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["camelcase", [\ + ["npm:5.3.1", {\ + "packageLocation": "./.yarn/cache/camelcase-npm-5.3.1-5db8af62c5-e6effce26b.zip/node_modules/camelcase/",\ + "packageDependencies": [\ + ["camelcase", "npm:5.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["canonicalize", [\ + ["npm:1.0.8", {\ + "packageLocation": "./.yarn/cache/canonicalize-npm-1.0.8-e87a0e7ee4-4087d6de4a.zip/node_modules/canonicalize/",\ + "packageDependencies": [\ + ["canonicalize", "npm:1.0.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["cbor", [\ + ["npm:10.0.12", {\ + "packageLocation": "./.yarn/cache/cbor-npm-10.0.12-83e5be6b0b-296b6564c6.zip/node_modules/cbor/",\ + "packageDependencies": [\ + ["cbor", "npm:10.0.12"],\ + ["nofilter", "npm:3.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["chalk", [\ + ["npm:2.4.2", {\ + "packageLocation": "./.yarn/cache/chalk-npm-2.4.2-3ea16dd91e-3d1d103433.zip/node_modules/chalk/",\ + "packageDependencies": [\ + ["ansi-styles", "npm:3.2.1"],\ + ["chalk", "npm:2.4.2"],\ + ["escape-string-regexp", "npm:1.0.5"],\ + ["supports-color", "npm:5.5.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.1.2", {\ + "packageLocation": "./.yarn/cache/chalk-npm-4.1.2-ba8b67ab80-cb3f3e5949.zip/node_modules/chalk/",\ + "packageDependencies": [\ + ["ansi-styles", "npm:4.3.0"],\ + ["chalk", "npm:4.1.2"],\ + ["supports-color", "npm:7.2.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.6.2", {\ + "packageLocation": "./.yarn/cache/chalk-npm-5.6.2-ecbd482482-1b2f48f6fb.zip/node_modules/chalk/",\ + "packageDependencies": [\ + ["chalk", "npm:5.6.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["chalk-template", [\ + ["npm:0.4.0", {\ + "packageLocation": "./.yarn/cache/chalk-template-npm-0.4.0-d7a0499c36-6c706802a7.zip/node_modules/chalk-template/",\ + "packageDependencies": [\ + ["chalk", "npm:4.1.2"],\ + ["chalk-template", "npm:0.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["char-regex", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/char-regex-npm-1.0.2-ecade5f97f-1ec5c2906a.zip/node_modules/char-regex/",\ + "packageDependencies": [\ + ["char-regex", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["character-parser", [\ + ["npm:2.2.0", {\ + "packageLocation": "./.yarn/cache/character-parser-npm-2.2.0-a5df9fb883-5980ddc776.zip/node_modules/character-parser/",\ + "packageDependencies": [\ + ["character-parser", "npm:2.2.0"],\ + ["is-regex", "npm:1.1.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["chart.js", [\ + ["npm:4.5.1", {\ + "packageLocation": "./.yarn/cache/chart.js-npm-4.5.1-97698d58cc-a8dd338537.zip/node_modules/chart.js/",\ + "packageDependencies": [\ + ["@kurkle/color", "npm:0.3.2"],\ + ["chart.js", "npm:4.5.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["chartjs-adapter-date-fns", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/chartjs-adapter-date-fns-npm-3.0.0-42916eb5be-29a104882f.zip/node_modules/chartjs-adapter-date-fns/",\ + "packageDependencies": [\ + ["chartjs-adapter-date-fns", "npm:3.0.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:3.0.0", {\ + "packageLocation": "./.yarn/__virtual__/chartjs-adapter-date-fns-virtual-6d1e2331b2/0/cache/chartjs-adapter-date-fns-npm-3.0.0-42916eb5be-29a104882f.zip/node_modules/chartjs-adapter-date-fns/",\ + "packageDependencies": [\ + ["@types/chart.js", null],\ + ["@types/date-fns", null],\ + ["chart.js", "npm:4.5.1"],\ + ["chartjs-adapter-date-fns", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:3.0.0"],\ + ["date-fns", "npm:4.1.0"]\ + ],\ + "packagePeers": [\ + "@types/chart.js",\ + "@types/date-fns",\ + "chart.js",\ + "date-fns"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["chartjs-chart-matrix", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/chartjs-chart-matrix-npm-2.0.1-8f0df14685-7233ea81b9.zip/node_modules/chartjs-chart-matrix/",\ + "packageDependencies": [\ + ["chartjs-chart-matrix", "npm:2.0.1"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:2.0.1", {\ + "packageLocation": "./.yarn/__virtual__/chartjs-chart-matrix-virtual-13784d4e5c/0/cache/chartjs-chart-matrix-npm-2.0.1-8f0df14685-7233ea81b9.zip/node_modules/chartjs-chart-matrix/",\ + "packageDependencies": [\ + ["@types/chart.js", null],\ + ["chart.js", "npm:4.5.1"],\ + ["chartjs-chart-matrix", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:2.0.1"]\ + ],\ + "packagePeers": [\ + "@types/chart.js",\ + "chart.js"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["chartjs-plugin-gradient", [\ + ["npm:0.6.1", {\ + "packageLocation": "./.yarn/cache/chartjs-plugin-gradient-npm-0.6.1-a7a0d5d7d9-b1d9ad27f9.zip/node_modules/chartjs-plugin-gradient/",\ + "packageDependencies": [\ + ["chartjs-plugin-gradient", "npm:0.6.1"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:0.6.1", {\ + "packageLocation": "./.yarn/__virtual__/chartjs-plugin-gradient-virtual-1e756027e4/0/cache/chartjs-plugin-gradient-npm-0.6.1-a7a0d5d7d9-b1d9ad27f9.zip/node_modules/chartjs-plugin-gradient/",\ + "packageDependencies": [\ + ["@types/chart.js", null],\ + ["chart.js", "npm:4.5.1"],\ + ["chartjs-plugin-gradient", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:0.6.1"]\ + ],\ + "packagePeers": [\ + "@types/chart.js",\ + "chart.js"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["chartjs-plugin-zoom", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/chartjs-plugin-zoom-npm-2.0.1-3e6869111b-ac823f9a0f.zip/node_modules/chartjs-plugin-zoom/",\ + "packageDependencies": [\ + ["chartjs-plugin-zoom", "npm:2.0.1"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:2.0.1", {\ + "packageLocation": "./.yarn/__virtual__/chartjs-plugin-zoom-virtual-32a0498726/0/cache/chartjs-plugin-zoom-npm-2.0.1-3e6869111b-ac823f9a0f.zip/node_modules/chartjs-plugin-zoom/",\ + "packageDependencies": [\ + ["@types/chart.js", null],\ + ["chart.js", "npm:4.5.1"],\ + ["chartjs-plugin-zoom", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:2.0.1"],\ + ["hammerjs", "npm:2.0.8"]\ + ],\ + "packagePeers": [\ + "@types/chart.js",\ + "chart.js"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["cheerio", [\ + ["npm:0.22.0", {\ + "packageLocation": "./.yarn/cache/cheerio-npm-0.22.0-0450a21558-eabc1db83c.zip/node_modules/cheerio/",\ + "packageDependencies": [\ + ["cheerio", "npm:0.22.0"],\ + ["css-select", "npm:1.2.0"],\ + ["dom-serializer", "npm:0.1.1"],\ + ["entities", "npm:1.1.2"],\ + ["htmlparser2", "npm:3.10.1"],\ + ["lodash.assignin", "npm:4.2.0"],\ + ["lodash.bind", "npm:4.2.1"],\ + ["lodash.defaults", "npm:4.2.0"],\ + ["lodash.filter", "npm:4.6.0"],\ + ["lodash.flatten", "npm:4.4.0"],\ + ["lodash.foreach", "npm:4.5.0"],\ + ["lodash.map", "npm:4.6.0"],\ + ["lodash.merge", "npm:4.6.2"],\ + ["lodash.pick", "npm:4.4.0"],\ + ["lodash.reduce", "npm:4.6.0"],\ + ["lodash.reject", "npm:4.6.0"],\ + ["lodash.some", "npm:4.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["chokidar", [\ + ["npm:3.5.3", {\ + "packageLocation": "./.yarn/cache/chokidar-npm-3.5.3-c5f9b0a56a-863e3ff78e.zip/node_modules/chokidar/",\ + "packageDependencies": [\ + ["anymatch", "npm:3.1.3"],\ + ["braces", "npm:3.0.2"],\ + ["chokidar", "npm:3.5.3"],\ + ["fsevents", "patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1"],\ + ["glob-parent", "npm:5.1.2"],\ + ["is-binary-path", "npm:2.1.0"],\ + ["is-glob", "npm:4.0.3"],\ + ["normalize-path", "npm:3.0.0"],\ + ["readdirp", "npm:3.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["chownr", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/chownr-npm-2.0.0-638f1c9c61-c57cf9dd07.zip/node_modules/chownr/",\ + "packageDependencies": [\ + ["chownr", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/chownr-npm-3.0.0-5275e85d25-b63cb1f73d.zip/node_modules/chownr/",\ + "packageDependencies": [\ + ["chownr", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["chunk-data", [\ + ["npm:0.1.0", {\ + "packageLocation": "./.yarn/cache/chunk-data-npm-0.1.0-92eca06b4f-3ab3f7b7da.zip/node_modules/chunk-data/",\ + "packageDependencies": [\ + ["chunk-data", "npm:0.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["city-timezones", [\ + ["npm:1.2.1", {\ + "packageLocation": "./.yarn/cache/city-timezones-npm-1.2.1-c5e7373ad7-2047edd6b5.zip/node_modules/city-timezones/",\ + "packageDependencies": [\ + ["city-timezones", "npm:1.2.1"],\ + ["lodash", "npm:4.17.21"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["clean-stack", [\ + ["npm:2.2.0", {\ + "packageLocation": "./.yarn/cache/clean-stack-npm-2.2.0-a8ce435a5c-2ac8cd2b2f.zip/node_modules/clean-stack/",\ + "packageDependencies": [\ + ["clean-stack", "npm:2.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["cli-highlight", [\ + ["npm:2.1.11", {\ + "packageLocation": "./.yarn/cache/cli-highlight-npm-2.1.11-569697f73a-05d2b5beb8.zip/node_modules/cli-highlight/",\ + "packageDependencies": [\ + ["chalk", "npm:4.1.2"],\ + ["cli-highlight", "npm:2.1.11"],\ + ["highlight.js", "npm:10.7.3"],\ + ["mz", "npm:2.7.0"],\ + ["parse5", "npm:5.1.1"],\ + ["parse5-htmlparser2-tree-adapter", "npm:6.0.1"],\ + ["yargs", "npm:16.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["client", [\ + ["workspace:packages/client", {\ + "packageLocation": "./packages/client/",\ + "packageDependencies": [\ + ["@phosphor-icons/web", "npm:2.0.3"],\ + ["@rollup/plugin-json", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:6.0.1"],\ + ["@rollup/pluginutils", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:5.1.0"],\ + ["@syuilo/aiscript", "npm:0.17.0"],\ + ["@types/glob", "npm:8.1.0"],\ + ["@types/katex", "npm:0.16.0"],\ + ["@types/matter-js", "npm:0.18.2"],\ + ["@types/punycode", "npm:2.1.0"],\ + ["@types/seedrandom", "npm:3.0.5"],\ + ["@types/throttle-debounce", "npm:5.0.0"],\ + ["@types/tinycolor2", "npm:1.4.3"],\ + ["@types/uuid", "npm:8.3.4"],\ + ["@vitejs/plugin-vue", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:6.0.6"],\ + ["@vue/compiler-sfc", "npm:3.5.34"],\ + ["autobind-decorator", "npm:2.4.0"],\ + ["autosize", "npm:5.0.2"],\ + ["blurhash", "npm:2.0.5"],\ + ["broadcast-channel", "npm:5.1.0"],\ + ["browser-image-resizer", "https://iceshrimp.dev/iceshrimp/browser-image-resizer.git#commit=798c66aeb9e86a792e7408067ee3c5d7d8575f1a"],\ + ["chart.js", "npm:4.5.1"],\ + ["chartjs-adapter-date-fns", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:3.0.0"],\ + ["chartjs-chart-matrix", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:2.0.1"],\ + ["chartjs-plugin-gradient", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:0.6.1"],\ + ["chartjs-plugin-zoom", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:2.0.1"],\ + ["city-timezones", "npm:1.2.1"],\ + ["client", "workspace:packages/client"],\ + ["compare-versions", "npm:6.0.0"],\ + ["cropperjs", "npm:2.1.1"],\ + ["date-fns", "npm:4.1.0"],\ + ["emojilib", "npm:4.0.2"],\ + ["eventemitter3", "npm:5.0.1"],\ + ["fast-blurhash", "npm:1.1.2"],\ + ["fengari", "npm:0.1.5"],\ + ["focus-trap", "npm:7.5.2"],\ + ["focus-trap-vue", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:4.0.2"],\ + ["gsap", "npm:3.12.2"],\ + ["iceshrimp-sdk", "workspace:packages/iceshrimp-sdk"],\ + ["idb-keyval", "npm:6.2.1"],\ + ["insert-text-at-cursor", "npm:0.3.0"],\ + ["json5", "npm:2.2.3"],\ + ["katex", "npm:0.16.45"],\ + ["matter-js", "npm:0.20.0"],\ + ["mfm-js", "npm:0.25.0"],\ + ["paralint", "npm:1.2.1"],\ + ["photoswipe", "npm:5.4.4"],\ + ["plyr", "npm:3.8.4"],\ + ["prettier", "npm:3.0.0"],\ + ["prettier-plugin-vue", "npm:1.1.6"],\ + ["prismjs", "npm:1.29.0"],\ + ["punycode", "npm:2.3.0"],\ + ["rollup", "npm:4.6.1"],\ + ["s-age", "npm:1.1.2"],\ + ["sass", "npm:1.99.0"],\ + ["seedrandom", "npm:3.0.5"],\ + ["shogiops", "npm:0.21.0"],\ + ["stringz", "npm:2.1.0"],\ + ["swiper", "npm:11.2.10"],\ + ["syuilo-password-strength", "npm:0.0.1"],\ + ["textarea-caret", "npm:3.1.0"],\ + ["throttle-debounce", "npm:5.0.0"],\ + ["tinycolor2", "npm:1.6.0"],\ + ["typescript", "patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5"],\ + ["unicode-emoji-json", "npm:0.8.0"],\ + ["uuid", "npm:14.0.0"],\ + ["vite", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:7.3.3"],\ + ["vue", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:3.5.34"],\ + ["vue-draggable-plus", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:0.2.2"],\ + ["vue-prism-editor", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:2.0.0-alpha.2"],\ + ["vuedraggable", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:4.1.0"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ + ["cliui", [\ + ["npm:6.0.0", {\ + "packageLocation": "./.yarn/cache/cliui-npm-6.0.0-488b2414c6-44afbcc29d.zip/node_modules/cliui/",\ + "packageDependencies": [\ + ["cliui", "npm:6.0.0"],\ + ["string-width", "npm:4.2.3"],\ + ["strip-ansi", "npm:6.0.1"],\ + ["wrap-ansi", "npm:6.2.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.4", {\ + "packageLocation": "./.yarn/cache/cliui-npm-7.0.4-d6b8a9edb6-db858c49af.zip/node_modules/cliui/",\ + "packageDependencies": [\ + ["cliui", "npm:7.0.4"],\ + ["string-width", "npm:4.2.3"],\ + ["strip-ansi", "npm:6.0.1"],\ + ["wrap-ansi", "npm:7.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:8.0.1", {\ + "packageLocation": "./.yarn/cache/cliui-npm-8.0.1-3b029092cf-eaa5561aeb.zip/node_modules/cliui/",\ + "packageDependencies": [\ + ["cliui", "npm:8.0.1"],\ + ["string-width", "npm:4.2.3"],\ + ["strip-ansi", "npm:6.0.1"],\ + ["wrap-ansi", "npm:7.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["clone-response", [\ + ["npm:1.0.3", {\ + "packageLocation": "./.yarn/cache/clone-response-npm-1.0.3-f71cb6aff5-4e671cac39.zip/node_modules/clone-response/",\ + "packageDependencies": [\ + ["clone-response", "npm:1.0.3"],\ + ["mimic-response", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["cluster-key-slot", [\ + ["npm:1.1.2", {\ + "packageLocation": "./.yarn/cache/cluster-key-slot-npm-1.1.2-0571a28825-516ed8b5e1.zip/node_modules/cluster-key-slot/",\ + "packageDependencies": [\ + ["cluster-key-slot", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["co", [\ + ["npm:4.6.0", {\ + "packageLocation": "./.yarn/cache/co-npm-4.6.0-03f2d1feb6-a5d9f37091.zip/node_modules/co/",\ + "packageDependencies": [\ + ["co", "npm:4.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["co-body", [\ + ["npm:6.1.0", {\ + "packageLocation": "./.yarn/cache/co-body-npm-6.1.0-8102b96671-2484710f70.zip/node_modules/co-body/",\ + "packageDependencies": [\ + ["co-body", "npm:6.1.0"],\ + ["inflation", "npm:2.0.0"],\ + ["qs", "npm:6.11.2"],\ + ["raw-body", "npm:2.5.2"],\ + ["type-is", "npm:1.6.18"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["color", [\ + ["npm:4.2.3", {\ + "packageLocation": "./.yarn/cache/color-npm-4.2.3-4a23227581-b23f5e500a.zip/node_modules/color/",\ + "packageDependencies": [\ + ["color", "npm:4.2.3"],\ + ["color-convert", "npm:2.0.1"],\ + ["color-string", "npm:1.9.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["color-convert", [\ + ["npm:1.9.3", {\ + "packageLocation": "./.yarn/cache/color-convert-npm-1.9.3-1fe690075e-ffa3190250.zip/node_modules/color-convert/",\ + "packageDependencies": [\ + ["color-convert", "npm:1.9.3"],\ + ["color-name", "npm:1.1.3"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/color-convert-npm-2.0.1-79730e935b-fa00c91b43.zip/node_modules/color-convert/",\ + "packageDependencies": [\ + ["color-convert", "npm:2.0.1"],\ + ["color-name", "npm:1.1.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["color-name", [\ + ["npm:1.1.3", {\ + "packageLocation": "./.yarn/cache/color-name-npm-1.1.3-728b7b5d39-09c5d3e33d.zip/node_modules/color-name/",\ + "packageDependencies": [\ + ["color-name", "npm:1.1.3"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.1.4", {\ + "packageLocation": "./.yarn/cache/color-name-npm-1.1.4-025792b0ea-b044585952.zip/node_modules/color-name/",\ + "packageDependencies": [\ + ["color-name", "npm:1.1.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["color-string", [\ + ["npm:1.9.1", {\ + "packageLocation": "./.yarn/cache/color-string-npm-1.9.1-dc020e56be-72aa0b81ee.zip/node_modules/color-string/",\ + "packageDependencies": [\ + ["color-name", "npm:1.1.4"],\ + ["color-string", "npm:1.9.1"],\ + ["simple-swizzle", "npm:0.2.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["color-support", [\ + ["npm:1.1.3", {\ + "packageLocation": "./.yarn/cache/color-support-npm-1.1.3-3be5c53455-4bcfe30eea.zip/node_modules/color-support/",\ + "packageDependencies": [\ + ["color-support", "npm:1.1.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["colors", [\ + ["npm:1.2.5", {\ + "packageLocation": "./.yarn/cache/colors-npm-1.2.5-891bb7682f-fe30007df0.zip/node_modules/colors/",\ + "packageDependencies": [\ + ["colors", "npm:1.2.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["commander", [\ + ["npm:10.0.1", {\ + "packageLocation": "./.yarn/cache/commander-npm-10.0.1-f17613b72b-8799faa84a.zip/node_modules/commander/",\ + "packageDependencies": [\ + ["commander", "npm:10.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.20.3", {\ + "packageLocation": "./.yarn/cache/commander-npm-2.20.3-d8dcbaa39b-90c5b68986.zip/node_modules/commander/",\ + "packageDependencies": [\ + ["commander", "npm:2.20.3"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.1.1", {\ + "packageLocation": "./.yarn/cache/commander-npm-4.1.1-22a0fe921b-3b2dc4125f.zip/node_modules/commander/",\ + "packageDependencies": [\ + ["commander", "npm:4.1.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.2.0", {\ + "packageLocation": "./.yarn/cache/commander-npm-7.2.0-19178180f8-9973af1072.zip/node_modules/commander/",\ + "packageDependencies": [\ + ["commander", "npm:7.2.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:8.3.0", {\ + "packageLocation": "./.yarn/cache/commander-npm-8.3.0-c0d18c66d5-6b7b5d3344.zip/node_modules/commander/",\ + "packageDependencies": [\ + ["commander", "npm:8.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["compare-versions", [\ + ["npm:6.0.0", {\ + "packageLocation": "./.yarn/cache/compare-versions-npm-6.0.0-f6c7b2d14e-403ee44b99.zip/node_modules/compare-versions/",\ + "packageDependencies": [\ + ["compare-versions", "npm:6.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["compress-commons", [\ + ["npm:4.1.1", {\ + "packageLocation": "./.yarn/cache/compress-commons-npm-4.1.1-9ac41d7ac3-7e35816503.zip/node_modules/compress-commons/",\ + "packageDependencies": [\ + ["buffer-crc32", "npm:0.2.13"],\ + ["compress-commons", "npm:4.1.1"],\ + ["crc32-stream", "npm:4.0.2"],\ + ["normalize-path", "npm:3.0.0"],\ + ["readable-stream", "npm:3.6.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["concat-map", [\ + ["npm:0.0.1", {\ + "packageLocation": "./.yarn/cache/concat-map-npm-0.0.1-85a921b7ee-9680699c8e.zip/node_modules/concat-map/",\ + "packageDependencies": [\ + ["concat-map", "npm:0.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["concat-stream", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/concat-stream-npm-2.0.0-8bb2ad5aa0-250e576d06.zip/node_modules/concat-stream/",\ + "packageDependencies": [\ + ["buffer-from", "npm:1.1.2"],\ + ["concat-stream", "npm:2.0.0"],\ + ["inherits", "npm:2.0.4"],\ + ["readable-stream", "npm:3.6.2"],\ + ["typedarray", "npm:0.0.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["condense-newlines", [\ + ["npm:0.2.1", {\ + "packageLocation": "./.yarn/cache/condense-newlines-npm-0.2.1-016452195f-3c20ff6ee8.zip/node_modules/condense-newlines/",\ + "packageDependencies": [\ + ["condense-newlines", "npm:0.2.1"],\ + ["extend-shallow", "npm:2.0.1"],\ + ["is-whitespace", "npm:0.3.0"],\ + ["kind-of", "npm:3.2.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["confbox", [\ + ["npm:0.1.8", {\ + "packageLocation": "./.yarn/cache/confbox-npm-0.1.8-8396039b68-4ebcfb1c6a.zip/node_modules/confbox/",\ + "packageDependencies": [\ + ["confbox", "npm:0.1.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["config-chain", [\ + ["npm:1.1.13", {\ + "packageLocation": "./.yarn/cache/config-chain-npm-1.1.13-82e06afbc4-83d22cabf7.zip/node_modules/config-chain/",\ + "packageDependencies": [\ + ["config-chain", "npm:1.1.13"],\ + ["ini", "npm:1.3.8"],\ + ["proto-list", "npm:1.2.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["consola", [\ + ["npm:3.4.2", {\ + "packageLocation": "./.yarn/cache/consola-npm-3.4.2-133d72719e-32192c9f50.zip/node_modules/consola/",\ + "packageDependencies": [\ + ["consola", "npm:3.4.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["console-control-strings", [\ + ["npm:1.1.0", {\ + "packageLocation": "./.yarn/cache/console-control-strings-npm-1.1.0-e3160e5275-27b5fa302b.zip/node_modules/console-control-strings/",\ + "packageDependencies": [\ + ["console-control-strings", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["consolidate", [\ + ["npm:0.16.0", {\ + "packageLocation": "./.yarn/cache/consolidate-npm-0.16.0-1a9b3c81f9-74b9bc2f1c.zip/node_modules/consolidate/",\ + "packageDependencies": [\ + ["consolidate", "npm:0.16.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:86ee2a8ef0b659363be6b28aece63a5c4d251db21e8e56fe2ea2e03233d27c1fc9c744ff2a904e3dbda7f6a5fedaa3054e931f4e747f5b9deb58af95c389db98#npm:0.16.0", {\ + "packageLocation": "./.yarn/__virtual__/consolidate-virtual-be4eb30fd6/0/cache/consolidate-npm-0.16.0-1a9b3c81f9-74b9bc2f1c.zip/node_modules/consolidate/",\ + "packageDependencies": [\ + ["@types/arc-templates", null],\ + ["@types/atpl", null],\ + ["@types/babel-core", null],\ + ["@types/bracket-template", null],\ + ["@types/coffee-script", null],\ + ["@types/dot", null],\ + ["@types/dust", null],\ + ["@types/dustjs-helpers", null],\ + ["@types/dustjs-linkedin", null],\ + ["@types/eco", null],\ + ["@types/ect", null],\ + ["@types/ejs", null],\ + ["@types/haml-coffee", null],\ + ["@types/hamlet", null],\ + ["@types/hamljs", null],\ + ["@types/handlebars", null],\ + ["@types/hogan.js", null],\ + ["@types/htmling", null],\ + ["@types/jade", null],\ + ["@types/jazz", null],\ + ["@types/jqtpl", null],\ + ["@types/just", null],\ + ["@types/liquid-node", null],\ + ["@types/liquor", null],\ + ["@types/lodash", null],\ + ["@types/marko", null],\ + ["@types/mote", null],\ + ["@types/mustache", null],\ + ["@types/nunjucks", null],\ + ["@types/plates", null],\ + ["@types/pug", null],\ + ["@types/qejs", null],\ + ["@types/ractive", null],\ + ["@types/razor-tmpl", null],\ + ["@types/react", null],\ + ["@types/react-dom", null],\ + ["@types/slm", null],\ + ["@types/squirrelly", null],\ + ["@types/swig", null],\ + ["@types/swig-templates", null],\ + ["@types/teacup", null],\ + ["@types/templayed", null],\ + ["@types/then-jade", null],\ + ["@types/then-pug", null],\ + ["@types/tinyliquid", null],\ + ["@types/toffee", null],\ + ["@types/twig", null],\ + ["@types/twing", null],\ + ["@types/underscore", null],\ + ["@types/vash", null],\ + ["@types/velocityjs", null],\ + ["@types/walrus", null],\ + ["@types/whiskers", null],\ + ["arc-templates", null],\ + ["atpl", null],\ + ["babel-core", null],\ + ["bluebird", "npm:3.7.2"],\ + ["bracket-template", null],\ + ["coffee-script", null],\ + ["consolidate", "virtual:86ee2a8ef0b659363be6b28aece63a5c4d251db21e8e56fe2ea2e03233d27c1fc9c744ff2a904e3dbda7f6a5fedaa3054e931f4e747f5b9deb58af95c389db98#npm:0.16.0"],\ + ["dot", null],\ + ["dust", null],\ + ["dustjs-helpers", null],\ + ["dustjs-linkedin", null],\ + ["eco", null],\ + ["ect", null],\ + ["ejs", "npm:3.1.9"],\ + ["haml-coffee", null],\ + ["hamlet", null],\ + ["hamljs", null],\ + ["handlebars", null],\ + ["hogan.js", null],\ + ["htmling", null],\ + ["jade", null],\ + ["jazz", null],\ + ["jqtpl", null],\ + ["just", null],\ + ["liquid-node", null],\ + ["liquor", null],\ + ["lodash", null],\ + ["marko", null],\ + ["mote", null],\ + ["mustache", null],\ + ["nunjucks", null],\ + ["plates", null],\ + ["pug", "npm:3.0.2"],\ + ["qejs", null],\ + ["ractive", null],\ + ["razor-tmpl", null],\ + ["react", null],\ + ["react-dom", null],\ + ["slm", null],\ + ["squirrelly", null],\ + ["swig", null],\ + ["swig-templates", null],\ + ["teacup", null],\ + ["templayed", null],\ + ["then-jade", null],\ + ["then-pug", null],\ + ["tinyliquid", null],\ + ["toffee", null],\ + ["twig", null],\ + ["twing", null],\ + ["underscore", null],\ + ["vash", null],\ + ["velocityjs", null],\ + ["walrus", null],\ + ["whiskers", null]\ + ],\ + "packagePeers": [\ + "@types/arc-templates",\ + "@types/atpl",\ + "@types/babel-core",\ + "@types/bracket-template",\ + "@types/coffee-script",\ + "@types/dot",\ + "@types/dust",\ + "@types/dustjs-helpers",\ + "@types/dustjs-linkedin",\ + "@types/eco",\ + "@types/ect",\ + "@types/ejs",\ + "@types/haml-coffee",\ + "@types/hamlet",\ + "@types/hamljs",\ + "@types/handlebars",\ + "@types/hogan.js",\ + "@types/htmling",\ + "@types/jade",\ + "@types/jazz",\ + "@types/jqtpl",\ + "@types/just",\ + "@types/liquid-node",\ + "@types/liquor",\ + "@types/lodash",\ + "@types/marko",\ + "@types/mote",\ + "@types/mustache",\ + "@types/nunjucks",\ + "@types/plates",\ + "@types/pug",\ + "@types/qejs",\ + "@types/ractive",\ + "@types/razor-tmpl",\ + "@types/react-dom",\ + "@types/react",\ + "@types/slm",\ + "@types/squirrelly",\ + "@types/swig-templates",\ + "@types/swig",\ + "@types/teacup",\ + "@types/templayed",\ + "@types/then-jade",\ + "@types/then-pug",\ + "@types/tinyliquid",\ + "@types/toffee",\ + "@types/twig",\ + "@types/twing",\ + "@types/underscore",\ + "@types/vash",\ + "@types/velocityjs",\ + "@types/walrus",\ + "@types/whiskers",\ + "arc-templates",\ + "atpl",\ + "babel-core",\ + "bracket-template",\ + "coffee-script",\ + "dot",\ + "dust",\ + "dustjs-helpers",\ + "dustjs-linkedin",\ + "eco",\ + "ect",\ + "haml-coffee",\ + "hamlet",\ + "hamljs",\ + "handlebars",\ + "hogan.js",\ + "htmling",\ + "jade",\ + "jazz",\ + "jqtpl",\ + "just",\ + "liquid-node",\ + "liquor",\ + "lodash",\ + "marko",\ + "mote",\ + "mustache",\ + "nunjucks",\ + "plates",\ + "qejs",\ + "ractive",\ + "razor-tmpl",\ + "react-dom",\ + "react",\ + "slm",\ + "squirrelly",\ + "swig-templates",\ + "swig",\ + "teacup",\ + "templayed",\ + "then-jade",\ + "then-pug",\ + "tinyliquid",\ + "toffee",\ + "twig",\ + "twing",\ + "underscore",\ + "vash",\ + "velocityjs",\ + "walrus",\ + "whiskers"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["constantinople", [\ + ["npm:4.0.1", {\ + "packageLocation": "./.yarn/cache/constantinople-npm-4.0.1-925d9c26ce-15fc9bec82.zip/node_modules/constantinople/",\ + "packageDependencies": [\ + ["@babel/parser", "npm:7.22.7"],\ + ["@babel/types", "npm:7.22.5"],\ + ["constantinople", "npm:4.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["content-disposition", [\ + ["npm:0.5.4", {\ + "packageLocation": "./.yarn/cache/content-disposition-npm-0.5.4-2d93678616-b7f4ce176e.zip/node_modules/content-disposition/",\ + "packageDependencies": [\ + ["content-disposition", "npm:0.5.4"],\ + ["safe-buffer", "npm:5.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["content-type", [\ + ["npm:1.0.5", {\ + "packageLocation": "./.yarn/cache/content-type-npm-1.0.5-3e037bf9ab-585847d98d.zip/node_modules/content-type/",\ + "packageDependencies": [\ + ["content-type", "npm:1.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["cookies", [\ + ["npm:0.8.0", {\ + "packageLocation": "./.yarn/cache/cookies-npm-0.8.0-d7388cbd94-5da4d72ba8.zip/node_modules/cookies/",\ + "packageDependencies": [\ + ["cookies", "npm:0.8.0"],\ + ["depd", "npm:2.0.0"],\ + ["keygrip", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.9.1", {\ + "packageLocation": "./.yarn/cache/cookies-npm-0.9.1-80a82ae099-4816461a38.zip/node_modules/cookies/",\ + "packageDependencies": [\ + ["cookies", "npm:0.9.1"],\ + ["depd", "npm:2.0.0"],\ + ["keygrip", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["copy-to", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/copy-to-npm-2.0.1-474b7b678b-05ea12875b.zip/node_modules/copy-to/",\ + "packageDependencies": [\ + ["copy-to", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["core-js", [\ + ["npm:3.49.0", {\ + "packageLocation": "./.yarn/unplugged/core-js-npm-3.49.0-0a974f48cc/node_modules/core-js/",\ + "packageDependencies": [\ + ["core-js", "npm:3.49.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["core-util-is", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/core-util-is-npm-1.0.2-9fc2b94dc3-d0f7587346.zip/node_modules/core-util-is/",\ + "packageDependencies": [\ + ["core-util-is", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.0.3", {\ + "packageLocation": "./.yarn/cache/core-util-is-npm-1.0.3-ca74b76c90-9de8597363.zip/node_modules/core-util-is/",\ + "packageDependencies": [\ + ["core-util-is", "npm:1.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["crc-32", [\ + ["npm:1.2.2", {\ + "packageLocation": "./.yarn/cache/crc-32-npm-1.2.2-28bdc12bcc-824f696a5b.zip/node_modules/crc-32/",\ + "packageDependencies": [\ + ["crc-32", "npm:1.2.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["crc32-stream", [\ + ["npm:4.0.2", {\ + "packageLocation": "./.yarn/cache/crc32-stream-npm-4.0.2-32a2ec50b7-1099559283.zip/node_modules/crc32-stream/",\ + "packageDependencies": [\ + ["crc-32", "npm:1.2.2"],\ + ["crc32-stream", "npm:4.0.2"],\ + ["readable-stream", "npm:3.6.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["cron-parser", [\ + ["npm:4.9.0", {\ + "packageLocation": "./.yarn/cache/cron-parser-npm-4.9.0-2a573f98e9-ffca5e532a.zip/node_modules/cron-parser/",\ + "packageDependencies": [\ + ["cron-parser", "npm:4.9.0"],\ + ["luxon", "npm:3.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["cropperjs", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/cropperjs-npm-2.1.1-8160372ae5-706a8ec57b.zip/node_modules/cropperjs/",\ + "packageDependencies": [\ + ["@cropper/elements", "npm:2.1.1"],\ + ["@cropper/utils", "npm:2.1.1"],\ + ["cropperjs", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["cross-env", [\ + ["npm:10.1.0", {\ + "packageLocation": "./.yarn/cache/cross-env-npm-10.1.0-d2d02f62e5-0e5d8bdefb.zip/node_modules/cross-env/",\ + "packageDependencies": [\ + ["@epic-web/invariant", "npm:1.0.0"],\ + ["cross-env", "npm:10.1.0"],\ + ["cross-spawn", "npm:7.0.6"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.3", {\ + "packageLocation": "./.yarn/cache/cross-env-npm-7.0.3-96d81820f4-e99911f0d3.zip/node_modules/cross-env/",\ + "packageDependencies": [\ + ["cross-env", "npm:7.0.3"],\ + ["cross-spawn", "npm:7.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["cross-spawn", [\ + ["npm:5.1.0", {\ + "packageLocation": "./.yarn/cache/cross-spawn-npm-5.1.0-a3e220603e-726939c995.zip/node_modules/cross-spawn/",\ + "packageDependencies": [\ + ["cross-spawn", "npm:5.1.0"],\ + ["lru-cache", "npm:4.1.5"],\ + ["shebang-command", "npm:1.2.0"],\ + ["which", "npm:1.3.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.3", {\ + "packageLocation": "./.yarn/cache/cross-spawn-npm-7.0.3-e4ff3e65b3-e1a13869d2.zip/node_modules/cross-spawn/",\ + "packageDependencies": [\ + ["cross-spawn", "npm:7.0.3"],\ + ["path-key", "npm:3.1.1"],\ + ["shebang-command", "npm:2.0.0"],\ + ["which", "npm:2.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.6", {\ + "packageLocation": "./.yarn/cache/cross-spawn-npm-7.0.6-264bddf921-0d52657d7a.zip/node_modules/cross-spawn/",\ + "packageDependencies": [\ + ["cross-spawn", "npm:7.0.6"],\ + ["path-key", "npm:3.1.1"],\ + ["shebang-command", "npm:2.0.0"],\ + ["which", "npm:2.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["css-select", [\ + ["npm:1.2.0", {\ + "packageLocation": "./.yarn/cache/css-select-npm-1.2.0-a7a03607e0-4a57b1e39d.zip/node_modules/css-select/",\ + "packageDependencies": [\ + ["boolbase", "npm:1.0.0"],\ + ["css-select", "npm:1.2.0"],\ + ["css-what", "npm:2.1.3"],\ + ["domutils", "npm:1.5.1"],\ + ["nth-check", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["css-what", [\ + ["npm:2.1.3", {\ + "packageLocation": "./.yarn/cache/css-what-npm-2.1.3-a9583898e8-2a46608ecb.zip/node_modules/css-what/",\ + "packageDependencies": [\ + ["css-what", "npm:2.1.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["cssstyle", [\ + ["npm:4.6.0", {\ + "packageLocation": "./.yarn/cache/cssstyle-npm-4.6.0-ef3d5f305b-1cb25c9d66.zip/node_modules/cssstyle/",\ + "packageDependencies": [\ + ["@asamuzakjp/css-color", "npm:3.2.0"],\ + ["cssstyle", "npm:4.6.0"],\ + ["rrweb-cssom", "npm:0.8.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["csstype", [\ + ["npm:3.2.3", {\ + "packageLocation": "./.yarn/cache/csstype-npm-3.2.3-741053244e-ad41baf7e2.zip/node_modules/csstype/",\ + "packageDependencies": [\ + ["csstype", "npm:3.2.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["custom-event-polyfill", [\ + ["npm:1.0.7", {\ + "packageLocation": "./.yarn/cache/custom-event-polyfill-npm-1.0.7-629381795b-f9ff2cf13e.zip/node_modules/custom-event-polyfill/",\ + "packageDependencies": [\ + ["custom-event-polyfill", "npm:1.0.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["d", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/d-npm-1.0.1-64afbbc689-1296e3f92e.zip/node_modules/d/",\ + "packageDependencies": [\ + ["d", "npm:1.0.1"],\ + ["es5-ext", "npm:0.10.62"],\ + ["type", "npm:1.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["dargs", [\ + ["npm:8.1.0", {\ + "packageLocation": "./.yarn/cache/dargs-npm-8.1.0-39fde97ef5-33f1b8f5f0.zip/node_modules/dargs/",\ + "packageDependencies": [\ + ["dargs", "npm:8.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["dashdash", [\ + ["npm:1.14.1", {\ + "packageLocation": "./.yarn/cache/dashdash-npm-1.14.1-be8f10a286-137b287fa0.zip/node_modules/dashdash/",\ + "packageDependencies": [\ + ["assert-plus", "npm:1.0.0"],\ + ["dashdash", "npm:1.14.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["data-uri-to-buffer", [\ + ["npm:4.0.1", {\ + "packageLocation": "./.yarn/cache/data-uri-to-buffer-npm-4.0.1-5c66a78beb-0d0790b67f.zip/node_modules/data-uri-to-buffer/",\ + "packageDependencies": [\ + ["data-uri-to-buffer", "npm:4.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["data-urls", [\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/data-urls-npm-5.0.0-4b58b89bfe-5c40568c31.zip/node_modules/data-urls/",\ + "packageDependencies": [\ + ["data-urls", "npm:5.0.0"],\ + ["whatwg-mimetype", "npm:4.0.0"],\ + ["whatwg-url", "npm:14.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["date-fns", [\ + ["npm:2.30.0", {\ + "packageLocation": "./.yarn/cache/date-fns-npm-2.30.0-895c790e0f-70b3e8ea7a.zip/node_modules/date-fns/",\ + "packageDependencies": [\ + ["@babel/runtime", "npm:7.22.6"],\ + ["date-fns", "npm:2.30.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.1.0", {\ + "packageLocation": "./.yarn/cache/date-fns-npm-4.1.0-764604ee0f-d5f6e9de5b.zip/node_modules/date-fns/",\ + "packageDependencies": [\ + ["date-fns", "npm:4.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["debug", [\ + ["npm:2.6.9", {\ + "packageLocation": "./.yarn/cache/debug-npm-2.6.9-7d4cb597dc-e07005f2b4.zip/node_modules/debug/",\ + "packageDependencies": [\ + ["debug", "npm:2.6.9"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["npm:3.2.7", {\ + "packageLocation": "./.yarn/cache/debug-npm-3.2.7-754e818c7a-d86fd7be2b.zip/node_modules/debug/",\ + "packageDependencies": [\ + ["debug", "npm:3.2.7"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["npm:4.3.3", {\ + "packageLocation": "./.yarn/cache/debug-npm-4.3.3-710fd4cc7f-723a9570dc.zip/node_modules/debug/",\ + "packageDependencies": [\ + ["debug", "npm:4.3.3"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["npm:4.3.4", {\ + "packageLocation": "./.yarn/cache/debug-npm-4.3.4-4513954577-0073c3bcbd.zip/node_modules/debug/",\ + "packageDependencies": [\ + ["debug", "npm:4.3.4"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["npm:4.4.3", {\ + "packageLocation": "./.yarn/cache/debug-npm-4.4.3-0105c6123a-9ada3434ea.zip/node_modules/debug/",\ + "packageDependencies": [\ + ["debug", "npm:4.4.3"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:0b70187c8540c711e7ec6828978e1d7ecbf862adebfcbfade4bfe1470fbdf59ca56319a056ee7ea510b1dd57a2faea769d1589a1b06d26c3a84a4bd41431045b#npm:2.6.9", {\ + "packageLocation": "./.yarn/__virtual__/debug-virtual-1eba590242/0/cache/debug-npm-2.6.9-7d4cb597dc-e07005f2b4.zip/node_modules/debug/",\ + "packageDependencies": [\ + ["@types/supports-color", null],\ + ["debug", "virtual:0b70187c8540c711e7ec6828978e1d7ecbf862adebfcbfade4bfe1470fbdf59ca56319a056ee7ea510b1dd57a2faea769d1589a1b06d26c3a84a4bd41431045b#npm:2.6.9"],\ + ["ms", "npm:2.0.0"],\ + ["supports-color", "npm:8.1.1"]\ + ],\ + "packagePeers": [\ + "@types/supports-color"\ + ],\ + "linkType": "HARD"\ + }],\ + ["virtual:4248438d52dae4f796f8ae7839807024b795f7c70ddf4cbb58a4d6330ce027df2b3c8481d65b7430d525478785f68212512cd0d537d63bda0f170167a0f757f6#npm:3.2.7", {\ + "packageLocation": "./.yarn/__virtual__/debug-virtual-3091bbe68e/0/cache/debug-npm-3.2.7-754e818c7a-d86fd7be2b.zip/node_modules/debug/",\ + "packageDependencies": [\ + ["@types/supports-color", null],\ + ["debug", "virtual:4248438d52dae4f796f8ae7839807024b795f7c70ddf4cbb58a4d6330ce027df2b3c8481d65b7430d525478785f68212512cd0d537d63bda0f170167a0f757f6#npm:3.2.7"],\ + ["ms", "npm:2.1.3"],\ + ["supports-color", "npm:8.1.1"]\ + ],\ + "packagePeers": [\ + "@types/supports-color"\ + ],\ + "linkType": "HARD"\ + }],\ + ["virtual:74d1fab5177fa3d9b5330713f9165a97e07bf791e6588c23ea17dde3e0cee950bb18c0366fe88ff7556f854762863ea192e2ad8db90eee046b5cb7c387ad9e72#npm:4.3.3", {\ + "packageLocation": "./.yarn/__virtual__/debug-virtual-57c726f1da/0/cache/debug-npm-4.3.3-710fd4cc7f-723a9570dc.zip/node_modules/debug/",\ + "packageDependencies": [\ + ["@types/supports-color", null],\ + ["debug", "virtual:74d1fab5177fa3d9b5330713f9165a97e07bf791e6588c23ea17dde3e0cee950bb18c0366fe88ff7556f854762863ea192e2ad8db90eee046b5cb7c387ad9e72#npm:4.3.3"],\ + ["ms", "npm:2.1.2"],\ + ["supports-color", "npm:8.1.1"]\ + ],\ + "packagePeers": [\ + "@types/supports-color"\ + ],\ + "linkType": "HARD"\ + }],\ + ["virtual:90a0f1fb5c11f2caeade015df18a36b1fbdd43c7dd5da4b8fc27a92da9a256be3f461a218d644a2c25bbbb94dccf8169d67cc52a5c2857f0f996be9f75f65682#npm:4.4.3", {\ + "packageLocation": "./.yarn/__virtual__/debug-virtual-8f4b8451ca/0/cache/debug-npm-4.4.3-0105c6123a-9ada3434ea.zip/node_modules/debug/",\ + "packageDependencies": [\ + ["@types/supports-color", null],\ + ["debug", "virtual:90a0f1fb5c11f2caeade015df18a36b1fbdd43c7dd5da4b8fc27a92da9a256be3f461a218d644a2c25bbbb94dccf8169d67cc52a5c2857f0f996be9f75f65682#npm:4.4.3"],\ + ["ms", "npm:2.1.3"],\ + ["supports-color", "npm:8.1.1"]\ + ],\ + "packagePeers": [\ + "@types/supports-color"\ + ],\ + "linkType": "HARD"\ + }],\ + ["virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4", {\ + "packageLocation": "./.yarn/__virtual__/debug-virtual-ebc9063b40/0/cache/debug-npm-4.3.4-4513954577-0073c3bcbd.zip/node_modules/debug/",\ + "packageDependencies": [\ + ["@types/supports-color", null],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["ms", "npm:2.1.2"],\ + ["supports-color", "npm:8.1.1"]\ + ],\ + "packagePeers": [\ + "@types/supports-color"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["decamelize", [\ + ["npm:1.2.0", {\ + "packageLocation": "./.yarn/cache/decamelize-npm-1.2.0-c5a2fdc622-ad8c51a7e7.zip/node_modules/decamelize/",\ + "packageDependencies": [\ + ["decamelize", "npm:1.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["decimal.js", [\ + ["npm:10.6.0", {\ + "packageLocation": "./.yarn/cache/decimal.js-npm-10.6.0-a72c1b8a2f-c0d45842d4.zip/node_modules/decimal.js/",\ + "packageDependencies": [\ + ["decimal.js", "npm:10.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["decompress", [\ + ["npm:4.2.1", {\ + "packageLocation": "./.yarn/cache/decompress-npm-4.2.1-a79829cc55-8247a31c6d.zip/node_modules/decompress/",\ + "packageDependencies": [\ + ["decompress", "npm:4.2.1"],\ + ["decompress-tar", "npm:4.1.1"],\ + ["decompress-tarbz2", "npm:4.1.1"],\ + ["decompress-targz", "npm:4.1.1"],\ + ["decompress-unzip", "npm:4.0.1"],\ + ["graceful-fs", "npm:4.2.11"],\ + ["make-dir", "npm:1.3.0"],\ + ["pify", "npm:2.3.0"],\ + ["strip-dirs", "npm:2.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["decompress-response", [\ + ["npm:10.0.0", {\ + "packageLocation": "./.yarn/cache/decompress-response-npm-10.0.0-6efc5f16be-e4a1dbbef3.zip/node_modules/decompress-response/",\ + "packageDependencies": [\ + ["decompress-response", "npm:10.0.0"],\ + ["mimic-response", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:6.0.0", {\ + "packageLocation": "./.yarn/cache/decompress-response-npm-6.0.0-359de2878c-d377cf47e0.zip/node_modules/decompress-response/",\ + "packageDependencies": [\ + ["decompress-response", "npm:6.0.0"],\ + ["mimic-response", "npm:3.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["decompress-tar", [\ + ["npm:4.1.1", {\ + "packageLocation": "./.yarn/cache/decompress-tar-npm-4.1.1-ff91950fee-820c645dfa.zip/node_modules/decompress-tar/",\ + "packageDependencies": [\ + ["decompress-tar", "npm:4.1.1"],\ + ["file-type", "npm:5.2.0"],\ + ["is-stream", "npm:1.1.0"],\ + ["tar-stream", "npm:1.6.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["decompress-tarbz2", [\ + ["npm:4.1.1", {\ + "packageLocation": "./.yarn/cache/decompress-tarbz2-npm-4.1.1-69114342e0-519c813377.zip/node_modules/decompress-tarbz2/",\ + "packageDependencies": [\ + ["decompress-tar", "npm:4.1.1"],\ + ["decompress-tarbz2", "npm:4.1.1"],\ + ["file-type", "npm:6.2.0"],\ + ["is-stream", "npm:1.1.0"],\ + ["seek-bzip", "npm:1.0.6"],\ + ["unbzip2-stream", "npm:1.4.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["decompress-targz", [\ + ["npm:4.1.1", {\ + "packageLocation": "./.yarn/cache/decompress-targz-npm-4.1.1-444d44d5aa-22738f58eb.zip/node_modules/decompress-targz/",\ + "packageDependencies": [\ + ["decompress-tar", "npm:4.1.1"],\ + ["decompress-targz", "npm:4.1.1"],\ + ["file-type", "npm:5.2.0"],\ + ["is-stream", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["decompress-unzip", [\ + ["npm:4.0.1", {\ + "packageLocation": "./.yarn/cache/decompress-unzip-npm-4.0.1-8d93b02d1f-ba9f3204ab.zip/node_modules/decompress-unzip/",\ + "packageDependencies": [\ + ["decompress-unzip", "npm:4.0.1"],\ + ["file-type", "npm:3.9.0"],\ + ["get-stream", "npm:2.3.1"],\ + ["pify", "npm:2.3.0"],\ + ["yauzl", "npm:2.10.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["deep-email-validator", [\ + ["npm:0.1.21", {\ + "packageLocation": "./.yarn/cache/deep-email-validator-npm-0.1.21-1b8cf9cd09-f3f9e4d3bd.zip/node_modules/deep-email-validator/",\ + "packageDependencies": [\ + ["@types/disposable-email-domains", "npm:1.0.4"],\ + ["axios", "npm:0.24.0"],\ + ["deep-email-validator", "npm:0.1.21"],\ + ["disposable-email-domains", "npm:1.0.62"],\ + ["mailcheck", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["deep-equal", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/deep-equal-npm-1.0.1-f05565c4e5-cbecc071af.zip/node_modules/deep-equal/",\ + "packageDependencies": [\ + ["deep-equal", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["deepmerge", [\ + ["npm:4.3.1", {\ + "packageLocation": "./.yarn/cache/deepmerge-npm-4.3.1-4f751a0844-058d9e1b0f.zip/node_modules/deepmerge/",\ + "packageDependencies": [\ + ["deepmerge", "npm:4.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["defer-to-connect", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/defer-to-connect-npm-2.0.1-9005cc8c60-8a9b50d2f2.zip/node_modules/defer-to-connect/",\ + "packageDependencies": [\ + ["defer-to-connect", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["delegates", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/delegates-npm-1.0.0-9b1942d75f-a51744d9b5.zip/node_modules/delegates/",\ + "packageDependencies": [\ + ["delegates", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["denque", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/denque-npm-2.1.0-578d0b6297-8ea0532157.zip/node_modules/denque/",\ + "packageDependencies": [\ + ["denque", "npm:2.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["depd", [\ + ["npm:1.1.2", {\ + "packageLocation": "./.yarn/cache/depd-npm-1.1.2-b0c8414da7-2ed6966fc1.zip/node_modules/depd/",\ + "packageDependencies": [\ + ["depd", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/depd-npm-2.0.0-b6c51a4b43-c0c8ff3607.zip/node_modules/depd/",\ + "packageDependencies": [\ + ["depd", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["destroy", [\ + ["npm:1.2.0", {\ + "packageLocation": "./.yarn/cache/destroy-npm-1.2.0-6a511802e2-0acb300b74.zip/node_modules/destroy/",\ + "packageDependencies": [\ + ["destroy", "npm:1.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["detect-libc", [\ + ["npm:2.0.3", {\ + "packageLocation": "./.yarn/cache/detect-libc-npm-2.0.3-2ddae34945-b4ea018d62.zip/node_modules/detect-libc/",\ + "packageDependencies": [\ + ["detect-libc", "npm:2.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["dezalgo", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/cache/dezalgo-npm-1.0.4-ae3b673c98-895389c6ae.zip/node_modules/dezalgo/",\ + "packageDependencies": [\ + ["asap", "npm:2.0.6"],\ + ["dezalgo", "npm:1.0.4"],\ + ["wrappy", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["dijkstrajs", [\ + ["npm:1.0.3", {\ + "packageLocation": "./.yarn/cache/dijkstrajs-npm-1.0.3-d5b1d1b11a-0d8429699a.zip/node_modules/dijkstrajs/",\ + "packageDependencies": [\ + ["dijkstrajs", "npm:1.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["disposable-email-domains", [\ + ["npm:1.0.62", {\ + "packageLocation": "./.yarn/cache/disposable-email-domains-npm-1.0.62-c79270b5ba-1d683fde9b.zip/node_modules/disposable-email-domains/",\ + "packageDependencies": [\ + ["disposable-email-domains", "npm:1.0.62"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["doctypes", [\ + ["npm:1.1.0", {\ + "packageLocation": "./.yarn/cache/doctypes-npm-1.1.0-cb4fdda595-6e6c2d1a80.zip/node_modules/doctypes/",\ + "packageDependencies": [\ + ["doctypes", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["dom-serializer", [\ + ["npm:0.1.1", {\ + "packageLocation": "./.yarn/cache/dom-serializer-npm-0.1.1-4c6e4ec242-4f6a3eff80.zip/node_modules/dom-serializer/",\ + "packageDependencies": [\ + ["dom-serializer", "npm:0.1.1"],\ + ["domelementtype", "npm:1.3.1"],\ + ["entities", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.2.2", {\ + "packageLocation": "./.yarn/cache/dom-serializer-npm-0.2.2-2e24969c0e-376344893e.zip/node_modules/dom-serializer/",\ + "packageDependencies": [\ + ["dom-serializer", "npm:0.2.2"],\ + ["domelementtype", "npm:2.3.0"],\ + ["entities", "npm:2.2.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/dom-serializer-npm-2.0.0-378ebc7200-e3bf9027a6.zip/node_modules/dom-serializer/",\ + "packageDependencies": [\ + ["dom-serializer", "npm:2.0.0"],\ + ["domelementtype", "npm:2.3.0"],\ + ["domhandler", "npm:5.0.3"],\ + ["entities", "npm:4.5.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["domelementtype", [\ + ["npm:1.3.1", {\ + "packageLocation": "./.yarn/cache/domelementtype-npm-1.3.1-87c4b5f9f4-7893da4021.zip/node_modules/domelementtype/",\ + "packageDependencies": [\ + ["domelementtype", "npm:1.3.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.3.0", {\ + "packageLocation": "./.yarn/cache/domelementtype-npm-2.3.0-02de7cbfba-ee837a318f.zip/node_modules/domelementtype/",\ + "packageDependencies": [\ + ["domelementtype", "npm:2.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["domhandler", [\ + ["npm:2.4.2", {\ + "packageLocation": "./.yarn/cache/domhandler-npm-2.4.2-497ea9cea1-d8b0303c53.zip/node_modules/domhandler/",\ + "packageDependencies": [\ + ["domelementtype", "npm:1.3.1"],\ + ["domhandler", "npm:2.4.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.0.3", {\ + "packageLocation": "./.yarn/cache/domhandler-npm-5.0.3-3ede73dc10-809b805a50.zip/node_modules/domhandler/",\ + "packageDependencies": [\ + ["domelementtype", "npm:2.3.0"],\ + ["domhandler", "npm:5.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["domutils", [\ + ["npm:1.5.1", {\ + "packageLocation": "./.yarn/cache/domutils-npm-1.5.1-6f8de414e8-88c610e4bb.zip/node_modules/domutils/",\ + "packageDependencies": [\ + ["dom-serializer", "npm:0.2.2"],\ + ["domelementtype", "npm:1.3.1"],\ + ["domutils", "npm:1.5.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.7.0", {\ + "packageLocation": "./.yarn/cache/domutils-npm-1.7.0-7a1529fcfc-8c1d879fd3.zip/node_modules/domutils/",\ + "packageDependencies": [\ + ["dom-serializer", "npm:0.2.2"],\ + ["domelementtype", "npm:1.3.1"],\ + ["domutils", "npm:1.7.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/domutils-npm-3.1.0-66c92ef7eb-9a169a6e57.zip/node_modules/domutils/",\ + "packageDependencies": [\ + ["dom-serializer", "npm:2.0.0"],\ + ["domelementtype", "npm:2.3.0"],\ + ["domhandler", "npm:5.0.3"],\ + ["domutils", "npm:3.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["dotenv", [\ + ["npm:16.3.1", {\ + "packageLocation": "./.yarn/cache/dotenv-npm-16.3.1-e6d380a398-dbb778237e.zip/node_modules/dotenv/",\ + "packageDependencies": [\ + ["dotenv", "npm:16.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["duplexify", [\ + ["npm:3.7.1", {\ + "packageLocation": "./.yarn/cache/duplexify-npm-3.7.1-8f4f1e821f-7799984d17.zip/node_modules/duplexify/",\ + "packageDependencies": [\ + ["duplexify", "npm:3.7.1"],\ + ["end-of-stream", "npm:1.4.4"],\ + ["inherits", "npm:2.0.4"],\ + ["readable-stream", "npm:2.3.8"],\ + ["stream-shift", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["eastasianwidth", [\ + ["npm:0.2.0", {\ + "packageLocation": "./.yarn/cache/eastasianwidth-npm-0.2.0-c37eb16bd1-9b1d3e1bae.zip/node_modules/eastasianwidth/",\ + "packageDependencies": [\ + ["eastasianwidth", "npm:0.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ecc-jsbn", [\ + ["npm:0.1.2", {\ + "packageLocation": "./.yarn/cache/ecc-jsbn-npm-0.1.2-85b7a7be89-d43591f239.zip/node_modules/ecc-jsbn/",\ + "packageDependencies": [\ + ["ecc-jsbn", "npm:0.1.2"],\ + ["jsbn", "npm:0.1.1"],\ + ["safer-buffer", "npm:2.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ecdsa-sig-formatter", [\ + ["npm:1.0.11", {\ + "packageLocation": "./.yarn/cache/ecdsa-sig-formatter-npm-1.0.11-b6784e7852-878e1aab8a.zip/node_modules/ecdsa-sig-formatter/",\ + "packageDependencies": [\ + ["ecdsa-sig-formatter", "npm:1.0.11"],\ + ["safe-buffer", "npm:5.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["editorconfig", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/cache/editorconfig-npm-1.0.4-95c5a5b144-bd0a7236f3.zip/node_modules/editorconfig/",\ + "packageDependencies": [\ + ["@one-ini/wasm", "npm:0.1.1"],\ + ["commander", "npm:10.0.1"],\ + ["editorconfig", "npm:1.0.4"],\ + ["minimatch", "npm:9.0.1"],\ + ["semver", "npm:7.5.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ee-first", [\ + ["npm:1.1.1", {\ + "packageLocation": "./.yarn/cache/ee-first-npm-1.1.1-33f8535b39-1b4cac778d.zip/node_modules/ee-first/",\ + "packageDependencies": [\ + ["ee-first", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ejs", [\ + ["npm:3.1.10", {\ + "packageLocation": "./.yarn/cache/ejs-npm-3.1.10-4e8cf4bdc1-a9cb7d7cd1.zip/node_modules/ejs/",\ + "packageDependencies": [\ + ["ejs", "npm:3.1.10"],\ + ["jake", "npm:10.8.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.1.9", {\ + "packageLocation": "./.yarn/cache/ejs-npm-3.1.9-e201b2088c-71f56d3754.zip/node_modules/ejs/",\ + "packageDependencies": [\ + ["ejs", "npm:3.1.9"],\ + ["jake", "npm:10.8.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["emoji-regex", [\ + ["npm:8.0.0", {\ + "packageLocation": "./.yarn/cache/emoji-regex-npm-8.0.0-213764015c-c72d67a682.zip/node_modules/emoji-regex/",\ + "packageDependencies": [\ + ["emoji-regex", "npm:8.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:9.2.2", {\ + "packageLocation": "./.yarn/cache/emoji-regex-npm-9.2.2-e6fac8d058-915acf859c.zip/node_modules/emoji-regex/",\ + "packageDependencies": [\ + ["emoji-regex", "npm:9.2.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["emojilib", [\ + ["npm:4.0.2", {\ + "packageLocation": "./.yarn/cache/emojilib-npm-4.0.2-957c9ac82d-43490c752c.zip/node_modules/emojilib/",\ + "packageDependencies": [\ + ["emojilib", "npm:4.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["encode-utf8", [\ + ["npm:1.0.3", {\ + "packageLocation": "./.yarn/cache/encode-utf8-npm-1.0.3-8f92a23782-0204c37cda.zip/node_modules/encode-utf8/",\ + "packageDependencies": [\ + ["encode-utf8", "npm:1.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["encodeurl", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/encodeurl-npm-1.0.2-f8c8454c41-e50e3d508c.zip/node_modules/encodeurl/",\ + "packageDependencies": [\ + ["encodeurl", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["encoding", [\ + ["npm:0.1.13", {\ + "packageLocation": "./.yarn/cache/encoding-npm-0.1.13-82a1837d30-bb98632f8f.zip/node_modules/encoding/",\ + "packageDependencies": [\ + ["encoding", "npm:0.1.13"],\ + ["iconv-lite", "npm:0.6.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["end-of-stream", [\ + ["npm:1.4.4", {\ + "packageLocation": "./.yarn/cache/end-of-stream-npm-1.4.4-497fc6dee1-530a5a5a1e.zip/node_modules/end-of-stream/",\ + "packageDependencies": [\ + ["end-of-stream", "npm:1.4.4"],\ + ["once", "npm:1.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["entities", [\ + ["npm:1.1.2", {\ + "packageLocation": "./.yarn/cache/entities-npm-1.1.2-78e77a4b6d-4a707022f4.zip/node_modules/entities/",\ + "packageDependencies": [\ + ["entities", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.2.0", {\ + "packageLocation": "./.yarn/cache/entities-npm-2.2.0-0fc8d5b2f7-2c765221ee.zip/node_modules/entities/",\ + "packageDependencies": [\ + ["entities", "npm:2.2.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.5.0", {\ + "packageLocation": "./.yarn/cache/entities-npm-4.5.0-7cdb83b832-ede2a35c9b.zip/node_modules/entities/",\ + "packageDependencies": [\ + ["entities", "npm:4.5.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:6.0.1", {\ + "packageLocation": "./.yarn/cache/entities-npm-6.0.1-84692dab43-62af130720.zip/node_modules/entities/",\ + "packageDependencies": [\ + ["entities", "npm:6.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.1", {\ + "packageLocation": "./.yarn/cache/entities-npm-7.0.1-61f8ba3430-3c0c58d869.zip/node_modules/entities/",\ + "packageDependencies": [\ + ["entities", "npm:7.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["env-paths", [\ + ["npm:2.2.1", {\ + "packageLocation": "./.yarn/cache/env-paths-npm-2.2.1-7c7577428c-65b5df55a8.zip/node_modules/env-paths/",\ + "packageDependencies": [\ + ["env-paths", "npm:2.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["err-code", [\ + ["npm:2.0.3", {\ + "packageLocation": "./.yarn/cache/err-code-npm-2.0.3-082e0ff9a7-1d20d825cd.zip/node_modules/err-code/",\ + "packageDependencies": [\ + ["err-code", "npm:2.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["es5-ext", [\ + ["npm:0.10.62", {\ + "packageLocation": "./.yarn/unplugged/es5-ext-npm-0.10.62-f20aca46cb/node_modules/es5-ext/",\ + "packageDependencies": [\ + ["es5-ext", "npm:0.10.62"],\ + ["es6-iterator", "npm:2.0.3"],\ + ["es6-symbol", "npm:3.1.3"],\ + ["next-tick", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["es6-iterator", [\ + ["npm:2.0.3", {\ + "packageLocation": "./.yarn/cache/es6-iterator-npm-2.0.3-4dadb0ccc1-dbadecf3d0.zip/node_modules/es6-iterator/",\ + "packageDependencies": [\ + ["d", "npm:1.0.1"],\ + ["es5-ext", "npm:0.10.62"],\ + ["es6-iterator", "npm:2.0.3"],\ + ["es6-symbol", "npm:3.1.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["es6-symbol", [\ + ["npm:3.1.3", {\ + "packageLocation": "./.yarn/cache/es6-symbol-npm-3.1.3-34d72f2a23-b404e5ecae.zip/node_modules/es6-symbol/",\ + "packageDependencies": [\ + ["d", "npm:1.0.1"],\ + ["es6-symbol", "npm:3.1.3"],\ + ["ext", "npm:1.7.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["esbuild", [\ + ["npm:0.27.7", {\ + "packageLocation": "./.yarn/unplugged/esbuild-npm-0.27.7-53394ab392/node_modules/esbuild/",\ + "packageDependencies": [\ + ["@esbuild/aix-ppc64", "npm:0.27.7"],\ + ["@esbuild/android-arm", "npm:0.27.7"],\ + ["@esbuild/android-arm64", "npm:0.27.7"],\ + ["@esbuild/android-x64", "npm:0.27.7"],\ + ["@esbuild/darwin-arm64", "npm:0.27.7"],\ + ["@esbuild/darwin-x64", "npm:0.27.7"],\ + ["@esbuild/freebsd-arm64", "npm:0.27.7"],\ + ["@esbuild/freebsd-x64", "npm:0.27.7"],\ + ["@esbuild/linux-arm", "npm:0.27.7"],\ + ["@esbuild/linux-arm64", "npm:0.27.7"],\ + ["@esbuild/linux-ia32", "npm:0.27.7"],\ + ["@esbuild/linux-loong64", "npm:0.27.7"],\ + ["@esbuild/linux-mips64el", "npm:0.27.7"],\ + ["@esbuild/linux-ppc64", "npm:0.27.7"],\ + ["@esbuild/linux-riscv64", "npm:0.27.7"],\ + ["@esbuild/linux-s390x", "npm:0.27.7"],\ + ["@esbuild/linux-x64", "npm:0.27.7"],\ + ["@esbuild/netbsd-arm64", "npm:0.27.7"],\ + ["@esbuild/netbsd-x64", "npm:0.27.7"],\ + ["@esbuild/openbsd-arm64", "npm:0.27.7"],\ + ["@esbuild/openbsd-x64", "npm:0.27.7"],\ + ["@esbuild/openharmony-arm64", "npm:0.27.7"],\ + ["@esbuild/sunos-x64", "npm:0.27.7"],\ + ["@esbuild/win32-arm64", "npm:0.27.7"],\ + ["@esbuild/win32-ia32", "npm:0.27.7"],\ + ["@esbuild/win32-x64", "npm:0.27.7"],\ + ["esbuild", "npm:0.27.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.28.0", {\ + "packageLocation": "./.yarn/unplugged/esbuild-npm-0.28.0-a0b033d09e/node_modules/esbuild/",\ + "packageDependencies": [\ + ["@esbuild/aix-ppc64", "npm:0.28.0"],\ + ["@esbuild/android-arm", "npm:0.28.0"],\ + ["@esbuild/android-arm64", "npm:0.28.0"],\ + ["@esbuild/android-x64", "npm:0.28.0"],\ + ["@esbuild/darwin-arm64", "npm:0.28.0"],\ + ["@esbuild/darwin-x64", "npm:0.28.0"],\ + ["@esbuild/freebsd-arm64", "npm:0.28.0"],\ + ["@esbuild/freebsd-x64", "npm:0.28.0"],\ + ["@esbuild/linux-arm", "npm:0.28.0"],\ + ["@esbuild/linux-arm64", "npm:0.28.0"],\ + ["@esbuild/linux-ia32", "npm:0.28.0"],\ + ["@esbuild/linux-loong64", "npm:0.28.0"],\ + ["@esbuild/linux-mips64el", "npm:0.28.0"],\ + ["@esbuild/linux-ppc64", "npm:0.28.0"],\ + ["@esbuild/linux-riscv64", "npm:0.28.0"],\ + ["@esbuild/linux-s390x", "npm:0.28.0"],\ + ["@esbuild/linux-x64", "npm:0.28.0"],\ + ["@esbuild/netbsd-arm64", "npm:0.28.0"],\ + ["@esbuild/netbsd-x64", "npm:0.28.0"],\ + ["@esbuild/openbsd-arm64", "npm:0.28.0"],\ + ["@esbuild/openbsd-x64", "npm:0.28.0"],\ + ["@esbuild/openharmony-arm64", "npm:0.28.0"],\ + ["@esbuild/sunos-x64", "npm:0.28.0"],\ + ["@esbuild/win32-arm64", "npm:0.28.0"],\ + ["@esbuild/win32-ia32", "npm:0.28.0"],\ + ["@esbuild/win32-x64", "npm:0.28.0"],\ + ["esbuild", "npm:0.28.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["escalade", [\ + ["npm:3.1.1", {\ + "packageLocation": "./.yarn/cache/escalade-npm-3.1.1-e02da076aa-afa618e733.zip/node_modules/escalade/",\ + "packageDependencies": [\ + ["escalade", "npm:3.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["escape-html", [\ + ["npm:1.0.3", {\ + "packageLocation": "./.yarn/cache/escape-html-npm-1.0.3-376c22ee74-6213ca9ae0.zip/node_modules/escape-html/",\ + "packageDependencies": [\ + ["escape-html", "npm:1.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["escape-regexp", [\ + ["npm:0.0.1", {\ + "packageLocation": "./.yarn/cache/escape-regexp-npm-0.0.1-7eb3cb9cd0-abb22ce7a1.zip/node_modules/escape-regexp/",\ + "packageDependencies": [\ + ["escape-regexp", "npm:0.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["escape-string-regexp", [\ + ["npm:1.0.5", {\ + "packageLocation": "./.yarn/cache/escape-string-regexp-npm-1.0.5-3284de402f-6092fda75c.zip/node_modules/escape-string-regexp/",\ + "packageDependencies": [\ + ["escape-string-regexp", "npm:1.0.5"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/escape-string-regexp-npm-4.0.0-4b531d8d59-98b48897d9.zip/node_modules/escape-string-regexp/",\ + "packageDependencies": [\ + ["escape-string-regexp", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/escape-string-regexp-npm-5.0.0-a663e825ce-20daabe197.zip/node_modules/escape-string-regexp/",\ + "packageDependencies": [\ + ["escape-string-regexp", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["esprima", [\ + ["npm:4.0.1", {\ + "packageLocation": "./.yarn/cache/esprima-npm-4.0.1-1084e98778-f1d3c622ad.zip/node_modules/esprima/",\ + "packageDependencies": [\ + ["esprima", "npm:4.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["estree-walker", [\ + ["npm:2.0.2", {\ + "packageLocation": "./.yarn/cache/estree-walker-npm-2.0.2-dfab42f65c-b02109c5d4.zip/node_modules/estree-walker/",\ + "packageDependencies": [\ + ["estree-walker", "npm:2.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["event-target-shim", [\ + ["npm:5.0.1", {\ + "packageLocation": "./.yarn/cache/event-target-shim-npm-5.0.1-cb48709025-49ff46c3a7.zip/node_modules/event-target-shim/",\ + "packageDependencies": [\ + ["event-target-shim", "npm:5.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["eventemitter3", [\ + ["npm:4.0.7", {\ + "packageLocation": "./.yarn/unplugged/eventemitter3-npm-4.0.7-7afcdd74ae/node_modules/eventemitter3/",\ + "packageDependencies": [\ + ["eventemitter3", "npm:4.0.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.0.1", {\ + "packageLocation": "./.yarn/cache/eventemitter3-npm-5.0.1-5e423b7df3-ac6423ec31.zip/node_modules/eventemitter3/",\ + "packageDependencies": [\ + ["eventemitter3", "npm:5.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["events", [\ + ["npm:3.3.0", {\ + "packageLocation": "./.yarn/cache/events-npm-3.3.0-c280bc7e48-a3d47e285e.zip/node_modules/events/",\ + "packageDependencies": [\ + ["events", "npm:3.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["execa", [\ + ["npm:0.7.0", {\ + "packageLocation": "./.yarn/cache/execa-npm-0.7.0-3f4e53d884-7c1721de38.zip/node_modules/execa/",\ + "packageDependencies": [\ + ["cross-spawn", "npm:5.1.0"],\ + ["execa", "npm:0.7.0"],\ + ["get-stream", "npm:3.0.0"],\ + ["is-stream", "npm:1.1.0"],\ + ["npm-run-path", "npm:2.0.2"],\ + ["p-finally", "npm:1.0.0"],\ + ["signal-exit", "npm:3.0.7"],\ + ["strip-eof", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.1.1", {\ + "packageLocation": "./.yarn/cache/execa-npm-5.1.1-191347acf5-8ada91f2d7.zip/node_modules/execa/",\ + "packageDependencies": [\ + ["cross-spawn", "npm:7.0.3"],\ + ["execa", "npm:5.1.1"],\ + ["get-stream", "npm:6.0.1"],\ + ["human-signals", "npm:2.1.0"],\ + ["is-stream", "npm:2.0.1"],\ + ["merge-stream", "npm:2.0.0"],\ + ["npm-run-path", "npm:4.0.1"],\ + ["onetime", "npm:5.1.2"],\ + ["signal-exit", "npm:3.0.7"],\ + ["strip-final-newline", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:6.1.0", {\ + "packageLocation": "./.yarn/cache/execa-npm-6.1.0-be1d7f323b-669437011a.zip/node_modules/execa/",\ + "packageDependencies": [\ + ["cross-spawn", "npm:7.0.3"],\ + ["execa", "npm:6.1.0"],\ + ["get-stream", "npm:6.0.1"],\ + ["human-signals", "npm:3.0.1"],\ + ["is-stream", "npm:3.0.0"],\ + ["merge-stream", "npm:2.0.0"],\ + ["npm-run-path", "npm:5.1.0"],\ + ["onetime", "npm:6.0.0"],\ + ["signal-exit", "npm:3.0.7"],\ + ["strip-final-newline", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["executable", [\ + ["npm:4.1.1", {\ + "packageLocation": "./.yarn/cache/executable-npm-4.1.1-c06d32cd1b-f01927ce59.zip/node_modules/executable/",\ + "packageDependencies": [\ + ["executable", "npm:4.1.1"],\ + ["pify", "npm:2.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["executioner", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/executioner-npm-2.0.1-5f8456f5f3-327d1cd4d3.zip/node_modules/executioner/",\ + "packageDependencies": [\ + ["executioner", "npm:2.0.1"],\ + ["mixly", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["exponential-backoff", [\ + ["npm:3.1.1", {\ + "packageLocation": "./.yarn/cache/exponential-backoff-npm-3.1.1-04df458b30-2d9bbb6473.zip/node_modules/exponential-backoff/",\ + "packageDependencies": [\ + ["exponential-backoff", "npm:3.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ext", [\ + ["npm:1.7.0", {\ + "packageLocation": "./.yarn/cache/ext-npm-1.7.0-580588ab93-666a135980.zip/node_modules/ext/",\ + "packageDependencies": [\ + ["ext", "npm:1.7.0"],\ + ["type", "npm:2.7.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ext-list", [\ + ["npm:2.2.2", {\ + "packageLocation": "./.yarn/cache/ext-list-npm-2.2.2-0f25ac20cc-fe69fedbef.zip/node_modules/ext-list/",\ + "packageDependencies": [\ + ["ext-list", "npm:2.2.2"],\ + ["mime-db", "npm:1.52.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ext-name", [\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/ext-name-npm-5.0.0-b0182711de-f598269bd5.zip/node_modules/ext-name/",\ + "packageDependencies": [\ + ["ext-list", "npm:2.2.2"],\ + ["ext-name", "npm:5.0.0"],\ + ["sort-keys-length", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["extend-shallow", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/extend-shallow-npm-2.0.1-e6ef52b29c-8fb58d9d7a.zip/node_modules/extend-shallow/",\ + "packageDependencies": [\ + ["extend-shallow", "npm:2.0.1"],\ + ["is-extendable", "npm:0.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["extsprintf", [\ + ["npm:1.3.0", {\ + "packageLocation": "./.yarn/cache/extsprintf-npm-1.3.0-61a92b324c-26967d6c7e.zip/node_modules/extsprintf/",\ + "packageDependencies": [\ + ["extsprintf", "npm:1.3.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.4.1", {\ + "packageLocation": "./.yarn/cache/extsprintf-npm-1.4.1-140b2f27ab-bfd6d55f3c.zip/node_modules/extsprintf/",\ + "packageDependencies": [\ + ["extsprintf", "npm:1.4.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fast-blurhash", [\ + ["npm:1.1.2", {\ + "packageLocation": "./.yarn/cache/fast-blurhash-npm-1.1.2-8242eade59-79b97c5f80.zip/node_modules/fast-blurhash/",\ + "packageDependencies": [\ + ["fast-blurhash", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fast-deep-equal", [\ + ["npm:3.1.3", {\ + "packageLocation": "./.yarn/cache/fast-deep-equal-npm-3.1.3-790edcfcf5-e21a9d8d84.zip/node_modules/fast-deep-equal/",\ + "packageDependencies": [\ + ["fast-deep-equal", "npm:3.1.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fast-fifo", [\ + ["npm:1.3.0", {\ + "packageLocation": "./.yarn/cache/fast-fifo-npm-1.3.0-bd15200000-edc589b818.zip/node_modules/fast-fifo/",\ + "packageDependencies": [\ + ["fast-fifo", "npm:1.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fast-glob", [\ + ["npm:3.3.1", {\ + "packageLocation": "./.yarn/cache/fast-glob-npm-3.3.1-8045ff8f4d-51bcd15472.zip/node_modules/fast-glob/",\ + "packageDependencies": [\ + ["@nodelib/fs.stat", "npm:2.0.5"],\ + ["@nodelib/fs.walk", "npm:1.2.8"],\ + ["fast-glob", "npm:3.3.1"],\ + ["glob-parent", "npm:5.1.2"],\ + ["merge2", "npm:1.4.1"],\ + ["micromatch", "npm:4.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fast-json-stable-stringify", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/fast-json-stable-stringify-npm-2.1.0-02e8905fda-2c20055c1f.zip/node_modules/fast-json-stable-stringify/",\ + "packageDependencies": [\ + ["fast-json-stable-stringify", "npm:2.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fast-xml-builder", [\ + ["npm:1.2.0", {\ + "packageLocation": "./.yarn/cache/fast-xml-builder-npm-1.2.0-7008dbf494-5948add779.zip/node_modules/fast-xml-builder/",\ + "packageDependencies": [\ + ["fast-xml-builder", "npm:1.2.0"],\ + ["path-expression-matcher", "npm:1.5.0"],\ + ["xml-naming", "npm:0.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fast-xml-parser", [\ + ["npm:3.21.1", {\ + "packageLocation": "./.yarn/cache/fast-xml-parser-npm-3.21.1-d651ad1d26-f9adbd8ae7.zip/node_modules/fast-xml-parser/",\ + "packageDependencies": [\ + ["fast-xml-parser", "npm:3.21.1"],\ + ["strnum", "npm:1.0.5"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.2.7", {\ + "packageLocation": "./.yarn/cache/fast-xml-parser-npm-4.2.7-c57a954c1f-012febec63.zip/node_modules/fast-xml-parser/",\ + "packageDependencies": [\ + ["fast-xml-parser", "npm:4.2.7"],\ + ["strnum", "npm:1.0.5"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.7.2", {\ + "packageLocation": "./.yarn/cache/fast-xml-parser-npm-5.7.2-960de42682-7f32d77127.zip/node_modules/fast-xml-parser/",\ + "packageDependencies": [\ + ["@nodable/entities", "npm:2.1.0"],\ + ["fast-xml-builder", "npm:1.2.0"],\ + ["fast-xml-parser", "npm:5.7.2"],\ + ["path-expression-matcher", "npm:1.5.0"],\ + ["strnum", "npm:2.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fastq", [\ + ["npm:1.15.0", {\ + "packageLocation": "./.yarn/cache/fastq-npm-1.15.0-1013f6514e-67c01b1c97.zip/node_modules/fastq/",\ + "packageDependencies": [\ + ["fastq", "npm:1.15.0"],\ + ["reusify", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fd-slicer", [\ + ["npm:1.1.0", {\ + "packageLocation": "./.yarn/cache/fd-slicer-npm-1.1.0-3cade0050a-db3e34fa48.zip/node_modules/fd-slicer/",\ + "packageDependencies": [\ + ["fd-slicer", "npm:1.1.0"],\ + ["pend", "npm:1.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fdir", [\ + ["npm:6.4.6", {\ + "packageLocation": "./.yarn/cache/fdir-npm-6.4.6-52922d4c25-c186ba387e.zip/node_modules/fdir/",\ + "packageDependencies": [\ + ["fdir", "npm:6.4.6"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["npm:6.5.0", {\ + "packageLocation": "./.yarn/cache/fdir-npm-6.5.0-8814a0dec7-14ca1c9f0a.zip/node_modules/fdir/",\ + "packageDependencies": [\ + ["fdir", "npm:6.5.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:102914a73b14bffc325c2cdf701d5ae063b57309ea75829f709b4273a7ea0d0e11784f2d6f2635e156595ab235d9a24869844d54ab73f4ad81d3a7b01b185214#npm:6.5.0", {\ + "packageLocation": "./.yarn/__virtual__/fdir-virtual-c9b12f80ea/0/cache/fdir-npm-6.5.0-8814a0dec7-14ca1c9f0a.zip/node_modules/fdir/",\ + "packageDependencies": [\ + ["@types/picomatch", null],\ + ["fdir", "virtual:102914a73b14bffc325c2cdf701d5ae063b57309ea75829f709b4273a7ea0d0e11784f2d6f2635e156595ab235d9a24869844d54ab73f4ad81d3a7b01b185214#npm:6.5.0"],\ + ["picomatch", "npm:4.0.4"]\ + ],\ + "packagePeers": [\ + "@types/picomatch",\ + "picomatch"\ + ],\ + "linkType": "HARD"\ + }],\ + ["virtual:d4e4bcf80e67f9de0540c123c7c4882e34dce6a8ba807a0a834f267f9132ee6bd264e69a49c6203aa89877ed3a5a5d633bfa002384881be452cc3a2d2fbcce0b#npm:6.4.6", {\ + "packageLocation": "./.yarn/__virtual__/fdir-virtual-895faf82c5/0/cache/fdir-npm-6.4.6-52922d4c25-c186ba387e.zip/node_modules/fdir/",\ + "packageDependencies": [\ + ["@types/picomatch", null],\ + ["fdir", "virtual:d4e4bcf80e67f9de0540c123c7c4882e34dce6a8ba807a0a834f267f9132ee6bd264e69a49c6203aa89877ed3a5a5d633bfa002384881be452cc3a2d2fbcce0b#npm:6.4.6"],\ + ["picomatch", "npm:4.0.2"]\ + ],\ + "packagePeers": [\ + "@types/picomatch",\ + "picomatch"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["feed", [\ + ["npm:4.2.2", {\ + "packageLocation": "./.yarn/cache/feed-npm-4.2.2-0c45e7a1e4-6aeee26b92.zip/node_modules/feed/",\ + "packageDependencies": [\ + ["feed", "npm:4.2.2"],\ + ["xml-js", "npm:1.6.11"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fengari", [\ + ["npm:0.1.5", {\ + "packageLocation": "./.yarn/cache/fengari-npm-0.1.5-b0da884855-4357facc3f.zip/node_modules/fengari/",\ + "packageDependencies": [\ + ["fengari", "npm:0.1.5"],\ + ["readline-sync", "npm:1.4.10"],\ + ["sprintf-js", "npm:1.1.3"],\ + ["tmp", "npm:0.2.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fetch-blob", [\ + ["npm:3.2.0", {\ + "packageLocation": "./.yarn/cache/fetch-blob-npm-3.2.0-28e01becfc-5264ecceb5.zip/node_modules/fetch-blob/",\ + "packageDependencies": [\ + ["fetch-blob", "npm:3.2.0"],\ + ["node-domexception", "npm:1.0.0"],\ + ["web-streams-polyfill", "npm:3.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["file-type", [\ + ["npm:17.1.6", {\ + "packageLocation": "./.yarn/cache/file-type-npm-17.1.6-5b81491f99-47c69b4046.zip/node_modules/file-type/",\ + "packageDependencies": [\ + ["file-type", "npm:17.1.6"],\ + ["readable-web-to-node-stream", "npm:3.0.2"],\ + ["strtok3", "npm:7.0.0"],\ + ["token-types", "npm:5.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:22.0.1", {\ + "packageLocation": "./.yarn/cache/file-type-npm-22.0.1-bbaec1ed8a-cfacf44cb0.zip/node_modules/file-type/",\ + "packageDependencies": [\ + ["@tokenizer/inflate", "npm:0.4.1"],\ + ["file-type", "npm:22.0.1"],\ + ["strtok3", "npm:10.3.5"],\ + ["token-types", "npm:6.1.2"],\ + ["uint8array-extras", "npm:1.5.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.9.0", {\ + "packageLocation": "./.yarn/cache/file-type-npm-3.9.0-fec2c20533-1c8bc99bbb.zip/node_modules/file-type/",\ + "packageDependencies": [\ + ["file-type", "npm:3.9.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.2.0", {\ + "packageLocation": "./.yarn/cache/file-type-npm-5.2.0-d8b09d0b59-73b44eaba7.zip/node_modules/file-type/",\ + "packageDependencies": [\ + ["file-type", "npm:5.2.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:6.2.0", {\ + "packageLocation": "./.yarn/cache/file-type-npm-6.2.0-0b469e4b41-c7214c3cf6.zip/node_modules/file-type/",\ + "packageDependencies": [\ + ["file-type", "npm:6.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["filelist", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/cache/filelist-npm-1.0.4-3a835ae0a7-4b436fa944.zip/node_modules/filelist/",\ + "packageDependencies": [\ + ["filelist", "npm:1.0.4"],\ + ["minimatch", "npm:5.1.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["filename-reserved-regex", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/filename-reserved-regex-npm-3.0.0-2ee763333c-1803e19ce6.zip/node_modules/filename-reserved-regex/",\ + "packageDependencies": [\ + ["filename-reserved-regex", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["filenamify", [\ + ["npm:5.1.1", {\ + "packageLocation": "./.yarn/cache/filenamify-npm-5.1.1-2c05749153-55a7ed0858.zip/node_modules/filenamify/",\ + "packageDependencies": [\ + ["filename-reserved-regex", "npm:3.0.0"],\ + ["filenamify", "npm:5.1.1"],\ + ["strip-outer", "npm:2.0.0"],\ + ["trim-repeated", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fill-range", [\ + ["npm:7.0.1", {\ + "packageLocation": "./.yarn/cache/fill-range-npm-7.0.1-b8b1817caa-e260f7592f.zip/node_modules/fill-range/",\ + "packageDependencies": [\ + ["fill-range", "npm:7.0.1"],\ + ["to-regex-range", "npm:5.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["find-up", [\ + ["npm:4.1.0", {\ + "packageLocation": "./.yarn/cache/find-up-npm-4.1.0-c3ccf8d855-4c172680e8.zip/node_modules/find-up/",\ + "packageDependencies": [\ + ["find-up", "npm:4.1.0"],\ + ["locate-path", "npm:5.0.0"],\ + ["path-exists", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["find-versions", [\ + ["npm:5.1.0", {\ + "packageLocation": "./.yarn/cache/find-versions-npm-5.1.0-357e2813b4-680bdb0081.zip/node_modules/find-versions/",\ + "packageDependencies": [\ + ["find-versions", "npm:5.1.0"],\ + ["semver-regex", "npm:4.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fix-dts-default-cjs-exports", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/fix-dts-default-cjs-exports-npm-1.0.1-421fe0399f-3324418bb6.zip/node_modules/fix-dts-default-cjs-exports/",\ + "packageDependencies": [\ + ["fix-dts-default-cjs-exports", "npm:1.0.1"],\ + ["magic-string", "npm:0.30.21"],\ + ["mlly", "npm:1.8.2"],\ + ["rollup", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fluent-ffmpeg", [\ + ["npm:2.1.2", {\ + "packageLocation": "./.yarn/cache/fluent-ffmpeg-npm-2.1.2-692c218f68-a6810fefd4.zip/node_modules/fluent-ffmpeg/",\ + "packageDependencies": [\ + ["async", "npm:3.2.4"],\ + ["fluent-ffmpeg", "npm:2.1.2"],\ + ["which", "npm:1.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["focus-trap", [\ + ["npm:7.5.2", {\ + "packageLocation": "./.yarn/cache/focus-trap-npm-7.5.2-1bfe4333ec-9f51e15e1b.zip/node_modules/focus-trap/",\ + "packageDependencies": [\ + ["focus-trap", "npm:7.5.2"],\ + ["tabbable", "npm:6.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["focus-trap-vue", [\ + ["npm:4.0.2", {\ + "packageLocation": "./.yarn/cache/focus-trap-vue-npm-4.0.2-01b014ebdc-8335bff74a.zip/node_modules/focus-trap-vue/",\ + "packageDependencies": [\ + ["focus-trap-vue", "npm:4.0.2"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:4.0.2", {\ + "packageLocation": "./.yarn/__virtual__/focus-trap-vue-virtual-ec380f7e8f/0/cache/focus-trap-vue-npm-4.0.2-01b014ebdc-8335bff74a.zip/node_modules/focus-trap-vue/",\ + "packageDependencies": [\ + ["@types/focus-trap", null],\ + ["@types/vue", null],\ + ["focus-trap", "npm:7.5.2"],\ + ["focus-trap-vue", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:4.0.2"],\ + ["vue", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:3.5.34"]\ + ],\ + "packagePeers": [\ + "@types/focus-trap",\ + "@types/vue",\ + "focus-trap",\ + "vue"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["follow-redirects", [\ + ["npm:1.15.2", {\ + "packageLocation": "./.yarn/cache/follow-redirects-npm-1.15.2-1ec1dd82be-8be0d39919.zip/node_modules/follow-redirects/",\ + "packageDependencies": [\ + ["follow-redirects", "npm:1.15.2"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:39e5c1e79ea63134f0cf339f4463df92854aaf708a45210afd29a0b4b9f67f95b34a1abbcabaae6d0033ad99a1d5f690ab51ed8e5d3283b87ccbc3a9ab3ec05f#npm:1.15.2", {\ + "packageLocation": "./.yarn/__virtual__/follow-redirects-virtual-2c858e005e/0/cache/follow-redirects-npm-1.15.2-1ec1dd82be-8be0d39919.zip/node_modules/follow-redirects/",\ + "packageDependencies": [\ + ["@types/debug", null],\ + ["debug", null],\ + ["follow-redirects", "virtual:39e5c1e79ea63134f0cf339f4463df92854aaf708a45210afd29a0b4b9f67f95b34a1abbcabaae6d0033ad99a1d5f690ab51ed8e5d3283b87ccbc3a9ab3ec05f#npm:1.15.2"]\ + ],\ + "packagePeers": [\ + "@types/debug",\ + "debug"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["foreground-child", [\ + ["npm:3.1.1", {\ + "packageLocation": "./.yarn/cache/foreground-child-npm-3.1.1-77e78ed774-087edd4485.zip/node_modules/foreground-child/",\ + "packageDependencies": [\ + ["cross-spawn", "npm:7.0.3"],\ + ["foreground-child", "npm:3.1.1"],\ + ["signal-exit", "npm:4.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["formdata-polyfill", [\ + ["npm:4.0.10", {\ + "packageLocation": "./.yarn/cache/formdata-polyfill-npm-4.0.10-e03013c013-9b5001d2ed.zip/node_modules/formdata-polyfill/",\ + "packageDependencies": [\ + ["fetch-blob", "npm:3.2.0"],\ + ["formdata-polyfill", "npm:4.0.10"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["formidable", [\ + ["npm:2.1.2", {\ + "packageLocation": "./.yarn/cache/formidable-npm-2.1.2-40ba18d67f-d385180e04.zip/node_modules/formidable/",\ + "packageDependencies": [\ + ["dezalgo", "npm:1.0.4"],\ + ["formidable", "npm:2.1.2"],\ + ["hexoid", "npm:1.0.0"],\ + ["once", "npm:1.4.0"],\ + ["qs", "npm:6.11.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fresh", [\ + ["npm:0.5.2", {\ + "packageLocation": "./.yarn/cache/fresh-npm-0.5.2-ad2bb4c0a2-64c88e489b.zip/node_modules/fresh/",\ + "packageDependencies": [\ + ["fresh", "npm:0.5.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fs-constants", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/fs-constants-npm-1.0.0-59576b2177-18f5b71837.zip/node_modules/fs-constants/",\ + "packageDependencies": [\ + ["fs-constants", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fs-extra", [\ + ["npm:7.0.1", {\ + "packageLocation": "./.yarn/cache/fs-extra-npm-7.0.1-b33a5e53e9-3fc6e56ba2.zip/node_modules/fs-extra/",\ + "packageDependencies": [\ + ["fs-extra", "npm:7.0.1"],\ + ["graceful-fs", "npm:4.2.11"],\ + ["jsonfile", "npm:4.0.0"],\ + ["universalify", "npm:0.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:8.1.0", {\ + "packageLocation": "./.yarn/cache/fs-extra-npm-8.1.0-197473387f-6fb12449f5.zip/node_modules/fs-extra/",\ + "packageDependencies": [\ + ["fs-extra", "npm:8.1.0"],\ + ["graceful-fs", "npm:4.2.11"],\ + ["jsonfile", "npm:4.0.0"],\ + ["universalify", "npm:0.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fs-minipass", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/fs-minipass-npm-2.1.0-501ef87306-03191781e9.zip/node_modules/fs-minipass/",\ + "packageDependencies": [\ + ["fs-minipass", "npm:2.1.0"],\ + ["minipass", "npm:3.3.6"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.2", {\ + "packageLocation": "./.yarn/cache/fs-minipass-npm-3.0.2-a27ef235f5-1c071b5b8f.zip/node_modules/fs-minipass/",\ + "packageDependencies": [\ + ["fs-minipass", "npm:3.0.2"],\ + ["minipass", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fs.realpath", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/fs.realpath-npm-1.0.0-c8f05d8126-e703107c28.zip/node_modules/fs.realpath/",\ + "packageDependencies": [\ + ["fs.realpath", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fsevents", [\ + ["patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1", {\ + "packageLocation": "./.yarn/unplugged/fsevents-patch-19706e7e35/node_modules/fsevents/",\ + "packageDependencies": [\ + ["fsevents", "patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1"],\ + ["node-gyp", "npm:9.4.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1", {\ + "packageLocation": "./.yarn/unplugged/fsevents-patch-6b67494872/node_modules/fsevents/",\ + "packageDependencies": [\ + ["fsevents", "patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1"],\ + ["node-gyp", "npm:9.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["fulcon", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/fulcon-npm-1.0.2-7dc7ea4b0e-212a4b4d69.zip/node_modules/fulcon/",\ + "packageDependencies": [\ + ["fulcon", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["function-bind", [\ + ["npm:1.1.1", {\ + "packageLocation": "./.yarn/cache/function-bind-npm-1.1.1-b56b322ae9-d83f296803.zip/node_modules/function-bind/",\ + "packageDependencies": [\ + ["function-bind", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["gauge", [\ + ["npm:4.0.4", {\ + "packageLocation": "./.yarn/cache/gauge-npm-4.0.4-8f878385e9-09535dd53b.zip/node_modules/gauge/",\ + "packageDependencies": [\ + ["aproba", "npm:2.0.0"],\ + ["color-support", "npm:1.1.3"],\ + ["console-control-strings", "npm:1.1.0"],\ + ["gauge", "npm:4.0.4"],\ + ["has-unicode", "npm:2.0.1"],\ + ["signal-exit", "npm:3.0.7"],\ + ["string-width", "npm:4.2.3"],\ + ["strip-ansi", "npm:6.0.1"],\ + ["wide-align", "npm:1.1.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["generic-pool", [\ + ["npm:3.9.0", {\ + "packageLocation": "./.yarn/cache/generic-pool-npm-3.9.0-21fff1a77f-3c632d30a6.zip/node_modules/generic-pool/",\ + "packageDependencies": [\ + ["generic-pool", "npm:3.9.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["get-caller-file", [\ + ["npm:2.0.5", {\ + "packageLocation": "./.yarn/cache/get-caller-file-npm-2.0.5-80e8a86305-b9769a836d.zip/node_modules/get-caller-file/",\ + "packageDependencies": [\ + ["get-caller-file", "npm:2.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["get-intrinsic", [\ + ["npm:1.2.1", {\ + "packageLocation": "./.yarn/cache/get-intrinsic-npm-1.2.1-ae857fd610-aee6318520.zip/node_modules/get-intrinsic/",\ + "packageDependencies": [\ + ["function-bind", "npm:1.1.1"],\ + ["get-intrinsic", "npm:1.2.1"],\ + ["has", "npm:1.0.3"],\ + ["has-proto", "npm:1.0.1"],\ + ["has-symbols", "npm:1.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["get-paths", [\ + ["npm:0.0.7", {\ + "packageLocation": "./.yarn/cache/get-paths-npm-0.0.7-8e806f47a2-6ad33c3137.zip/node_modules/get-paths/",\ + "packageDependencies": [\ + ["get-paths", "npm:0.0.7"],\ + ["pify", "npm:4.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["get-stream", [\ + ["npm:2.3.1", {\ + "packageLocation": "./.yarn/cache/get-stream-npm-2.3.1-1755f3cab9-712738e6a3.zip/node_modules/get-stream/",\ + "packageDependencies": [\ + ["get-stream", "npm:2.3.1"],\ + ["object-assign", "npm:4.1.1"],\ + ["pinkie-promise", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/get-stream-npm-3.0.0-ca0b13ddbe-de14fbb3b4.zip/node_modules/get-stream/",\ + "packageDependencies": [\ + ["get-stream", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.2.0", {\ + "packageLocation": "./.yarn/cache/get-stream-npm-5.2.0-2cfd3b452b-13a73148dc.zip/node_modules/get-stream/",\ + "packageDependencies": [\ + ["get-stream", "npm:5.2.0"],\ + ["pump", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:6.0.1", {\ + "packageLocation": "./.yarn/cache/get-stream-npm-6.0.1-83e51a4642-781266d297.zip/node_modules/get-stream/",\ + "packageDependencies": [\ + ["get-stream", "npm:6.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:9.0.1", {\ + "packageLocation": "./.yarn/cache/get-stream-npm-9.0.1-2e58b883c0-ce56e6db6b.zip/node_modules/get-stream/",\ + "packageDependencies": [\ + ["@sec-ant/readable-stream", "npm:0.4.1"],\ + ["get-stream", "npm:9.0.1"],\ + ["is-stream", "npm:4.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["getpass", [\ + ["npm:0.1.7", {\ + "packageLocation": "./.yarn/cache/getpass-npm-0.1.7-519164a3be-ab18d55661.zip/node_modules/getpass/",\ + "packageDependencies": [\ + ["assert-plus", "npm:1.0.0"],\ + ["getpass", "npm:0.1.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["glob", [\ + ["npm:10.3.3", {\ + "packageLocation": "./.yarn/cache/glob-npm-10.3.3-2d9abea8c7-0d1a59dff5.zip/node_modules/glob/",\ + "packageDependencies": [\ + ["foreground-child", "npm:3.1.1"],\ + ["glob", "npm:10.3.3"],\ + ["jackspeak", "npm:2.2.2"],\ + ["minimatch", "npm:9.0.3"],\ + ["minipass", "npm:7.0.2"],\ + ["path-scurry", "npm:1.10.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:13.0.6", {\ + "packageLocation": "./.yarn/cache/glob-npm-13.0.6-864eb0cece-201ad69e5f.zip/node_modules/glob/",\ + "packageDependencies": [\ + ["glob", "npm:13.0.6"],\ + ["minimatch", "npm:10.2.5"],\ + ["minipass", "npm:7.1.3"],\ + ["path-scurry", "npm:2.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.2.3", {\ + "packageLocation": "./.yarn/cache/glob-npm-7.2.3-2d866d17a5-59452a9202.zip/node_modules/glob/",\ + "packageDependencies": [\ + ["fs.realpath", "npm:1.0.0"],\ + ["glob", "npm:7.2.3"],\ + ["inflight", "npm:1.0.6"],\ + ["inherits", "npm:2.0.4"],\ + ["minimatch", "npm:3.1.2"],\ + ["once", "npm:1.4.0"],\ + ["path-is-absolute", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:8.1.0", {\ + "packageLocation": "./.yarn/cache/glob-npm-8.1.0-65f64af8b1-9aab1c75eb.zip/node_modules/glob/",\ + "packageDependencies": [\ + ["fs.realpath", "npm:1.0.0"],\ + ["glob", "npm:8.1.0"],\ + ["inflight", "npm:1.0.6"],\ + ["inherits", "npm:2.0.4"],\ + ["minimatch", "npm:5.1.6"],\ + ["once", "npm:1.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["glob-parent", [\ + ["npm:5.1.2", {\ + "packageLocation": "./.yarn/cache/glob-parent-npm-5.1.2-021ab32634-32cd106ce8.zip/node_modules/glob-parent/",\ + "packageDependencies": [\ + ["glob-parent", "npm:5.1.2"],\ + ["is-glob", "npm:4.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["got", [\ + ["npm:11.8.5", {\ + "packageLocation": "./.yarn/cache/got-npm-11.8.5-787b5e3116-8e3f1a886b.zip/node_modules/got/",\ + "packageDependencies": [\ + ["@sindresorhus/is", "npm:4.6.0"],\ + ["@szmarczak/http-timer", "npm:4.0.6"],\ + ["@types/cacheable-request", "npm:6.0.3"],\ + ["@types/responselike", "npm:1.0.0"],\ + ["cacheable-lookup", "npm:5.0.4"],\ + ["cacheable-request", "npm:7.0.4"],\ + ["decompress-response", "npm:6.0.0"],\ + ["got", "npm:11.8.5"],\ + ["http2-wrapper", "npm:1.0.3"],\ + ["lowercase-keys", "npm:2.0.0"],\ + ["p-cancelable", "npm:2.1.1"],\ + ["responselike", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:11.8.6", {\ + "packageLocation": "./.yarn/cache/got-npm-11.8.6-89e7cd5d67-a30c74029d.zip/node_modules/got/",\ + "packageDependencies": [\ + ["@sindresorhus/is", "npm:4.6.0"],\ + ["@szmarczak/http-timer", "npm:4.0.6"],\ + ["@types/cacheable-request", "npm:6.0.3"],\ + ["@types/responselike", "npm:1.0.0"],\ + ["cacheable-lookup", "npm:5.0.4"],\ + ["cacheable-request", "npm:7.0.4"],\ + ["decompress-response", "npm:6.0.0"],\ + ["got", "npm:11.8.6"],\ + ["http2-wrapper", "npm:1.0.3"],\ + ["lowercase-keys", "npm:2.0.0"],\ + ["p-cancelable", "npm:2.1.1"],\ + ["responselike", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:15.0.5", {\ + "packageLocation": "./.yarn/cache/got-npm-15.0.5-a837c81fb5-b45f3f8d8e.zip/node_modules/got/",\ + "packageDependencies": [\ + ["@sindresorhus/is", "npm:8.0.0"],\ + ["byte-counter", "npm:0.1.0"],\ + ["cacheable-lookup", "npm:7.0.0"],\ + ["cacheable-request", "npm:13.0.19"],\ + ["chunk-data", "npm:0.1.0"],\ + ["decompress-response", "npm:10.0.0"],\ + ["got", "npm:15.0.5"],\ + ["http2-wrapper", "npm:2.2.1"],\ + ["keyv", "npm:5.6.0"],\ + ["lowercase-keys", "npm:4.0.1"],\ + ["responselike", "npm:4.0.2"],\ + ["type-fest", "npm:5.6.0"],\ + ["uint8array-extras", "npm:1.5.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["graceful-fs", [\ + ["npm:4.2.11", {\ + "packageLocation": "./.yarn/cache/graceful-fs-npm-4.2.11-24bb648a68-bf152d0ed1.zip/node_modules/graceful-fs/",\ + "packageDependencies": [\ + ["graceful-fs", "npm:4.2.11"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["gsap", [\ + ["npm:3.12.2", {\ + "packageLocation": "./.yarn/cache/gsap-npm-3.12.2-0e0850bbe0-9a8a1605aa.zip/node_modules/gsap/",\ + "packageDependencies": [\ + ["gsap", "npm:3.12.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["gunzip-maybe", [\ + ["npm:1.4.2", {\ + "packageLocation": "./.yarn/cache/gunzip-maybe-npm-1.4.2-97df376cb9-82a4eadb61.zip/node_modules/gunzip-maybe/",\ + "packageDependencies": [\ + ["browserify-zlib", "npm:0.1.4"],\ + ["gunzip-maybe", "npm:1.4.2"],\ + ["is-deflate", "npm:1.0.0"],\ + ["is-gzip", "npm:1.0.0"],\ + ["peek-stream", "npm:1.1.3"],\ + ["pumpify", "npm:1.5.1"],\ + ["through2", "npm:2.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["hammerjs", [\ + ["npm:2.0.8", {\ + "packageLocation": "./.yarn/cache/hammerjs-npm-2.0.8-f656ba2573-9155d056f2.zip/node_modules/hammerjs/",\ + "packageDependencies": [\ + ["hammerjs", "npm:2.0.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["happy-dom", [\ + ["npm:20.9.0", {\ + "packageLocation": "./.yarn/cache/happy-dom-npm-20.9.0-480a4fb09e-5b54d641b4.zip/node_modules/happy-dom/",\ + "packageDependencies": [\ + ["@types/node", "npm:25.6.2"],\ + ["@types/whatwg-mimetype", "npm:3.0.2"],\ + ["@types/ws", "npm:8.18.1"],\ + ["entities", "npm:7.0.1"],\ + ["happy-dom", "npm:20.9.0"],\ + ["whatwg-mimetype", "npm:3.0.0"],\ + ["ws", "virtual:480a4fb09e5db13ca37db95ab0b87fa859e5f5a8c839e4ec91bb8bc7cd4c00ab4eb257765778c4c33e94967fef678e7ad3b289430644113a0013a853a4a6552f#npm:8.20.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["has", [\ + ["npm:1.0.3", {\ + "packageLocation": "./.yarn/cache/has-npm-1.0.3-b7f00631c1-a449f3185b.zip/node_modules/has/",\ + "packageDependencies": [\ + ["function-bind", "npm:1.1.1"],\ + ["has", "npm:1.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["has-flag", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/has-flag-npm-3.0.0-16ac11fe05-4a15638b45.zip/node_modules/has-flag/",\ + "packageDependencies": [\ + ["has-flag", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/has-flag-npm-4.0.0-32af9f0536-261a135703.zip/node_modules/has-flag/",\ + "packageDependencies": [\ + ["has-flag", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["has-proto", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/has-proto-npm-1.0.1-631ea9d820-eab2ab0ed1.zip/node_modules/has-proto/",\ + "packageDependencies": [\ + ["has-proto", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["has-symbols", [\ + ["npm:1.0.3", {\ + "packageLocation": "./.yarn/cache/has-symbols-npm-1.0.3-1986bff2c4-464f97a820.zip/node_modules/has-symbols/",\ + "packageDependencies": [\ + ["has-symbols", "npm:1.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["has-tostringtag", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/has-tostringtag-npm-1.0.0-b1fcf3ab55-95546e7132.zip/node_modules/has-tostringtag/",\ + "packageDependencies": [\ + ["has-symbols", "npm:1.0.3"],\ + ["has-tostringtag", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["has-unicode", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/has-unicode-npm-2.0.1-893adb4747-041b4293ad.zip/node_modules/has-unicode/",\ + "packageDependencies": [\ + ["has-unicode", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["hexoid", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/hexoid-npm-1.0.0-2274609209-f2271b8b6b.zip/node_modules/hexoid/",\ + "packageDependencies": [\ + ["hexoid", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["highlight.js", [\ + ["npm:10.7.3", {\ + "packageLocation": "./.yarn/cache/highlight.js-npm-10.7.3-247e67d5c0-db8d10a541.zip/node_modules/highlight.js/",\ + "packageDependencies": [\ + ["highlight.js", "npm:10.7.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["hpagent", [\ + ["npm:0.1.2", {\ + "packageLocation": "./.yarn/cache/hpagent-npm-0.1.2-f4fe59bad9-bd033b3700.zip/node_modules/hpagent/",\ + "packageDependencies": [\ + ["hpagent", "npm:0.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["html-encoding-sniffer", [\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/html-encoding-sniffer-npm-4.0.0-5f6627070d-e86efd4932.zip/node_modules/html-encoding-sniffer/",\ + "packageDependencies": [\ + ["html-encoding-sniffer", "npm:4.0.0"],\ + ["whatwg-encoding", "npm:3.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["html-entities", [\ + ["npm:2.3.2", {\ + "packageLocation": "./.yarn/cache/html-entities-npm-2.3.2-366c4c257a-c2a3ae9553.zip/node_modules/html-entities/",\ + "packageDependencies": [\ + ["html-entities", "npm:2.3.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["htmlparser2", [\ + ["npm:3.10.1", {\ + "packageLocation": "./.yarn/cache/htmlparser2-npm-3.10.1-1bc462e640-d5297fe76c.zip/node_modules/htmlparser2/",\ + "packageDependencies": [\ + ["domelementtype", "npm:1.3.1"],\ + ["domhandler", "npm:2.4.2"],\ + ["domutils", "npm:1.7.0"],\ + ["entities", "npm:1.1.2"],\ + ["htmlparser2", "npm:3.10.1"],\ + ["inherits", "npm:2.0.4"],\ + ["readable-stream", "npm:3.6.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:8.0.2", {\ + "packageLocation": "./.yarn/cache/htmlparser2-npm-8.0.2-5d9f901bb6-ea5512956e.zip/node_modules/htmlparser2/",\ + "packageDependencies": [\ + ["domelementtype", "npm:2.3.0"],\ + ["domhandler", "npm:5.0.3"],\ + ["domutils", "npm:3.1.0"],\ + ["entities", "npm:4.5.0"],\ + ["htmlparser2", "npm:8.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["http-assert", [\ + ["npm:1.5.0", {\ + "packageLocation": "./.yarn/cache/http-assert-npm-1.5.0-bf7ea4ffcf-69c9b3c14c.zip/node_modules/http-assert/",\ + "packageDependencies": [\ + ["deep-equal", "npm:1.0.1"],\ + ["http-assert", "npm:1.5.0"],\ + ["http-errors", "npm:1.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["http-cache-semantics", [\ + ["npm:4.1.1", {\ + "packageLocation": "./.yarn/cache/http-cache-semantics-npm-4.1.1-1120131375-362d5ed66b.zip/node_modules/http-cache-semantics/",\ + "packageDependencies": [\ + ["http-cache-semantics", "npm:4.1.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.2.0", {\ + "packageLocation": "./.yarn/cache/http-cache-semantics-npm-4.2.0-fadacfb3ad-4efd2dfcfe.zip/node_modules/http-cache-semantics/",\ + "packageDependencies": [\ + ["http-cache-semantics", "npm:4.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["http-errors", [\ + ["npm:1.6.3", {\ + "packageLocation": "./.yarn/cache/http-errors-npm-1.6.3-9b5bc0b0a8-e48732657e.zip/node_modules/http-errors/",\ + "packageDependencies": [\ + ["depd", "npm:1.1.2"],\ + ["http-errors", "npm:1.6.3"],\ + ["inherits", "npm:2.0.3"],\ + ["setprototypeof", "npm:1.1.0"],\ + ["statuses", "npm:1.5.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.8.1", {\ + "packageLocation": "./.yarn/cache/http-errors-npm-1.8.1-fb60d9f6ae-76fc491bd8.zip/node_modules/http-errors/",\ + "packageDependencies": [\ + ["depd", "npm:1.1.2"],\ + ["http-errors", "npm:1.8.1"],\ + ["inherits", "npm:2.0.4"],\ + ["setprototypeof", "npm:1.2.0"],\ + ["statuses", "npm:1.5.0"],\ + ["toidentifier", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/http-errors-npm-2.0.0-3f1c503428-0e7f76ee8f.zip/node_modules/http-errors/",\ + "packageDependencies": [\ + ["depd", "npm:2.0.0"],\ + ["http-errors", "npm:2.0.0"],\ + ["inherits", "npm:2.0.4"],\ + ["setprototypeof", "npm:1.2.0"],\ + ["statuses", "npm:2.0.1"],\ + ["toidentifier", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/http-errors-npm-2.0.1-6d19ab492e-9fe31bc0ed.zip/node_modules/http-errors/",\ + "packageDependencies": [\ + ["depd", "npm:2.0.0"],\ + ["http-errors", "npm:2.0.1"],\ + ["inherits", "npm:2.0.4"],\ + ["setprototypeof", "npm:1.2.0"],\ + ["statuses", "npm:2.0.2"],\ + ["toidentifier", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["http-proxy-agent", [\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/http-proxy-agent-npm-5.0.0-7f1f121b83-5ee19423bc.zip/node_modules/http-proxy-agent/",\ + "packageDependencies": [\ + ["@tootallnate/once", "npm:2.0.0"],\ + ["agent-base", "npm:6.0.2"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["http-proxy-agent", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.0", {\ + "packageLocation": "./.yarn/cache/http-proxy-agent-npm-7.0.0-106a57cc8c-dbaaf3d9f3.zip/node_modules/http-proxy-agent/",\ + "packageDependencies": [\ + ["agent-base", "npm:7.1.0"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["http-proxy-agent", "npm:7.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.2", {\ + "packageLocation": "./.yarn/cache/http-proxy-agent-npm-7.0.2-643ed7cc33-d062acfa0c.zip/node_modules/http-proxy-agent/",\ + "packageDependencies": [\ + ["agent-base", "npm:7.1.0"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["http-proxy-agent", "npm:7.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["http2-wrapper", [\ + ["npm:1.0.3", {\ + "packageLocation": "./.yarn/cache/http2-wrapper-npm-1.0.3-5b58ade1df-8097ee2699.zip/node_modules/http2-wrapper/",\ + "packageDependencies": [\ + ["http2-wrapper", "npm:1.0.3"],\ + ["quick-lru", "npm:5.1.1"],\ + ["resolve-alpn", "npm:1.2.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.2.1", {\ + "packageLocation": "./.yarn/cache/http2-wrapper-npm-2.2.1-c033aaabde-e7a5ac6548.zip/node_modules/http2-wrapper/",\ + "packageDependencies": [\ + ["http2-wrapper", "npm:2.2.1"],\ + ["quick-lru", "npm:5.1.1"],\ + ["resolve-alpn", "npm:1.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["http_ece", [\ + ["npm:1.1.0", {\ + "packageLocation": "./.yarn/cache/http_ece-npm-1.1.0-c72cab8bae-a546c8a0be.zip/node_modules/http_ece/",\ + "packageDependencies": [\ + ["http_ece", "npm:1.1.0"],\ + ["urlsafe-base64", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["https-proxy-agent", [\ + ["npm:5.0.1", {\ + "packageLocation": "./.yarn/cache/https-proxy-agent-npm-5.0.1-42d65f358e-f0dce7bdca.zip/node_modules/https-proxy-agent/",\ + "packageDependencies": [\ + ["agent-base", "npm:6.0.2"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["https-proxy-agent", "npm:5.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.1", {\ + "packageLocation": "./.yarn/cache/https-proxy-agent-npm-7.0.1-a2d5d93ee0-68e5a570fd.zip/node_modules/https-proxy-agent/",\ + "packageDependencies": [\ + ["agent-base", "npm:7.1.0"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["https-proxy-agent", "npm:7.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.2", {\ + "packageLocation": "./.yarn/cache/https-proxy-agent-npm-7.0.2-83ea6a5d42-9ec844f78f.zip/node_modules/https-proxy-agent/",\ + "packageDependencies": [\ + ["agent-base", "npm:7.1.0"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["https-proxy-agent", "npm:7.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.6", {\ + "packageLocation": "./.yarn/cache/https-proxy-agent-npm-7.0.6-27a95c2690-784b628cbd.zip/node_modules/https-proxy-agent/",\ + "packageDependencies": [\ + ["agent-base", "npm:7.1.3"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["https-proxy-agent", "npm:7.0.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["human-signals", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/human-signals-npm-2.1.0-f75815481d-df59be9e0a.zip/node_modules/human-signals/",\ + "packageDependencies": [\ + ["human-signals", "npm:2.1.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.1", {\ + "packageLocation": "./.yarn/cache/human-signals-npm-3.0.1-0c557ca74a-0b2741651e.zip/node_modules/human-signals/",\ + "packageDependencies": [\ + ["human-signals", "npm:3.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["humanize-ms", [\ + ["npm:1.2.1", {\ + "packageLocation": "./.yarn/cache/humanize-ms-npm-1.2.1-e942bd7329-9c7a74a282.zip/node_modules/humanize-ms/",\ + "packageDependencies": [\ + ["humanize-ms", "npm:1.2.1"],\ + ["ms", "npm:2.1.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["humanize-number", [\ + ["npm:0.0.2", {\ + "packageLocation": "./.yarn/cache/humanize-number-npm-0.0.2-ac26ad7632-9c98c9d06b.zip/node_modules/humanize-number/",\ + "packageDependencies": [\ + ["humanize-number", "npm:0.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["iceshrimp", [\ + ["workspace:.", {\ + "packageLocation": "./",\ + "packageDependencies": [\ + ["@biomejs/biome", "npm:2.4.14"],\ + ["@bull-board/api", "virtual:6d3c013820dba430e71ebb352cb5205445a13ea3c7a848f57a7ff58fb0d6469fe4d374280277dac42cb77a6dbf8e924e64f2f0b3413c28a02da9d890c199e6d7#npm:5.6.0"],\ + ["@bull-board/ui", "npm:5.6.0"],\ + ["@types/node", "npm:22.19.18"],\ + ["chokidar", "npm:3.5.3"],\ + ["cross-env", "npm:7.0.3"],\ + ["esbuild", "npm:0.28.0"],\ + ["execa", "npm:5.1.1"],\ + ["glob", "npm:13.0.6"],\ + ["iceshrimp", "workspace:."],\ + ["install-peers", "npm:1.0.4"],\ + ["js-yaml", "npm:4.1.0"],\ + ["seedrandom", "npm:3.0.5"],\ + ["typescript", "patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5"],\ + ["yaml", "npm:2.3.4"],\ + ["yoctocolors", "npm:2.1.2"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ + ["iceshrimp-sdk", [\ + ["workspace:packages/iceshrimp-sdk", {\ + "packageLocation": "./packages/iceshrimp-sdk/",\ + "packageDependencies": [\ + ["@microsoft/api-documenter", "npm:7.22.30"],\ + ["@microsoft/api-extractor", "npm:7.36.3"],\ + ["@types/node", "npm:22.19.18"],\ + ["eventemitter3", "npm:4.0.7"],\ + ["iceshrimp-sdk", "workspace:packages/iceshrimp-sdk"],\ + ["reconnecting-websocket", "npm:4.4.0"],\ + ["semver", "npm:7.5.4"],\ + ["tsup", "virtual:9edf5f93d67eba3c8380c148f92c9ef44d49ad903cc76cc912170574219df4f3782593d38e7624104e39c45acd9d7b6cc855f78d7141d92f15dca9bd39617c31#npm:8.5.1"],\ + ["typescript", "patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ + ["iconv-lite", [\ + ["npm:0.4.24", {\ + "packageLocation": "./.yarn/cache/iconv-lite-npm-0.4.24-c5c4ac6695-6d3a2dac6e.zip/node_modules/iconv-lite/",\ + "packageDependencies": [\ + ["iconv-lite", "npm:0.4.24"],\ + ["safer-buffer", "npm:2.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.6.3", {\ + "packageLocation": "./.yarn/cache/iconv-lite-npm-0.6.3-24b8aae27e-24e3292dd3.zip/node_modules/iconv-lite/",\ + "packageDependencies": [\ + ["iconv-lite", "npm:0.6.3"],\ + ["safer-buffer", "npm:2.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["idb-keyval", [\ + ["npm:6.2.1", {\ + "packageLocation": "./.yarn/cache/idb-keyval-npm-6.2.1-05d362a952-9a1416ff5e.zip/node_modules/idb-keyval/",\ + "packageDependencies": [\ + ["idb-keyval", "npm:6.2.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:6.2.2", {\ + "packageLocation": "./.yarn/cache/idb-keyval-npm-6.2.2-0a2fe720e7-8c22342d94.zip/node_modules/idb-keyval/",\ + "packageDependencies": [\ + ["idb-keyval", "npm:6.2.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ieee754", [\ + ["npm:1.2.1", {\ + "packageLocation": "./.yarn/cache/ieee754-npm-1.2.1-fb63b3caeb-d9f2557a59.zip/node_modules/ieee754/",\ + "packageDependencies": [\ + ["ieee754", "npm:1.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["immutable", [\ + ["npm:5.1.5", {\ + "packageLocation": "./.yarn/cache/immutable-npm-5.1.5-65d37ab8d3-7aec274023.zip/node_modules/immutable/",\ + "packageDependencies": [\ + ["immutable", "npm:5.1.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["import-lazy", [\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/import-lazy-npm-4.0.0-3215653869-943309cc8e.zip/node_modules/import-lazy/",\ + "packageDependencies": [\ + ["import-lazy", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["imurmurhash", [\ + ["npm:0.1.4", {\ + "packageLocation": "./.yarn/cache/imurmurhash-npm-0.1.4-610c5068a0-2d30b157a9.zip/node_modules/imurmurhash/",\ + "packageDependencies": [\ + ["imurmurhash", "npm:0.1.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["indent-string", [\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/indent-string-npm-4.0.0-7b717435b2-cd3f5cbc9c.zip/node_modules/indent-string/",\ + "packageDependencies": [\ + ["indent-string", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["inflation", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/inflation-npm-2.0.0-e638c91672-a0494871b1.zip/node_modules/inflation/",\ + "packageDependencies": [\ + ["inflation", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["inflight", [\ + ["npm:1.0.6", {\ + "packageLocation": "./.yarn/cache/inflight-npm-1.0.6-ccedb4b908-d2ebd65441.zip/node_modules/inflight/",\ + "packageDependencies": [\ + ["inflight", "npm:1.0.6"],\ + ["once", "npm:1.4.0"],\ + ["wrappy", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["inherits", [\ + ["npm:2.0.3", {\ + "packageLocation": "./.yarn/cache/inherits-npm-2.0.3-401e64b080-8771303d66.zip/node_modules/inherits/",\ + "packageDependencies": [\ + ["inherits", "npm:2.0.3"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.4", {\ + "packageLocation": "./.yarn/cache/inherits-npm-2.0.4-c66b3957a0-cd45e923be.zip/node_modules/inherits/",\ + "packageDependencies": [\ + ["inherits", "npm:2.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ini", [\ + ["npm:1.3.8", {\ + "packageLocation": "./.yarn/cache/ini-npm-1.3.8-fb5040b4c0-314ae176e8.zip/node_modules/ini/",\ + "packageDependencies": [\ + ["ini", "npm:1.3.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["insert-text-at-cursor", [\ + ["npm:0.3.0", {\ + "packageLocation": "./.yarn/cache/insert-text-at-cursor-npm-0.3.0-89467037f3-f14975bd4e.zip/node_modules/insert-text-at-cursor/",\ + "packageDependencies": [\ + ["insert-text-at-cursor", "npm:0.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["install-artifact-from-github", [\ + ["npm:1.4.0", {\ + "packageLocation": "./.yarn/cache/install-artifact-from-github-npm-1.4.0-85c86bde45-04844f8f8f.zip/node_modules/install-artifact-from-github/",\ + "packageDependencies": [\ + ["install-artifact-from-github", "npm:1.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["install-peers", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/unplugged/install-peers-npm-1.0.4-cc1c5f140e/node_modules/install-peers/",\ + "packageDependencies": [\ + ["executioner", "npm:2.0.1"],\ + ["install-peers", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ioredis", [\ + ["npm:5.4.1", {\ + "packageLocation": "./.yarn/cache/ioredis-npm-5.4.1-c96e18ae67-9043b812ac.zip/node_modules/ioredis/",\ + "packageDependencies": [\ + ["@ioredis/commands", "npm:1.2.0"],\ + ["cluster-key-slot", "npm:1.1.2"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["denque", "npm:2.1.0"],\ + ["ioredis", "npm:5.4.1"],\ + ["lodash.defaults", "npm:4.2.0"],\ + ["lodash.isarguments", "npm:3.1.0"],\ + ["redis-errors", "npm:1.2.0"],\ + ["redis-parser", "npm:3.0.0"],\ + ["standard-as-callback", "npm:2.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ip", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/ip-npm-2.0.0-204facb3cc-1270b11e53.zip/node_modules/ip/",\ + "packageDependencies": [\ + ["ip", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ip-address", [\ + ["npm:7.1.0", {\ + "packageLocation": "./.yarn/cache/ip-address-npm-7.1.0-1544481a53-6681847385.zip/node_modules/ip-address/",\ + "packageDependencies": [\ + ["ip-address", "npm:7.1.0"],\ + ["jsbn", "npm:1.1.0"],\ + ["sprintf-js", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:9.0.5", {\ + "packageLocation": "./.yarn/cache/ip-address-npm-9.0.5-9fa024d42a-1ed81e0672.zip/node_modules/ip-address/",\ + "packageDependencies": [\ + ["ip-address", "npm:9.0.5"],\ + ["jsbn", "npm:1.1.0"],\ + ["sprintf-js", "npm:1.1.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ip-cidr", [\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/ip-cidr-npm-3.1.0-c5e12ec1d6-c1f4e8f6d7.zip/node_modules/ip-cidr/",\ + "packageDependencies": [\ + ["ip-address", "npm:7.1.0"],\ + ["ip-cidr", "npm:3.1.0"],\ + ["jsbn", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ip-regex", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/ip-regex-npm-4.3.0-4ac12c6be9-7ff904b891.zip/node_modules/ip-regex/",\ + "packageDependencies": [\ + ["ip-regex", "npm:4.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ipaddr.js", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/ipaddr.js-npm-2.1.0-7091ce1549-42c16d95cf.zip/node_modules/ipaddr.js/",\ + "packageDependencies": [\ + ["ipaddr.js", "npm:2.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-arrayish", [\ + ["npm:0.3.2", {\ + "packageLocation": "./.yarn/cache/is-arrayish-npm-0.3.2-f856180f79-81a78d518e.zip/node_modules/is-arrayish/",\ + "packageDependencies": [\ + ["is-arrayish", "npm:0.3.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-binary-path", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/is-binary-path-npm-2.1.0-e61d46f557-078e51b4f9.zip/node_modules/is-binary-path/",\ + "packageDependencies": [\ + ["binary-extensions", "npm:2.2.0"],\ + ["is-binary-path", "npm:2.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-buffer", [\ + ["npm:1.1.6", {\ + "packageLocation": "./.yarn/cache/is-buffer-npm-1.1.6-08199d9ccc-f63da109e7.zip/node_modules/is-buffer/",\ + "packageDependencies": [\ + ["is-buffer", "npm:1.1.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-core-module", [\ + ["npm:2.12.1", {\ + "packageLocation": "./.yarn/cache/is-core-module-npm-2.12.1-ce74e89160-35d5f90c95.zip/node_modules/is-core-module/",\ + "packageDependencies": [\ + ["has", "npm:1.0.3"],\ + ["is-core-module", "npm:2.12.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-deflate", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/is-deflate-npm-1.0.0-9dd27645d8-c2f9f2d3db.zip/node_modules/is-deflate/",\ + "packageDependencies": [\ + ["is-deflate", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-expression", [\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/is-expression-npm-4.0.0-44cc07c8aa-0f01d0ff53.zip/node_modules/is-expression/",\ + "packageDependencies": [\ + ["acorn", "npm:7.4.1"],\ + ["is-expression", "npm:4.0.0"],\ + ["object-assign", "npm:4.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-extendable", [\ + ["npm:0.1.1", {\ + "packageLocation": "./.yarn/cache/is-extendable-npm-0.1.1-322b4649ec-3875571d20.zip/node_modules/is-extendable/",\ + "packageDependencies": [\ + ["is-extendable", "npm:0.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-extglob", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/is-extglob-npm-2.1.1-0870ea68b5-df033653d0.zip/node_modules/is-extglob/",\ + "packageDependencies": [\ + ["is-extglob", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-fullwidth-code-point", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/is-fullwidth-code-point-npm-3.0.0-1ecf4ebee5-44a30c2945.zip/node_modules/is-fullwidth-code-point/",\ + "packageDependencies": [\ + ["is-fullwidth-code-point", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-generator-function", [\ + ["npm:1.0.10", {\ + "packageLocation": "./.yarn/cache/is-generator-function-npm-1.0.10-1d0f3809ef-499a3ce636.zip/node_modules/is-generator-function/",\ + "packageDependencies": [\ + ["has-tostringtag", "npm:1.0.0"],\ + ["is-generator-function", "npm:1.0.10"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-glob", [\ + ["npm:4.0.3", {\ + "packageLocation": "./.yarn/cache/is-glob-npm-4.0.3-cb87bf1bdb-3ed74f2b0c.zip/node_modules/is-glob/",\ + "packageDependencies": [\ + ["is-extglob", "npm:2.1.1"],\ + ["is-glob", "npm:4.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-gzip", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/is-gzip-npm-1.0.0-083ca1eb6c-0d28931c1f.zip/node_modules/is-gzip/",\ + "packageDependencies": [\ + ["is-gzip", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-ip", [\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/is-ip-npm-3.1.0-7b8bc9330c-da2c2b2824.zip/node_modules/is-ip/",\ + "packageDependencies": [\ + ["ip-regex", "npm:4.3.0"],\ + ["is-ip", "npm:3.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-lambda", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/is-lambda-npm-1.0.1-7ab55bc8a8-93a32f0194.zip/node_modules/is-lambda/",\ + "packageDependencies": [\ + ["is-lambda", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-natural-number", [\ + ["npm:4.0.1", {\ + "packageLocation": "./.yarn/cache/is-natural-number-npm-4.0.1-b5fd86a31d-3e5e3d52e0.zip/node_modules/is-natural-number/",\ + "packageDependencies": [\ + ["is-natural-number", "npm:4.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-number", [\ + ["npm:7.0.0", {\ + "packageLocation": "./.yarn/cache/is-number-npm-7.0.0-060086935c-6a6c3383f6.zip/node_modules/is-number/",\ + "packageDependencies": [\ + ["is-number", "npm:7.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-plain-obj", [\ + ["npm:1.1.0", {\ + "packageLocation": "./.yarn/cache/is-plain-obj-npm-1.1.0-1046f64c0b-0ee0480779.zip/node_modules/is-plain-obj/",\ + "packageDependencies": [\ + ["is-plain-obj", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-plain-object", [\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/is-plain-object-npm-5.0.0-285b70faa3-e32d27061e.zip/node_modules/is-plain-object/",\ + "packageDependencies": [\ + ["is-plain-object", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-potential-custom-element-name", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/is-potential-custom-element-name-npm-1.0.1-f352f606f8-ced7bbbb64.zip/node_modules/is-potential-custom-element-name/",\ + "packageDependencies": [\ + ["is-potential-custom-element-name", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-promise", [\ + ["npm:2.2.2", {\ + "packageLocation": "./.yarn/cache/is-promise-npm-2.2.2-afbf94db67-18bf7d1c59.zip/node_modules/is-promise/",\ + "packageDependencies": [\ + ["is-promise", "npm:2.2.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-regex", [\ + ["npm:1.1.4", {\ + "packageLocation": "./.yarn/cache/is-regex-npm-1.1.4-cca193ef11-36d9174d16.zip/node_modules/is-regex/",\ + "packageDependencies": [\ + ["call-bind", "npm:1.0.2"],\ + ["has-tostringtag", "npm:1.0.0"],\ + ["is-regex", "npm:1.1.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-stream", [\ + ["npm:1.1.0", {\ + "packageLocation": "./.yarn/cache/is-stream-npm-1.1.0-818ecbf6bb-351aa77c54.zip/node_modules/is-stream/",\ + "packageDependencies": [\ + ["is-stream", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/is-stream-npm-2.0.1-c802db55e7-b8e05ccdf9.zip/node_modules/is-stream/",\ + "packageDependencies": [\ + ["is-stream", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/is-stream-npm-3.0.0-a77ac9a62e-172093fe99.zip/node_modules/is-stream/",\ + "packageDependencies": [\ + ["is-stream", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.1", {\ + "packageLocation": "./.yarn/cache/is-stream-npm-4.0.1-328fd196cc-cbea3f1fc2.zip/node_modules/is-stream/",\ + "packageDependencies": [\ + ["is-stream", "npm:4.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-svg", [\ + ["npm:4.3.2", {\ + "packageLocation": "./.yarn/cache/is-svg-npm-4.3.2-0866375a1b-b3bce2395e.zip/node_modules/is-svg/",\ + "packageDependencies": [\ + ["fast-xml-parser", "npm:3.21.1"],\ + ["is-svg", "npm:4.3.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-typedarray", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/is-typedarray-npm-1.0.0-bbd99de5b6-4b433bfb0f.zip/node_modules/is-typedarray/",\ + "packageDependencies": [\ + ["is-typedarray", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-url", [\ + ["npm:1.2.4", {\ + "packageLocation": "./.yarn/cache/is-url-npm-1.2.4-0a28aeb560-100e74b3b1.zip/node_modules/is-url/",\ + "packageDependencies": [\ + ["is-url", "npm:1.2.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["is-whitespace", [\ + ["npm:0.3.0", {\ + "packageLocation": "./.yarn/cache/is-whitespace-npm-0.3.0-994f2b3b8c-dac8fc9a9b.zip/node_modules/is-whitespace/",\ + "packageDependencies": [\ + ["is-whitespace", "npm:0.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["isarray", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/isarray-npm-1.0.0-db4f547720-f032df8e02.zip/node_modules/isarray/",\ + "packageDependencies": [\ + ["isarray", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["isexe", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/isexe-npm-2.0.0-b58870bd2e-7c9f715c03.zip/node_modules/isexe/",\ + "packageDependencies": [\ + ["isexe", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.1.1", {\ + "packageLocation": "./.yarn/cache/isexe-npm-3.1.1-9c0061eead-7fe1931ee4.zip/node_modules/isexe/",\ + "packageDependencies": [\ + ["isexe", "npm:3.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["jackspeak", [\ + ["npm:2.2.2", {\ + "packageLocation": "./.yarn/cache/jackspeak-npm-2.2.2-374ca454fe-0f43b70bdd.zip/node_modules/jackspeak/",\ + "packageDependencies": [\ + ["@isaacs/cliui", "npm:8.0.2"],\ + ["@pkgjs/parseargs", "npm:0.11.0"],\ + ["jackspeak", "npm:2.2.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["jake", [\ + ["npm:10.8.7", {\ + "packageLocation": "./.yarn/cache/jake-npm-10.8.7-1caf9b4534-ad1cfe3988.zip/node_modules/jake/",\ + "packageDependencies": [\ + ["async", "npm:3.2.4"],\ + ["chalk", "npm:4.1.2"],\ + ["filelist", "npm:1.0.4"],\ + ["jake", "npm:10.8.7"],\ + ["minimatch", "npm:3.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["jju", [\ + ["npm:1.4.0", {\ + "packageLocation": "./.yarn/cache/jju-npm-1.4.0-670678eaa3-1067ff8ce0.zip/node_modules/jju/",\ + "packageDependencies": [\ + ["jju", "npm:1.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["joycon", [\ + ["npm:3.1.1", {\ + "packageLocation": "./.yarn/cache/joycon-npm-3.1.1-3033e0e5f4-4b36e34791.zip/node_modules/joycon/",\ + "packageDependencies": [\ + ["joycon", "npm:3.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["jpeg-js", [\ + ["npm:0.4.4", {\ + "packageLocation": "./.yarn/cache/jpeg-js-npm-0.4.4-4dd87659c3-30bb6e16e7.zip/node_modules/jpeg-js/",\ + "packageDependencies": [\ + ["jpeg-js", "npm:0.4.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["js-beautify", [\ + ["npm:1.14.9", {\ + "packageLocation": "./.yarn/cache/js-beautify-npm-1.14.9-0bf2af42de-a7f57bb468.zip/node_modules/js-beautify/",\ + "packageDependencies": [\ + ["config-chain", "npm:1.1.13"],\ + ["editorconfig", "npm:1.0.4"],\ + ["glob", "npm:8.1.0"],\ + ["js-beautify", "npm:1.14.9"],\ + ["nopt", "npm:6.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["js-stringify", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/js-stringify-npm-1.0.2-898ffeac57-f9701d9e53.zip/node_modules/js-stringify/",\ + "packageDependencies": [\ + ["js-stringify", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["js-yaml", [\ + ["npm:3.13.1", {\ + "packageLocation": "./.yarn/cache/js-yaml-npm-3.13.1-3a28ff3b75-cec89175b0.zip/node_modules/js-yaml/",\ + "packageDependencies": [\ + ["argparse", "npm:1.0.10"],\ + ["esprima", "npm:4.0.1"],\ + ["js-yaml", "npm:3.13.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.1.0", {\ + "packageLocation": "./.yarn/cache/js-yaml-npm-4.1.0-3606f32312-c138a34a3f.zip/node_modules/js-yaml/",\ + "packageDependencies": [\ + ["argparse", "npm:2.0.1"],\ + ["js-yaml", "npm:4.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["jsbn", [\ + ["npm:0.1.1", {\ + "packageLocation": "./.yarn/cache/jsbn-npm-0.1.1-0eb7132404-5450133242.zip/node_modules/jsbn/",\ + "packageDependencies": [\ + ["jsbn", "npm:0.1.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.1.0", {\ + "packageLocation": "./.yarn/cache/jsbn-npm-1.1.0-1da0181838-bebe7ae829.zip/node_modules/jsbn/",\ + "packageDependencies": [\ + ["jsbn", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["jschardet", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/jschardet-npm-3.0.0-0a853331a8-b5a87e188b.zip/node_modules/jschardet/",\ + "packageDependencies": [\ + ["jschardet", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["jsdom", [\ + ["npm:26.1.0", {\ + "packageLocation": "./.yarn/cache/jsdom-npm-26.1.0-3857255f02-39d78c4889.zip/node_modules/jsdom/",\ + "packageDependencies": [\ + ["jsdom", "npm:26.1.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:26.1.0", {\ + "packageLocation": "./.yarn/__virtual__/jsdom-virtual-b95527b5a0/0/cache/jsdom-npm-26.1.0-3857255f02-39d78c4889.zip/node_modules/jsdom/",\ + "packageDependencies": [\ + ["@types/canvas", null],\ + ["canvas", null],\ + ["cssstyle", "npm:4.6.0"],\ + ["data-urls", "npm:5.0.0"],\ + ["decimal.js", "npm:10.6.0"],\ + ["html-encoding-sniffer", "npm:4.0.0"],\ + ["http-proxy-agent", "npm:7.0.2"],\ + ["https-proxy-agent", "npm:7.0.6"],\ + ["is-potential-custom-element-name", "npm:1.0.1"],\ + ["jsdom", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:26.1.0"],\ + ["nwsapi", "npm:2.2.23"],\ + ["parse5", "npm:7.3.0"],\ + ["rrweb-cssom", "npm:0.8.0"],\ + ["saxes", "npm:6.0.0"],\ + ["symbol-tree", "npm:3.2.4"],\ + ["tough-cookie", "npm:5.1.2"],\ + ["w3c-xmlserializer", "npm:5.0.0"],\ + ["webidl-conversions", "npm:7.0.0"],\ + ["whatwg-encoding", "npm:3.1.1"],\ + ["whatwg-mimetype", "npm:4.0.0"],\ + ["whatwg-url", "npm:14.2.0"],\ + ["ws", "virtual:480a4fb09e5db13ca37db95ab0b87fa859e5f5a8c839e4ec91bb8bc7cd4c00ab4eb257765778c4c33e94967fef678e7ad3b289430644113a0013a853a4a6552f#npm:8.20.0"],\ + ["xml-name-validator", "npm:5.0.0"]\ + ],\ + "packagePeers": [\ + "@types/canvas",\ + "canvas"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["json-buffer", [\ + ["npm:3.0.1", {\ + "packageLocation": "./.yarn/cache/json-buffer-npm-3.0.1-f8f6d20603-8287615452.zip/node_modules/json-buffer/",\ + "packageDependencies": [\ + ["json-buffer", "npm:3.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["json-schema", [\ + ["npm:0.4.0", {\ + "packageLocation": "./.yarn/cache/json-schema-npm-0.4.0-e776313070-8b3b64eff4.zip/node_modules/json-schema/",\ + "packageDependencies": [\ + ["json-schema", "npm:0.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["json-schema-traverse", [\ + ["npm:0.4.1", {\ + "packageLocation": "./.yarn/cache/json-schema-traverse-npm-0.4.1-4759091693-7486074d3b.zip/node_modules/json-schema-traverse/",\ + "packageDependencies": [\ + ["json-schema-traverse", "npm:0.4.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/json-schema-traverse-npm-1.0.0-fb3684f4f0-02f2f466cd.zip/node_modules/json-schema-traverse/",\ + "packageDependencies": [\ + ["json-schema-traverse", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["json-stringify-safe", [\ + ["npm:5.0.1", {\ + "packageLocation": "./.yarn/cache/json-stringify-safe-npm-5.0.1-064ddd6ab4-59169a081e.zip/node_modules/json-stringify-safe/",\ + "packageDependencies": [\ + ["json-stringify-safe", "npm:5.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["json5", [\ + ["npm:2.2.3", {\ + "packageLocation": "./.yarn/cache/json5-npm-2.2.3-9962c55073-1db67b853f.zip/node_modules/json5/",\ + "packageDependencies": [\ + ["json5", "npm:2.2.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["jsonfile", [\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/jsonfile-npm-4.0.0-10ce3aea15-17796f0ab1.zip/node_modules/jsonfile/",\ + "packageDependencies": [\ + ["graceful-fs", "npm:4.2.11"],\ + ["jsonfile", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/jsonfile-npm-5.0.0-be63f5e054-fcf7a1c6c9.zip/node_modules/jsonfile/",\ + "packageDependencies": [\ + ["graceful-fs", "npm:4.2.11"],\ + ["jsonfile", "npm:5.0.0"],\ + ["universalify", "npm:0.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["jsonld", [\ + ["npm:8.2.0", {\ + "packageLocation": "./.yarn/cache/jsonld-npm-8.2.0-a0797ceb72-743a00fd5b.zip/node_modules/jsonld/",\ + "packageDependencies": [\ + ["@digitalbazaar/http-client", "npm:3.4.1"],\ + ["canonicalize", "npm:1.0.8"],\ + ["jsonld", "npm:8.2.0"],\ + ["lru-cache", "npm:6.0.0"],\ + ["rdf-canonize", "npm:3.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["jsprim", [\ + ["npm:1.4.2", {\ + "packageLocation": "./.yarn/cache/jsprim-npm-1.4.2-1ae6cade9a-df2bf234ea.zip/node_modules/jsprim/",\ + "packageDependencies": [\ + ["assert-plus", "npm:1.0.0"],\ + ["extsprintf", "npm:1.3.0"],\ + ["json-schema", "npm:0.4.0"],\ + ["jsprim", "npm:1.4.2"],\ + ["verror", "npm:1.10.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["jsrsasign", [\ + ["npm:10.8.6", {\ + "packageLocation": "./.yarn/cache/jsrsasign-npm-10.8.6-1afbd97b19-92de977d13.zip/node_modules/jsrsasign/",\ + "packageDependencies": [\ + ["jsrsasign", "npm:10.8.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["jssha", [\ + ["npm:3.3.0", {\ + "packageLocation": "./.yarn/cache/jssha-npm-3.3.0-8ad1281d04-bc30449766.zip/node_modules/jssha/",\ + "packageDependencies": [\ + ["jssha", "npm:3.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["jstransformer", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/jstransformer-npm-1.0.0-41a47d180a-7bca6e2e2f.zip/node_modules/jstransformer/",\ + "packageDependencies": [\ + ["is-promise", "npm:2.2.2"],\ + ["jstransformer", "npm:1.0.0"],\ + ["promise", "npm:7.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["jwa", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/jwa-npm-2.0.1-9ebe28a626-b04312a1de.zip/node_modules/jwa/",\ + "packageDependencies": [\ + ["buffer-equal-constant-time", "npm:1.0.1"],\ + ["ecdsa-sig-formatter", "npm:1.0.11"],\ + ["jwa", "npm:2.0.1"],\ + ["safe-buffer", "npm:5.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["jws", [\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/jws-npm-4.0.0-2a24fd53b9-1d15f4cdea.zip/node_modules/jws/",\ + "packageDependencies": [\ + ["jwa", "npm:2.0.1"],\ + ["jws", "npm:4.0.0"],\ + ["safe-buffer", "npm:5.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["katex", [\ + ["npm:0.16.45", {\ + "packageLocation": "./.yarn/cache/katex-npm-0.16.45-bd95b30a87-8c82f9651c.zip/node_modules/katex/",\ + "packageDependencies": [\ + ["commander", "npm:8.3.0"],\ + ["katex", "npm:0.16.45"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["keygrip", [\ + ["npm:1.1.0", {\ + "packageLocation": "./.yarn/cache/keygrip-npm-1.1.0-8da030c3ff-078cd16a46.zip/node_modules/keygrip/",\ + "packageDependencies": [\ + ["keygrip", "npm:1.1.0"],\ + ["tsscmp", "npm:1.0.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["keyv", [\ + ["npm:4.5.3", {\ + "packageLocation": "./.yarn/cache/keyv-npm-4.5.3-d2382300dd-2c96e345ec.zip/node_modules/keyv/",\ + "packageDependencies": [\ + ["json-buffer", "npm:3.0.1"],\ + ["keyv", "npm:4.5.3"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.6.0", {\ + "packageLocation": "./.yarn/cache/keyv-npm-5.6.0-998f7ab008-f1de999fdf.zip/node_modules/keyv/",\ + "packageDependencies": [\ + ["@keyv/serialize", "npm:1.1.1"],\ + ["keyv", "npm:5.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["kind-of", [\ + ["npm:3.2.2", {\ + "packageLocation": "./.yarn/cache/kind-of-npm-3.2.2-7deaffa5f9-b6e7eed10f.zip/node_modules/kind-of/",\ + "packageDependencies": [\ + ["is-buffer", "npm:1.1.6"],\ + ["kind-of", "npm:3.2.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["koa", [\ + ["npm:2.13.4", {\ + "packageLocation": "./.yarn/cache/koa-npm-2.13.4-8aee05a69e-5ddd5a08dc.zip/node_modules/koa/",\ + "packageDependencies": [\ + ["accepts", "npm:1.3.8"],\ + ["cache-content-type", "npm:1.0.1"],\ + ["content-disposition", "npm:0.5.4"],\ + ["content-type", "npm:1.0.5"],\ + ["cookies", "npm:0.8.0"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["delegates", "npm:1.0.0"],\ + ["depd", "npm:2.0.0"],\ + ["destroy", "npm:1.2.0"],\ + ["encodeurl", "npm:1.0.2"],\ + ["escape-html", "npm:1.0.3"],\ + ["fresh", "npm:0.5.2"],\ + ["http-assert", "npm:1.5.0"],\ + ["http-errors", "npm:1.8.1"],\ + ["is-generator-function", "npm:1.0.10"],\ + ["koa", "npm:2.13.4"],\ + ["koa-compose", "npm:4.1.0"],\ + ["koa-convert", "npm:2.0.0"],\ + ["on-finished", "npm:2.4.1"],\ + ["only", "npm:0.0.2"],\ + ["parseurl", "npm:1.3.3"],\ + ["statuses", "npm:1.5.0"],\ + ["type-is", "npm:1.6.18"],\ + ["vary", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.14.2", {\ + "packageLocation": "./.yarn/cache/koa-npm-2.14.2-0908395b5d-be3592ad2e.zip/node_modules/koa/",\ + "packageDependencies": [\ + ["accepts", "npm:1.3.8"],\ + ["cache-content-type", "npm:1.0.1"],\ + ["content-disposition", "npm:0.5.4"],\ + ["content-type", "npm:1.0.5"],\ + ["cookies", "npm:0.8.0"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["delegates", "npm:1.0.0"],\ + ["depd", "npm:2.0.0"],\ + ["destroy", "npm:1.2.0"],\ + ["encodeurl", "npm:1.0.2"],\ + ["escape-html", "npm:1.0.3"],\ + ["fresh", "npm:0.5.2"],\ + ["http-assert", "npm:1.5.0"],\ + ["http-errors", "npm:1.8.1"],\ + ["is-generator-function", "npm:1.0.10"],\ + ["koa", "npm:2.14.2"],\ + ["koa-compose", "npm:4.1.0"],\ + ["koa-convert", "npm:2.0.0"],\ + ["on-finished", "npm:2.4.1"],\ + ["only", "npm:0.0.2"],\ + ["parseurl", "npm:1.3.3"],\ + ["statuses", "npm:1.5.0"],\ + ["type-is", "npm:1.6.18"],\ + ["vary", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.16.4", {\ + "packageLocation": "./.yarn/cache/koa-npm-2.16.4-af02a0a309-f49e76c2cb.zip/node_modules/koa/",\ + "packageDependencies": [\ + ["accepts", "npm:1.3.8"],\ + ["cache-content-type", "npm:1.0.1"],\ + ["content-disposition", "npm:0.5.4"],\ + ["content-type", "npm:1.0.5"],\ + ["cookies", "npm:0.9.1"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["delegates", "npm:1.0.0"],\ + ["depd", "npm:2.0.0"],\ + ["destroy", "npm:1.2.0"],\ + ["encodeurl", "npm:1.0.2"],\ + ["escape-html", "npm:1.0.3"],\ + ["fresh", "npm:0.5.2"],\ + ["http-assert", "npm:1.5.0"],\ + ["http-errors", "npm:1.8.1"],\ + ["is-generator-function", "npm:1.0.10"],\ + ["koa", "npm:2.16.4"],\ + ["koa-compose", "npm:4.1.0"],\ + ["koa-convert", "npm:2.0.0"],\ + ["on-finished", "npm:2.4.1"],\ + ["only", "npm:0.0.2"],\ + ["parseurl", "npm:1.3.3"],\ + ["statuses", "npm:1.5.0"],\ + ["type-is", "npm:1.6.18"],\ + ["vary", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["koa-body", [\ + ["npm:6.0.1", {\ + "packageLocation": "./.yarn/cache/koa-body-npm-6.0.1-e672d44ab2-d241d4d228.zip/node_modules/koa-body/",\ + "packageDependencies": [\ + ["@types/co-body", "npm:6.1.0"],\ + ["@types/formidable", "npm:2.0.6"],\ + ["@types/koa", "npm:2.13.7"],\ + ["co-body", "npm:6.1.0"],\ + ["formidable", "npm:2.1.2"],\ + ["koa-body", "npm:6.0.1"],\ + ["zod", "npm:3.21.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["koa-bodyparser", [\ + ["npm:4.4.1", {\ + "packageLocation": "./.yarn/cache/koa-bodyparser-npm-4.4.1-d833e4beca-c741a99cca.zip/node_modules/koa-bodyparser/",\ + "packageDependencies": [\ + ["co-body", "npm:6.1.0"],\ + ["copy-to", "npm:2.0.1"],\ + ["koa-bodyparser", "npm:4.4.1"],\ + ["type-is", "npm:1.6.18"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["koa-compose", [\ + ["npm:4.1.0", {\ + "packageLocation": "./.yarn/cache/koa-compose-npm-4.1.0-b682d9324e-46cb16792d.zip/node_modules/koa-compose/",\ + "packageDependencies": [\ + ["koa-compose", "npm:4.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["koa-convert", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/koa-convert-npm-2.0.0-d709eca55c-7385b33919.zip/node_modules/koa-convert/",\ + "packageDependencies": [\ + ["co", "npm:4.6.0"],\ + ["koa-compose", "npm:4.1.0"],\ + ["koa-convert", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["koa-favicon", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/koa-favicon-npm-2.1.0-6825eb9290-f70eff31f9.zip/node_modules/koa-favicon/",\ + "packageDependencies": [\ + ["koa-favicon", "npm:2.1.0"],\ + ["mz", "npm:2.7.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["koa-logger", [\ + ["npm:3.2.1", {\ + "packageLocation": "./.yarn/cache/koa-logger-npm-3.2.1-c647362edb-b29ba25eb4.zip/node_modules/koa-logger/",\ + "packageDependencies": [\ + ["bytes", "npm:3.1.2"],\ + ["chalk", "npm:2.4.2"],\ + ["humanize-number", "npm:0.0.2"],\ + ["koa-logger", "npm:3.2.1"],\ + ["passthrough-counter", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["koa-mount", [\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/koa-mount-npm-4.0.0-c8a47bd44e-c7e8c5cca4.zip/node_modules/koa-mount/",\ + "packageDependencies": [\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["koa-compose", "npm:4.1.0"],\ + ["koa-mount", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["koa-remove-trailing-slashes", [\ + ["npm:2.0.3", {\ + "packageLocation": "./.yarn/cache/koa-remove-trailing-slashes-npm-2.0.3-2b7f265a71-43336f9792.zip/node_modules/koa-remove-trailing-slashes/",\ + "packageDependencies": [\ + ["koa-remove-trailing-slashes", "npm:2.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["koa-router", [\ + ["npm:10.1.1", {\ + "packageLocation": "./.yarn/cache/koa-router-npm-10.1.1-442054cb9a-f362a9dae2.zip/node_modules/koa-router/",\ + "packageDependencies": [\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["http-errors", "npm:1.8.1"],\ + ["koa-compose", "npm:4.1.0"],\ + ["koa-router", "npm:10.1.1"],\ + ["methods", "npm:1.1.2"],\ + ["path-to-regexp", "npm:6.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["koa-send", [\ + ["npm:5.0.1", {\ + "packageLocation": "./.yarn/cache/koa-send-npm-5.0.1-1b37331b00-a9fbaadbe0.zip/node_modules/koa-send/",\ + "packageDependencies": [\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["http-errors", "npm:1.8.1"],\ + ["koa-send", "npm:5.0.1"],\ + ["resolve-path", "npm:1.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["koa-slow", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/koa-slow-npm-2.1.0-ea5569dfc3-bde4b04340.zip/node_modules/koa-slow/",\ + "packageDependencies": [\ + ["koa-slow", "npm:2.1.0"],\ + ["lodash.isregexp", "npm:3.0.5"],\ + ["q", "npm:1.4.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["koa-static", [\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/koa-static-npm-5.0.0-4248438d52-8d9b9c4d2b.zip/node_modules/koa-static/",\ + "packageDependencies": [\ + ["debug", "virtual:4248438d52dae4f796f8ae7839807024b795f7c70ddf4cbb58a4d6330ce027df2b3c8481d65b7430d525478785f68212512cd0d537d63bda0f170167a0f757f6#npm:3.2.7"],\ + ["koa-send", "npm:5.0.1"],\ + ["koa-static", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["koa-views", [\ + ["npm:7.0.2", {\ + "packageLocation": "./.yarn/cache/koa-views-npm-7.0.2-f4a5c0091b-edff754c9f.zip/node_modules/koa-views/",\ + "packageDependencies": [\ + ["koa-views", "npm:7.0.2"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["npm:8.1.0", {\ + "packageLocation": "./.yarn/cache/koa-views-npm-8.1.0-a99b75d6d2-650e534351.zip/node_modules/koa-views/",\ + "packageDependencies": [\ + ["koa-views", "npm:8.1.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:47e7552797ac54a9c2afddff4b4e04d9d332a7c4ae9063d663783ce2e037b701b60b632afdcf95d0a9161f0abbca22d39846d9260e4899a8593e974d45bbf1e0#npm:7.0.2", {\ + "packageLocation": "./.yarn/__virtual__/koa-views-virtual-86ee2a8ef0/0/cache/koa-views-npm-7.0.2-f4a5c0091b-edff754c9f.zip/node_modules/koa-views/",\ + "packageDependencies": [\ + ["@types/koa", null],\ + ["consolidate", "virtual:86ee2a8ef0b659363be6b28aece63a5c4d251db21e8e56fe2ea2e03233d27c1fc9c744ff2a904e3dbda7f6a5fedaa3054e931f4e747f5b9deb58af95c389db98#npm:0.16.0"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["get-paths", "npm:0.0.7"],\ + ["koa-send", "npm:5.0.1"],\ + ["koa-views", "virtual:47e7552797ac54a9c2afddff4b4e04d9d332a7c4ae9063d663783ce2e037b701b60b632afdcf95d0a9161f0abbca22d39846d9260e4899a8593e974d45bbf1e0#npm:7.0.2"],\ + ["mz", "npm:2.7.0"],\ + ["pretty", "npm:2.0.0"],\ + ["resolve-path", "npm:1.4.0"]\ + ],\ + "packagePeers": [\ + "@types/koa"\ + ],\ + "linkType": "HARD"\ + }],\ + ["virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:7.0.2", {\ + "packageLocation": "./.yarn/__virtual__/koa-views-virtual-bf8ad99819/0/cache/koa-views-npm-7.0.2-f4a5c0091b-edff754c9f.zip/node_modules/koa-views/",\ + "packageDependencies": [\ + ["@types/koa", "npm:2.13.6"],\ + ["consolidate", "virtual:86ee2a8ef0b659363be6b28aece63a5c4d251db21e8e56fe2ea2e03233d27c1fc9c744ff2a904e3dbda7f6a5fedaa3054e931f4e747f5b9deb58af95c389db98#npm:0.16.0"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["get-paths", "npm:0.0.7"],\ + ["koa-send", "npm:5.0.1"],\ + ["koa-views", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:7.0.2"],\ + ["mz", "npm:2.7.0"],\ + ["pretty", "npm:2.0.0"],\ + ["resolve-path", "npm:1.4.0"]\ + ],\ + "packagePeers": [\ + "@types/koa"\ + ],\ + "linkType": "HARD"\ + }],\ + ["virtual:b4af58161fd183d4fbd2129fe4c22cc032d75bc29df35f30e106743a6b0753da95cbfe952c1cfa2afab5061506b95a6c48070f65c332e28defab4f16f6a6de0e#npm:8.1.0", {\ + "packageLocation": "./.yarn/__virtual__/koa-views-virtual-02775651b0/0/cache/koa-views-npm-8.1.0-a99b75d6d2-650e534351.zip/node_modules/koa-views/",\ + "packageDependencies": [\ + ["@ladjs/consolidate", "virtual:02775651b005880128e4f9b39ab8f78565ba46ef3e4875f91f1fb702ae5b7df99dc203b26ae54230cc4a7173329d2306ee63e4d9c8ac260f9af074e484d8f11e#npm:1.0.1"],\ + ["@types/koa", null],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["get-paths", "npm:0.0.7"],\ + ["koa-send", "npm:5.0.1"],\ + ["koa-views", "virtual:b4af58161fd183d4fbd2129fe4c22cc032d75bc29df35f30e106743a6b0753da95cbfe952c1cfa2afab5061506b95a6c48070f65c332e28defab4f16f6a6de0e#npm:8.1.0"],\ + ["mz", "npm:2.7.0"],\ + ["pretty", "npm:2.0.0"],\ + ["resolve-path", "npm:1.4.0"]\ + ],\ + "packagePeers": [\ + "@types/koa"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ky", [\ + ["npm:0.33.3", {\ + "packageLocation": "./.yarn/cache/ky-npm-0.33.3-7d1cbfa9f4-556b2241fe.zip/node_modules/ky/",\ + "packageDependencies": [\ + ["ky", "npm:0.33.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ky-universal", [\ + ["npm:0.11.0", {\ + "packageLocation": "./.yarn/cache/ky-universal-npm-0.11.0-f343804243-42e4c91551.zip/node_modules/ky-universal/",\ + "packageDependencies": [\ + ["ky-universal", "npm:0.11.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:8f7eac0be74664d725f06767d8b014c5441ef91d67e5f5b4d48b1eb4b262e4666123cac7be8dcbb99a7798fb4a21e6f4d9693a3957796c4a48c23e26af86514f#npm:0.11.0", {\ + "packageLocation": "./.yarn/__virtual__/ky-universal-virtual-a0d11879db/0/cache/ky-universal-npm-0.11.0-f343804243-42e4c91551.zip/node_modules/ky-universal/",\ + "packageDependencies": [\ + ["@types/ky", null],\ + ["@types/web-streams-polyfill", null],\ + ["abort-controller", "npm:3.0.0"],\ + ["ky", "npm:0.33.3"],\ + ["ky-universal", "virtual:8f7eac0be74664d725f06767d8b014c5441ef91d67e5f5b4d48b1eb4b262e4666123cac7be8dcbb99a7798fb4a21e6f4d9693a3957796c4a48c23e26af86514f#npm:0.11.0"],\ + ["node-fetch", "npm:3.3.2"],\ + ["web-streams-polyfill", null]\ + ],\ + "packagePeers": [\ + "@types/ky",\ + "@types/web-streams-polyfill",\ + "ky",\ + "web-streams-polyfill"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lazystream", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/lazystream-npm-1.0.1-7477e64441-35f8cf8b57.zip/node_modules/lazystream/",\ + "packageDependencies": [\ + ["lazystream", "npm:1.0.1"],\ + ["readable-stream", "npm:2.3.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lilconfig", [\ + ["npm:3.1.3", {\ + "packageLocation": "./.yarn/cache/lilconfig-npm-3.1.3-74a77377bb-b932ce1af9.zip/node_modules/lilconfig/",\ + "packageDependencies": [\ + ["lilconfig", "npm:3.1.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lines-and-columns", [\ + ["npm:1.2.4", {\ + "packageLocation": "./.yarn/cache/lines-and-columns-npm-1.2.4-d6c7cc5799-0c37f9f7fa.zip/node_modules/lines-and-columns/",\ + "packageDependencies": [\ + ["lines-and-columns", "npm:1.2.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["load-tsconfig", [\ + ["npm:0.2.5", {\ + "packageLocation": "./.yarn/cache/load-tsconfig-npm-0.2.5-70feef5c98-b3176f6f0c.zip/node_modules/load-tsconfig/",\ + "packageDependencies": [\ + ["load-tsconfig", "npm:0.2.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["loadjs", [\ + ["npm:4.3.0", {\ + "packageLocation": "./.yarn/cache/loadjs-npm-4.3.0-5056345cd4-4ddcc6c5d1.zip/node_modules/loadjs/",\ + "packageDependencies": [\ + ["loadjs", "npm:4.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["locate-path", [\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/locate-path-npm-5.0.0-46580c43e4-83e51725e6.zip/node_modules/locate-path/",\ + "packageDependencies": [\ + ["locate-path", "npm:5.0.0"],\ + ["p-locate", "npm:4.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash", [\ + ["npm:4.17.21", {\ + "packageLocation": "./.yarn/cache/lodash-npm-4.17.21-6382451519-c08619c038.zip/node_modules/lodash/",\ + "packageDependencies": [\ + ["lodash", "npm:4.17.21"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.assignin", [\ + ["npm:4.2.0", {\ + "packageLocation": "./.yarn/cache/lodash.assignin-npm-4.2.0-f45fed9160-6f7a7b6f74.zip/node_modules/lodash.assignin/",\ + "packageDependencies": [\ + ["lodash.assignin", "npm:4.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.bind", [\ + ["npm:4.2.1", {\ + "packageLocation": "./.yarn/cache/lodash.bind-npm-4.2.1-0b68ea9ea1-946cc5dbd8.zip/node_modules/lodash.bind/",\ + "packageDependencies": [\ + ["lodash.bind", "npm:4.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.defaults", [\ + ["npm:4.2.0", {\ + "packageLocation": "./.yarn/cache/lodash.defaults-npm-4.2.0-c5dea025ab-6a2a9ea5ad.zip/node_modules/lodash.defaults/",\ + "packageDependencies": [\ + ["lodash.defaults", "npm:4.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.difference", [\ + ["npm:4.5.0", {\ + "packageLocation": "./.yarn/cache/lodash.difference-npm-4.5.0-7a179a50e1-b22adb1be9.zip/node_modules/lodash.difference/",\ + "packageDependencies": [\ + ["lodash.difference", "npm:4.5.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.filter", [\ + ["npm:4.6.0", {\ + "packageLocation": "./.yarn/cache/lodash.filter-npm-4.6.0-21e2aceac9-a95c363b6c.zip/node_modules/lodash.filter/",\ + "packageDependencies": [\ + ["lodash.filter", "npm:4.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.flatten", [\ + ["npm:4.4.0", {\ + "packageLocation": "./.yarn/cache/lodash.flatten-npm-4.4.0-495935e617-a2b192f220.zip/node_modules/lodash.flatten/",\ + "packageDependencies": [\ + ["lodash.flatten", "npm:4.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.foreach", [\ + ["npm:4.5.0", {\ + "packageLocation": "./.yarn/cache/lodash.foreach-npm-4.5.0-ca8531894e-1917091b9e.zip/node_modules/lodash.foreach/",\ + "packageDependencies": [\ + ["lodash.foreach", "npm:4.5.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.get", [\ + ["npm:4.4.2", {\ + "packageLocation": "./.yarn/cache/lodash.get-npm-4.4.2-7bda64ed87-2a4925f6e8.zip/node_modules/lodash.get/",\ + "packageDependencies": [\ + ["lodash.get", "npm:4.4.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.isarguments", [\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/lodash.isarguments-npm-3.1.0-9e74d350b8-e5186d5fe0.zip/node_modules/lodash.isarguments/",\ + "packageDependencies": [\ + ["lodash.isarguments", "npm:3.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.isequal", [\ + ["npm:4.5.0", {\ + "packageLocation": "./.yarn/cache/lodash.isequal-npm-4.5.0-f8b0f64d63-82fc58a83a.zip/node_modules/lodash.isequal/",\ + "packageDependencies": [\ + ["lodash.isequal", "npm:4.5.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.isplainobject", [\ + ["npm:4.0.6", {\ + "packageLocation": "./.yarn/cache/lodash.isplainobject-npm-4.0.6-d73937742f-29c6351f28.zip/node_modules/lodash.isplainobject/",\ + "packageDependencies": [\ + ["lodash.isplainobject", "npm:4.0.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.isregexp", [\ + ["npm:3.0.5", {\ + "packageLocation": "./.yarn/cache/lodash.isregexp-npm-3.0.5-eb38f9af80-973f4887f0.zip/node_modules/lodash.isregexp/",\ + "packageDependencies": [\ + ["lodash.isregexp", "npm:3.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.map", [\ + ["npm:4.6.0", {\ + "packageLocation": "./.yarn/cache/lodash.map-npm-4.6.0-8013e2ad18-f1e69def35.zip/node_modules/lodash.map/",\ + "packageDependencies": [\ + ["lodash.map", "npm:4.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.merge", [\ + ["npm:4.6.2", {\ + "packageLocation": "./.yarn/cache/lodash.merge-npm-4.6.2-77cb4416bf-d0ea2dd009.zip/node_modules/lodash.merge/",\ + "packageDependencies": [\ + ["lodash.merge", "npm:4.6.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.pick", [\ + ["npm:4.4.0", {\ + "packageLocation": "./.yarn/cache/lodash.pick-npm-4.4.0-126deebf95-5a76778aa1.zip/node_modules/lodash.pick/",\ + "packageDependencies": [\ + ["lodash.pick", "npm:4.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.reduce", [\ + ["npm:4.6.0", {\ + "packageLocation": "./.yarn/cache/lodash.reduce-npm-4.6.0-a2e428f3e9-1cfefb3dd1.zip/node_modules/lodash.reduce/",\ + "packageDependencies": [\ + ["lodash.reduce", "npm:4.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.reject", [\ + ["npm:4.6.0", {\ + "packageLocation": "./.yarn/cache/lodash.reject-npm-4.6.0-3ffbb26ce0-ca47f52dbe.zip/node_modules/lodash.reject/",\ + "packageDependencies": [\ + ["lodash.reject", "npm:4.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.some", [\ + ["npm:4.6.0", {\ + "packageLocation": "./.yarn/cache/lodash.some-npm-4.6.0-645cee43a3-4e686a2f73.zip/node_modules/lodash.some/",\ + "packageDependencies": [\ + ["lodash.some", "npm:4.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lodash.union", [\ + ["npm:4.6.0", {\ + "packageLocation": "./.yarn/cache/lodash.union-npm-4.6.0-8c9e2d9292-175f5786ef.zip/node_modules/lodash.union/",\ + "packageDependencies": [\ + ["lodash.union", "npm:4.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lowercase-keys", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/lowercase-keys-npm-2.0.0-1876065a32-1c233d2da3.zip/node_modules/lowercase-keys/",\ + "packageDependencies": [\ + ["lowercase-keys", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/lowercase-keys-npm-3.0.0-f8c4730215-67a3f81409.zip/node_modules/lowercase-keys/",\ + "packageDependencies": [\ + ["lowercase-keys", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.1", {\ + "packageLocation": "./.yarn/cache/lowercase-keys-npm-4.0.1-e6e81ece73-f9b3f00fc1.zip/node_modules/lowercase-keys/",\ + "packageDependencies": [\ + ["lowercase-keys", "npm:4.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["lru-cache", [\ + ["npm:10.0.0", {\ + "packageLocation": "./.yarn/cache/lru-cache-npm-10.0.0-256d74bb20-590e00d6cc.zip/node_modules/lru-cache/",\ + "packageDependencies": [\ + ["lru-cache", "npm:10.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:10.0.2", {\ + "packageLocation": "./.yarn/cache/lru-cache-npm-10.0.2-fcff47e16f-a675b71a19.zip/node_modules/lru-cache/",\ + "packageDependencies": [\ + ["lru-cache", "npm:10.0.2"],\ + ["semver", "npm:7.5.4"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:10.4.3", {\ + "packageLocation": "./.yarn/cache/lru-cache-npm-10.4.3-30c10b861a-e6e9026736.zip/node_modules/lru-cache/",\ + "packageDependencies": [\ + ["lru-cache", "npm:10.4.3"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:11.3.6", {\ + "packageLocation": "./.yarn/cache/lru-cache-npm-11.3.6-a27889e3d2-d69ab55277.zip/node_modules/lru-cache/",\ + "packageDependencies": [\ + ["lru-cache", "npm:11.3.6"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.1.5", {\ + "packageLocation": "./.yarn/cache/lru-cache-npm-4.1.5-ede304cc43-9ec7d73f11.zip/node_modules/lru-cache/",\ + "packageDependencies": [\ + ["lru-cache", "npm:4.1.5"],\ + ["pseudomap", "npm:1.0.2"],\ + ["yallist", "npm:2.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:6.0.0", {\ + "packageLocation": "./.yarn/cache/lru-cache-npm-6.0.0-b4c8668fe1-fc1fe2ee20.zip/node_modules/lru-cache/",\ + "packageDependencies": [\ + ["lru-cache", "npm:6.0.0"],\ + ["yallist", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.18.3", {\ + "packageLocation": "./.yarn/cache/lru-cache-npm-7.18.3-e68be5b11c-6029ca5aba.zip/node_modules/lru-cache/",\ + "packageDependencies": [\ + ["lru-cache", "npm:7.18.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["luxon", [\ + ["npm:3.3.0", {\ + "packageLocation": "./.yarn/cache/luxon-npm-3.3.0-bdbae9bfd5-ff60904401.zip/node_modules/luxon/",\ + "packageDependencies": [\ + ["luxon", "npm:3.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["magic-string", [\ + ["npm:0.30.21", {\ + "packageLocation": "./.yarn/cache/magic-string-npm-0.30.21-9a226cb21e-57d5691f41.zip/node_modules/magic-string/",\ + "packageDependencies": [\ + ["@jridgewell/sourcemap-codec", "npm:1.5.5"],\ + ["magic-string", "npm:0.30.21"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["mailcheck", [\ + ["npm:1.1.1", {\ + "packageLocation": "./.yarn/cache/mailcheck-npm-1.1.1-2b947214fa-7dda570a97.zip/node_modules/mailcheck/",\ + "packageDependencies": [\ + ["mailcheck", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["make-dir", [\ + ["npm:1.3.0", {\ + "packageLocation": "./.yarn/cache/make-dir-npm-1.3.0-692810d225-c564f6e7bb.zip/node_modules/make-dir/",\ + "packageDependencies": [\ + ["make-dir", "npm:1.3.0"],\ + ["pify", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["make-fetch-happen", [\ + ["npm:11.1.1", {\ + "packageLocation": "./.yarn/cache/make-fetch-happen-npm-11.1.1-f32b79aaaa-b4b442cfaa.zip/node_modules/make-fetch-happen/",\ + "packageDependencies": [\ + ["agentkeepalive", "npm:4.3.0"],\ + ["cacache", "npm:17.1.3"],\ + ["http-cache-semantics", "npm:4.1.1"],\ + ["http-proxy-agent", "npm:5.0.0"],\ + ["https-proxy-agent", "npm:5.0.1"],\ + ["is-lambda", "npm:1.0.1"],\ + ["lru-cache", "npm:7.18.3"],\ + ["make-fetch-happen", "npm:11.1.1"],\ + ["minipass", "npm:5.0.0"],\ + ["minipass-fetch", "npm:3.0.3"],\ + ["minipass-flush", "npm:1.0.5"],\ + ["minipass-pipeline", "npm:1.2.4"],\ + ["negotiator", "npm:0.6.3"],\ + ["promise-retry", "npm:2.0.1"],\ + ["socks-proxy-agent", "npm:7.0.0"],\ + ["ssri", "npm:10.0.4"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:14.0.3", {\ + "packageLocation": "./.yarn/cache/make-fetch-happen-npm-14.0.3-23b30e8691-fce0385840.zip/node_modules/make-fetch-happen/",\ + "packageDependencies": [\ + ["@npmcli/agent", "npm:3.0.0"],\ + ["cacache", "npm:19.0.1"],\ + ["http-cache-semantics", "npm:4.1.1"],\ + ["make-fetch-happen", "npm:14.0.3"],\ + ["minipass", "npm:7.0.4"],\ + ["minipass-fetch", "npm:4.0.1"],\ + ["minipass-flush", "npm:1.0.5"],\ + ["minipass-pipeline", "npm:1.2.4"],\ + ["negotiator", "npm:1.0.0"],\ + ["proc-log", "npm:5.0.0"],\ + ["promise-retry", "npm:2.0.1"],\ + ["ssri", "npm:12.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["matter-js", [\ + ["npm:0.20.0", {\ + "packageLocation": "./.yarn/cache/matter-js-npm-0.20.0-7d3bb65115-db275f6b7a.zip/node_modules/matter-js/",\ + "packageDependencies": [\ + ["matter-js", "npm:0.20.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["media-typer", [\ + ["npm:0.3.0", {\ + "packageLocation": "./.yarn/cache/media-typer-npm-0.3.0-8674f8f0f5-38e0984db3.zip/node_modules/media-typer/",\ + "packageDependencies": [\ + ["media-typer", "npm:0.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["merge-stream", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/merge-stream-npm-2.0.0-2ac83efea5-6fa4dcc8d8.zip/node_modules/merge-stream/",\ + "packageDependencies": [\ + ["merge-stream", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["merge2", [\ + ["npm:1.4.1", {\ + "packageLocation": "./.yarn/cache/merge2-npm-1.4.1-a2507bd06c-7268db63ed.zip/node_modules/merge2/",\ + "packageDependencies": [\ + ["merge2", "npm:1.4.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["methods", [\ + ["npm:1.1.2", {\ + "packageLocation": "./.yarn/cache/methods-npm-1.1.2-92f6fdb39b-a385dd974f.zip/node_modules/methods/",\ + "packageDependencies": [\ + ["methods", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["mfm-js", [\ + ["npm:0.25.0", {\ + "packageLocation": "./.yarn/cache/mfm-js-npm-0.25.0-e4594c8ee9-d46db0f988.zip/node_modules/mfm-js/",\ + "packageDependencies": [\ + ["@twemoji/parser", "npm:16.0.0"],\ + ["mfm-js", "npm:0.25.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["micromatch", [\ + ["npm:4.0.5", {\ + "packageLocation": "./.yarn/cache/micromatch-npm-4.0.5-cfab5d7669-a749888789.zip/node_modules/micromatch/",\ + "packageDependencies": [\ + ["braces", "npm:3.0.2"],\ + ["micromatch", "npm:4.0.5"],\ + ["picomatch", "npm:2.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["mime-db", [\ + ["npm:1.52.0", {\ + "packageLocation": "./.yarn/cache/mime-db-npm-1.52.0-b5371d6fd2-54bb60bf39.zip/node_modules/mime-db/",\ + "packageDependencies": [\ + ["mime-db", "npm:1.52.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["mime-types", [\ + ["npm:2.1.35", {\ + "packageLocation": "./.yarn/cache/mime-types-npm-2.1.35-dd9ea9f3e2-89aa9651b6.zip/node_modules/mime-types/",\ + "packageDependencies": [\ + ["mime-db", "npm:1.52.0"],\ + ["mime-types", "npm:2.1.35"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["mimic-fn", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/mimic-fn-npm-2.1.0-4fbeb3abb4-d2421a3444.zip/node_modules/mimic-fn/",\ + "packageDependencies": [\ + ["mimic-fn", "npm:2.1.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/mimic-fn-npm-4.0.0-feaeda79f7-995dcece15.zip/node_modules/mimic-fn/",\ + "packageDependencies": [\ + ["mimic-fn", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["mimic-response", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/mimic-response-npm-1.0.1-f6f85dde84-034c78753b.zip/node_modules/mimic-response/",\ + "packageDependencies": [\ + ["mimic-response", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/mimic-response-npm-3.1.0-a4a24b4e96-7e71904761.zip/node_modules/mimic-response/",\ + "packageDependencies": [\ + ["mimic-response", "npm:3.1.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/mimic-response-npm-4.0.0-c080547e26-33b804cc96.zip/node_modules/mimic-response/",\ + "packageDependencies": [\ + ["mimic-response", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["minimalistic-assert", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/minimalistic-assert-npm-1.0.1-dc8bb23d29-cc7974a926.zip/node_modules/minimalistic-assert/",\ + "packageDependencies": [\ + ["minimalistic-assert", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["minimatch", [\ + ["npm:10.2.5", {\ + "packageLocation": "./.yarn/cache/minimatch-npm-10.2.5-f1c8297822-19e87a931a.zip/node_modules/minimatch/",\ + "packageDependencies": [\ + ["brace-expansion", "npm:5.0.6"],\ + ["minimatch", "npm:10.2.5"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.1.2", {\ + "packageLocation": "./.yarn/cache/minimatch-npm-3.1.2-9405269906-e0b25b04cd.zip/node_modules/minimatch/",\ + "packageDependencies": [\ + ["brace-expansion", "npm:1.1.11"],\ + ["minimatch", "npm:3.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.1.6", {\ + "packageLocation": "./.yarn/cache/minimatch-npm-5.1.6-1e71429f4c-126b36485b.zip/node_modules/minimatch/",\ + "packageDependencies": [\ + ["brace-expansion", "npm:2.0.1"],\ + ["minimatch", "npm:5.1.6"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:9.0.1", {\ + "packageLocation": "./.yarn/cache/minimatch-npm-9.0.1-277fdc6fbd-b4e98f4dc7.zip/node_modules/minimatch/",\ + "packageDependencies": [\ + ["brace-expansion", "npm:2.0.1"],\ + ["minimatch", "npm:9.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:9.0.3", {\ + "packageLocation": "./.yarn/cache/minimatch-npm-9.0.3-69d7d6fad5-c81b47d281.zip/node_modules/minimatch/",\ + "packageDependencies": [\ + ["brace-expansion", "npm:2.0.1"],\ + ["minimatch", "npm:9.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["minimist", [\ + ["npm:1.2.8", {\ + "packageLocation": "./.yarn/cache/minimist-npm-1.2.8-d7af7b1dce-908491b6cc.zip/node_modules/minimist/",\ + "packageDependencies": [\ + ["minimist", "npm:1.2.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["minipass", [\ + ["npm:3.3.6", {\ + "packageLocation": "./.yarn/cache/minipass-npm-3.3.6-b8d93a945b-a5c6ef069f.zip/node_modules/minipass/",\ + "packageDependencies": [\ + ["minipass", "npm:3.3.6"],\ + ["yallist", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/minipass-npm-5.0.0-c64fb63c92-61682162d2.zip/node_modules/minipass/",\ + "packageDependencies": [\ + ["minipass", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.2", {\ + "packageLocation": "./.yarn/cache/minipass-npm-7.0.2-baa42a5a34-25d3afc74e.zip/node_modules/minipass/",\ + "packageDependencies": [\ + ["minipass", "npm:7.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.4", {\ + "packageLocation": "./.yarn/cache/minipass-npm-7.0.4-eacb4e042e-e864bd02ce.zip/node_modules/minipass/",\ + "packageDependencies": [\ + ["minipass", "npm:7.0.4"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.1.2", {\ + "packageLocation": "./.yarn/cache/minipass-npm-7.1.2-3a5327d36d-c25f0ee819.zip/node_modules/minipass/",\ + "packageDependencies": [\ + ["minipass", "npm:7.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.1.3", {\ + "packageLocation": "./.yarn/cache/minipass-npm-7.1.3-b73a16498d-175e4d5e20.zip/node_modules/minipass/",\ + "packageDependencies": [\ + ["minipass", "npm:7.1.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["minipass-collect", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/minipass-collect-npm-1.0.2-3b4676eab5-14df761028.zip/node_modules/minipass-collect/",\ + "packageDependencies": [\ + ["minipass", "npm:3.3.6"],\ + ["minipass-collect", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/minipass-collect-npm-2.0.1-73d3907e40-b251bceea6.zip/node_modules/minipass-collect/",\ + "packageDependencies": [\ + ["minipass", "npm:7.0.4"],\ + ["minipass-collect", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["minipass-fetch", [\ + ["npm:3.0.3", {\ + "packageLocation": "./.yarn/cache/minipass-fetch-npm-3.0.3-2c4966d142-045339fa8f.zip/node_modules/minipass-fetch/",\ + "packageDependencies": [\ + ["encoding", "npm:0.1.13"],\ + ["minipass", "npm:5.0.0"],\ + ["minipass-fetch", "npm:3.0.3"],\ + ["minipass-sized", "npm:1.0.3"],\ + ["minizlib", "npm:2.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.1", {\ + "packageLocation": "./.yarn/cache/minipass-fetch-npm-4.0.1-ce1d15e957-7ddfebdbb8.zip/node_modules/minipass-fetch/",\ + "packageDependencies": [\ + ["encoding", "npm:0.1.13"],\ + ["minipass", "npm:7.0.4"],\ + ["minipass-fetch", "npm:4.0.1"],\ + ["minipass-sized", "npm:1.0.3"],\ + ["minizlib", "npm:3.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["minipass-flush", [\ + ["npm:1.0.5", {\ + "packageLocation": "./.yarn/cache/minipass-flush-npm-1.0.5-efe79d9826-56269a0b22.zip/node_modules/minipass-flush/",\ + "packageDependencies": [\ + ["minipass", "npm:3.3.6"],\ + ["minipass-flush", "npm:1.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["minipass-pipeline", [\ + ["npm:1.2.4", {\ + "packageLocation": "./.yarn/cache/minipass-pipeline-npm-1.2.4-5924cb077f-b14240dac0.zip/node_modules/minipass-pipeline/",\ + "packageDependencies": [\ + ["minipass", "npm:3.3.6"],\ + ["minipass-pipeline", "npm:1.2.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["minipass-sized", [\ + ["npm:1.0.3", {\ + "packageLocation": "./.yarn/cache/minipass-sized-npm-1.0.3-306d86f432-40982d8d83.zip/node_modules/minipass-sized/",\ + "packageDependencies": [\ + ["minipass", "npm:3.3.6"],\ + ["minipass-sized", "npm:1.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["minizlib", [\ + ["npm:2.1.2", {\ + "packageLocation": "./.yarn/cache/minizlib-npm-2.1.2-ea89cd0cfb-ae0f45436f.zip/node_modules/minizlib/",\ + "packageDependencies": [\ + ["minipass", "npm:3.3.6"],\ + ["minizlib", "npm:2.1.2"],\ + ["yallist", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.2", {\ + "packageLocation": "./.yarn/cache/minizlib-npm-3.0.2-f56e815013-c075bed159.zip/node_modules/minizlib/",\ + "packageDependencies": [\ + ["minipass", "npm:7.1.2"],\ + ["minizlib", "npm:3.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["mixly", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/mixly-npm-1.0.0-760f4e5ea8-a8acb64803.zip/node_modules/mixly/",\ + "packageDependencies": [\ + ["fulcon", "npm:1.0.2"],\ + ["mixly", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["mkdirp", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/cache/mkdirp-npm-1.0.4-37f6ef56b9-d71b8dcd4b.zip/node_modules/mkdirp/",\ + "packageDependencies": [\ + ["mkdirp", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.1.6", {\ + "packageLocation": "./.yarn/cache/mkdirp-npm-2.1.6-832c38f12a-4672fadb94.zip/node_modules/mkdirp/",\ + "packageDependencies": [\ + ["mkdirp", "npm:2.1.6"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.1", {\ + "packageLocation": "./.yarn/cache/mkdirp-npm-3.0.1-f94bfa769e-16fd79c286.zip/node_modules/mkdirp/",\ + "packageDependencies": [\ + ["mkdirp", "npm:3.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["mlly", [\ + ["npm:1.8.2", {\ + "packageLocation": "./.yarn/cache/mlly-npm-1.8.2-b1760a820e-e13b79edb1.zip/node_modules/mlly/",\ + "packageDependencies": [\ + ["acorn", "npm:8.16.0"],\ + ["mlly", "npm:1.8.2"],\ + ["pathe", "npm:2.0.3"],\ + ["pkg-types", "npm:1.3.1"],\ + ["ufo", "npm:1.6.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["moment", [\ + ["npm:2.29.4", {\ + "packageLocation": "./.yarn/cache/moment-npm-2.29.4-902943305d-157c5af5a0.zip/node_modules/moment/",\ + "packageDependencies": [\ + ["moment", "npm:2.29.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ms", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/ms-npm-2.0.0-9e1101a471-0e6a22b8b7.zip/node_modules/ms/",\ + "packageDependencies": [\ + ["ms", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.1.2", {\ + "packageLocation": "./.yarn/cache/ms-npm-2.1.2-ec0c1512ff-673cdb2c31.zip/node_modules/ms/",\ + "packageDependencies": [\ + ["ms", "npm:2.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.1.3", {\ + "packageLocation": "./.yarn/cache/ms-npm-2.1.3-81ff3cfac1-aa92de6080.zip/node_modules/ms/",\ + "packageDependencies": [\ + ["ms", "npm:2.1.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["msgpackr", [\ + ["npm:1.11.0", {\ + "packageLocation": "./.yarn/cache/msgpackr-npm-1.11.0-c075b2537e-e95edf511a.zip/node_modules/msgpackr/",\ + "packageDependencies": [\ + ["msgpackr", "npm:1.11.0"],\ + ["msgpackr-extract", "npm:3.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.11.2", {\ + "packageLocation": "./.yarn/cache/msgpackr-npm-1.11.2-a21c5db6f8-7602f1e91e.zip/node_modules/msgpackr/",\ + "packageDependencies": [\ + ["msgpackr", "npm:1.11.2"],\ + ["msgpackr-extract", "npm:3.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["msgpackr-extract", [\ + ["npm:3.0.2", {\ + "packageLocation": "./.yarn/unplugged/msgpackr-extract-npm-3.0.2-93e8773fad/node_modules/msgpackr-extract/",\ + "packageDependencies": [\ + ["@msgpackr-extract/msgpackr-extract-darwin-arm64", "npm:3.0.2"],\ + ["@msgpackr-extract/msgpackr-extract-darwin-x64", "npm:3.0.2"],\ + ["@msgpackr-extract/msgpackr-extract-linux-arm", "npm:3.0.2"],\ + ["@msgpackr-extract/msgpackr-extract-linux-arm64", "npm:3.0.2"],\ + ["@msgpackr-extract/msgpackr-extract-linux-x64", "npm:3.0.2"],\ + ["@msgpackr-extract/msgpackr-extract-win32-x64", "npm:3.0.2"],\ + ["msgpackr-extract", "npm:3.0.2"],\ + ["node-gyp", "npm:9.4.0"],\ + ["node-gyp-build-optional-packages", "npm:5.0.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["multer", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/multer-npm-2.1.1-f8d312dc4a-fb22868caa.zip/node_modules/multer/",\ + "packageDependencies": [\ + ["append-field", "npm:1.0.0"],\ + ["busboy", "npm:1.6.0"],\ + ["concat-stream", "npm:2.0.0"],\ + ["multer", "npm:2.1.1"],\ + ["type-is", "npm:1.6.18"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["mz", [\ + ["npm:2.7.0", {\ + "packageLocation": "./.yarn/cache/mz-npm-2.7.0-ec3cef4ec2-8427de0ece.zip/node_modules/mz/",\ + "packageDependencies": [\ + ["any-promise", "npm:1.3.0"],\ + ["mz", "npm:2.7.0"],\ + ["object-assign", "npm:4.1.1"],\ + ["thenify-all", "npm:1.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["nan", [\ + ["npm:2.22.2", {\ + "packageLocation": "./.yarn/unplugged/nan-npm-2.22.2-bb092bb459/node_modules/nan/",\ + "packageDependencies": [\ + ["nan", "npm:2.22.2"],\ + ["node-gyp", "npm:9.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["nanoid", [\ + ["npm:3.3.12", {\ + "packageLocation": "./.yarn/cache/nanoid-npm-3.3.12-41f8e0bb94-6eec280694.zip/node_modules/nanoid/",\ + "packageDependencies": [\ + ["nanoid", "npm:3.3.12"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.3.6", {\ + "packageLocation": "./.yarn/cache/nanoid-npm-3.3.6-e6d6ae7e71-67235c39d1.zip/node_modules/nanoid/",\ + "packageDependencies": [\ + ["nanoid", "npm:3.3.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["needle", [\ + ["npm:2.9.1", {\ + "packageLocation": "./.yarn/cache/needle-npm-2.9.1-f40e591726-ad8df9aff5.zip/node_modules/needle/",\ + "packageDependencies": [\ + ["debug", "virtual:4248438d52dae4f796f8ae7839807024b795f7c70ddf4cbb58a4d6330ce027df2b3c8481d65b7430d525478785f68212512cd0d537d63bda0f170167a0f757f6#npm:3.2.7"],\ + ["iconv-lite", "npm:0.4.24"],\ + ["needle", "npm:2.9.1"],\ + ["sax", "npm:1.2.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["negotiator", [\ + ["npm:0.6.3", {\ + "packageLocation": "./.yarn/cache/negotiator-npm-0.6.3-9d50e36171-2723fb822a.zip/node_modules/negotiator/",\ + "packageDependencies": [\ + ["negotiator", "npm:0.6.3"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/negotiator-npm-1.0.0-47d727e27e-b5734e8729.zip/node_modules/negotiator/",\ + "packageDependencies": [\ + ["negotiator", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["nested-property", [\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/nested-property-npm-4.0.0-017e2c909b-5653a67d68.zip/node_modules/nested-property/",\ + "packageDependencies": [\ + ["nested-property", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["netmask", [\ + ["npm:2.0.2", {\ + "packageLocation": "./.yarn/cache/netmask-npm-2.0.2-2299510a4d-375cabe898.zip/node_modules/netmask/",\ + "packageDependencies": [\ + ["netmask", "npm:2.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["next-tick", [\ + ["npm:1.1.0", {\ + "packageLocation": "./.yarn/cache/next-tick-npm-1.1.0-e0eb60d6a4-83b5cf3602.zip/node_modules/next-tick/",\ + "packageDependencies": [\ + ["next-tick", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["node-abort-controller", [\ + ["npm:3.1.1", {\ + "packageLocation": "./.yarn/cache/node-abort-controller-npm-3.1.1-e246ed42cd-0a2cdb7ec0.zip/node_modules/node-abort-controller/",\ + "packageDependencies": [\ + ["node-abort-controller", "npm:3.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["node-addon-api", [\ + ["npm:7.1.1", {\ + "packageLocation": "./.yarn/unplugged/node-addon-api-npm-7.1.1-bfb302df19/node_modules/node-addon-api/",\ + "packageDependencies": [\ + ["node-addon-api", "npm:7.1.1"],\ + ["node-gyp", "npm:9.4.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:8.7.0", {\ + "packageLocation": "./.yarn/unplugged/node-addon-api-npm-8.7.0-fdd958cf91/node_modules/node-addon-api/",\ + "packageDependencies": [\ + ["node-addon-api", "npm:8.7.0"],\ + ["node-gyp", "npm:9.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["node-domexception", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/node-domexception-npm-1.0.0-e1e813b76f-e332522f24.zip/node_modules/node-domexception/",\ + "packageDependencies": [\ + ["node-domexception", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["node-fetch", [\ + ["npm:2.7.0", {\ + "packageLocation": "./.yarn/cache/node-fetch-npm-2.7.0-587d57004e-b24f8a3dc9.zip/node_modules/node-fetch/",\ + "packageDependencies": [\ + ["node-fetch", "npm:2.7.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["npm:3.3.2", {\ + "packageLocation": "./.yarn/cache/node-fetch-npm-3.3.2-5267e015f2-24207ca8c8.zip/node_modules/node-fetch/",\ + "packageDependencies": [\ + ["data-uri-to-buffer", "npm:4.0.1"],\ + ["fetch-blob", "npm:3.2.0"],\ + ["formdata-polyfill", "npm:4.0.10"],\ + ["node-fetch", "npm:3.3.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["virtual:51e9f46b428ea6d95952b3989264341ce06b0a468128a1513d2a677665e3df49f50badc4ffe0876bf3a85788c6bc671492e31321ae54acc78e8c8cca85a3138d#npm:2.7.0", {\ + "packageLocation": "./.yarn/__virtual__/node-fetch-virtual-8eb4b58d32/0/cache/node-fetch-npm-2.7.0-587d57004e-b24f8a3dc9.zip/node_modules/node-fetch/",\ + "packageDependencies": [\ + ["@types/encoding", null],\ + ["encoding", null],\ + ["node-fetch", "virtual:51e9f46b428ea6d95952b3989264341ce06b0a468128a1513d2a677665e3df49f50badc4ffe0876bf3a85788c6bc671492e31321ae54acc78e8c8cca85a3138d#npm:2.7.0"],\ + ["whatwg-url", "npm:5.0.0"]\ + ],\ + "packagePeers": [\ + "@types/encoding",\ + "encoding"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["node-gyp", [\ + ["npm:11.2.0", {\ + "packageLocation": "./.yarn/unplugged/node-gyp-npm-11.2.0-36aeb0fa50/node_modules/node-gyp/",\ + "packageDependencies": [\ + ["env-paths", "npm:2.2.1"],\ + ["exponential-backoff", "npm:3.1.1"],\ + ["graceful-fs", "npm:4.2.11"],\ + ["make-fetch-happen", "npm:14.0.3"],\ + ["node-gyp", "npm:11.2.0"],\ + ["nopt", "npm:8.1.0"],\ + ["proc-log", "npm:5.0.0"],\ + ["semver", "npm:7.5.4"],\ + ["tar", "npm:7.4.3"],\ + ["tinyglobby", "npm:0.2.14"],\ + ["which", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:9.4.0", {\ + "packageLocation": "./.yarn/unplugged/node-gyp-npm-9.4.0-ebf5f5573e/node_modules/node-gyp/",\ + "packageDependencies": [\ + ["env-paths", "npm:2.2.1"],\ + ["exponential-backoff", "npm:3.1.1"],\ + ["glob", "npm:7.2.3"],\ + ["graceful-fs", "npm:4.2.11"],\ + ["make-fetch-happen", "npm:11.1.1"],\ + ["node-gyp", "npm:9.4.0"],\ + ["nopt", "npm:6.0.0"],\ + ["npmlog", "npm:6.0.2"],\ + ["rimraf", "npm:3.0.2"],\ + ["semver", "npm:7.5.4"],\ + ["tar", "npm:6.1.15"],\ + ["which", "npm:2.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["node-gyp-build", [\ + ["npm:4.6.0", {\ + "packageLocation": "./.yarn/cache/node-gyp-build-npm-4.6.0-5434aac3e5-c8b57abe5e.zip/node_modules/node-gyp-build/",\ + "packageDependencies": [\ + ["node-gyp-build", "npm:4.6.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.8.4", {\ + "packageLocation": "./.yarn/cache/node-gyp-build-npm-4.8.4-106c2a0b4f-6a7d62289d.zip/node_modules/node-gyp-build/",\ + "packageDependencies": [\ + ["node-gyp-build", "npm:4.8.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["node-gyp-build-optional-packages", [\ + ["npm:5.0.7", {\ + "packageLocation": "./.yarn/cache/node-gyp-build-optional-packages-npm-5.0.7-40f21a5d68-f61780b83e.zip/node_modules/node-gyp-build-optional-packages/",\ + "packageDependencies": [\ + ["node-gyp-build-optional-packages", "npm:5.0.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["nodemailer", [\ + ["npm:6.9.3", {\ + "packageLocation": "./.yarn/cache/nodemailer-npm-6.9.3-8a4e39e54a-01ba9688f3.zip/node_modules/nodemailer/",\ + "packageDependencies": [\ + ["nodemailer", "npm:6.9.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["nofilter", [\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/nofilter-npm-3.1.0-3c5ba47d92-f63d87231d.zip/node_modules/nofilter/",\ + "packageDependencies": [\ + ["nofilter", "npm:3.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["nopt", [\ + ["npm:6.0.0", {\ + "packageLocation": "./.yarn/cache/nopt-npm-6.0.0-5ea8050815-3c1128e07c.zip/node_modules/nopt/",\ + "packageDependencies": [\ + ["abbrev", "npm:1.1.1"],\ + ["nopt", "npm:6.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:8.1.0", {\ + "packageLocation": "./.yarn/cache/nopt-npm-8.1.0-5570ef63cd-26ab456c51.zip/node_modules/nopt/",\ + "packageDependencies": [\ + ["abbrev", "npm:3.0.1"],\ + ["nopt", "npm:8.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["normalize-path", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/normalize-path-npm-3.0.0-658ba7d77f-88eeb4da89.zip/node_modules/normalize-path/",\ + "packageDependencies": [\ + ["normalize-path", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["normalize-url", [\ + ["npm:6.1.0", {\ + "packageLocation": "./.yarn/cache/normalize-url-npm-6.1.0-b95bc12ece-5ae699402c.zip/node_modules/normalize-url/",\ + "packageDependencies": [\ + ["normalize-url", "npm:6.1.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:8.1.1", {\ + "packageLocation": "./.yarn/cache/normalize-url-npm-8.1.1-28828b84df-a96519b536.zip/node_modules/normalize-url/",\ + "packageDependencies": [\ + ["normalize-url", "npm:8.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["npm-run-path", [\ + ["npm:2.0.2", {\ + "packageLocation": "./.yarn/cache/npm-run-path-npm-2.0.2-96c8b48857-acd5ad8164.zip/node_modules/npm-run-path/",\ + "packageDependencies": [\ + ["npm-run-path", "npm:2.0.2"],\ + ["path-key", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.1", {\ + "packageLocation": "./.yarn/cache/npm-run-path-npm-4.0.1-7aebd8bab3-5374c0cea4.zip/node_modules/npm-run-path/",\ + "packageDependencies": [\ + ["npm-run-path", "npm:4.0.1"],\ + ["path-key", "npm:3.1.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.1.0", {\ + "packageLocation": "./.yarn/cache/npm-run-path-npm-5.1.0-79c0668d42-dc184eb5ec.zip/node_modules/npm-run-path/",\ + "packageDependencies": [\ + ["npm-run-path", "npm:5.1.0"],\ + ["path-key", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["npmlog", [\ + ["npm:6.0.2", {\ + "packageLocation": "./.yarn/cache/npmlog-npm-6.0.2-e0e69455c7-82b123677e.zip/node_modules/npmlog/",\ + "packageDependencies": [\ + ["are-we-there-yet", "npm:3.0.1"],\ + ["console-control-strings", "npm:1.1.0"],\ + ["gauge", "npm:4.0.4"],\ + ["npmlog", "npm:6.0.2"],\ + ["set-blocking", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["nth-check", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/nth-check-npm-1.0.2-3f6d0d22eb-59e115fdd7.zip/node_modules/nth-check/",\ + "packageDependencies": [\ + ["boolbase", "npm:1.0.0"],\ + ["nth-check", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["nwsapi", [\ + ["npm:2.2.23", {\ + "packageLocation": "./.yarn/cache/nwsapi-npm-2.2.23-aa3710d724-aa4a570039.zip/node_modules/nwsapi/",\ + "packageDependencies": [\ + ["nwsapi", "npm:2.2.23"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["oauth", [\ + ["npm:0.10.0", {\ + "packageLocation": "./.yarn/cache/oauth-npm-0.10.0-74adff7683-3568089799.zip/node_modules/oauth/",\ + "packageDependencies": [\ + ["oauth", "npm:0.10.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["object-assign", [\ + ["npm:4.1.1", {\ + "packageLocation": "./.yarn/cache/object-assign-npm-4.1.1-1004ad6dec-fcc6e4ea8c.zip/node_modules/object-assign/",\ + "packageDependencies": [\ + ["object-assign", "npm:4.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["object-inspect", [\ + ["npm:1.12.3", {\ + "packageLocation": "./.yarn/cache/object-inspect-npm-1.12.3-1e7d20f5ff-532b0036f0.zip/node_modules/object-inspect/",\ + "packageDependencies": [\ + ["object-inspect", "npm:1.12.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["oblivious-set", [\ + ["npm:1.1.1", {\ + "packageLocation": "./.yarn/cache/oblivious-set-npm-1.1.1-cf2d419961-ea1830c38a.zip/node_modules/oblivious-set/",\ + "packageDependencies": [\ + ["oblivious-set", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["obuf", [\ + ["npm:1.1.2", {\ + "packageLocation": "./.yarn/cache/obuf-npm-1.1.2-8db5fae8dd-53ff4ab3a1.zip/node_modules/obuf/",\ + "packageDependencies": [\ + ["obuf", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["on-finished", [\ + ["npm:2.4.1", {\ + "packageLocation": "./.yarn/cache/on-finished-npm-2.4.1-907af70f88-8e81472c50.zip/node_modules/on-finished/",\ + "packageDependencies": [\ + ["ee-first", "npm:1.1.1"],\ + ["on-finished", "npm:2.4.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["once", [\ + ["npm:1.4.0", {\ + "packageLocation": "./.yarn/cache/once-npm-1.4.0-ccf03ef07a-cd0a885013.zip/node_modules/once/",\ + "packageDependencies": [\ + ["once", "npm:1.4.0"],\ + ["wrappy", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["onetime", [\ + ["npm:5.1.2", {\ + "packageLocation": "./.yarn/cache/onetime-npm-5.1.2-3ed148fa42-e9fd0695a0.zip/node_modules/onetime/",\ + "packageDependencies": [\ + ["mimic-fn", "npm:2.1.0"],\ + ["onetime", "npm:5.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:6.0.0", {\ + "packageLocation": "./.yarn/cache/onetime-npm-6.0.0-4f3684e29a-0846ce78e4.zip/node_modules/onetime/",\ + "packageDependencies": [\ + ["mimic-fn", "npm:4.0.0"],\ + ["onetime", "npm:6.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["only", [\ + ["npm:0.0.2", {\ + "packageLocation": "./.yarn/cache/only-npm-0.0.2-122402a3f9-e2ad03e486.zip/node_modules/only/",\ + "packageDependencies": [\ + ["only", "npm:0.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["opencollective-postinstall", [\ + ["npm:2.0.3", {\ + "packageLocation": "./.yarn/cache/opencollective-postinstall-npm-2.0.3-954643c36b-69d6377808.zip/node_modules/opencollective-postinstall/",\ + "packageDependencies": [\ + ["opencollective-postinstall", "npm:2.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["opentype.js", [\ + ["npm:0.4.11", {\ + "packageLocation": "./.yarn/cache/opentype.js-npm-0.4.11-bb2895af0c-436887f913.zip/node_modules/opentype.js/",\ + "packageDependencies": [\ + ["opentype.js", "npm:0.4.11"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["os-filter-obj", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/os-filter-obj-npm-2.0.0-bcc0bc3165-08808a109b.zip/node_modules/os-filter-obj/",\ + "packageDependencies": [\ + ["arch", "npm:2.2.0"],\ + ["os-filter-obj", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["os-utils", [\ + ["npm:0.0.14", {\ + "packageLocation": "./.yarn/cache/os-utils-npm-0.0.14-139244a17c-6436c9af98.zip/node_modules/os-utils/",\ + "packageDependencies": [\ + ["os-utils", "npm:0.0.14"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["otpauth", [\ + ["npm:9.1.4", {\ + "packageLocation": "./.yarn/cache/otpauth-npm-9.1.4-f92984b665-a5a1a0ffa3.zip/node_modules/otpauth/",\ + "packageDependencies": [\ + ["jssha", "npm:3.3.0"],\ + ["otpauth", "npm:9.1.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["p-cancelable", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/p-cancelable-npm-2.1.1-9388305f02-7f1b64db17.zip/node_modules/p-cancelable/",\ + "packageDependencies": [\ + ["p-cancelable", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["p-finally", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/p-finally-npm-1.0.0-35fbaa57c6-93a654c53d.zip/node_modules/p-finally/",\ + "packageDependencies": [\ + ["p-finally", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["p-limit", [\ + ["npm:2.3.0", {\ + "packageLocation": "./.yarn/cache/p-limit-npm-2.3.0-94a0310039-84ff17f1a3.zip/node_modules/p-limit/",\ + "packageDependencies": [\ + ["p-limit", "npm:2.3.0"],\ + ["p-try", "npm:2.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["p-locate", [\ + ["npm:4.1.0", {\ + "packageLocation": "./.yarn/cache/p-locate-npm-4.1.0-eec6872537-513bd14a45.zip/node_modules/p-locate/",\ + "packageDependencies": [\ + ["p-limit", "npm:2.3.0"],\ + ["p-locate", "npm:4.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["p-map", [\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/p-map-npm-4.0.0-4677ae07c7-7ba4a2b1e2.zip/node_modules/p-map/",\ + "packageDependencies": [\ + ["aggregate-error", "npm:3.1.0"],\ + ["p-map", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.3", {\ + "packageLocation": "./.yarn/cache/p-map-npm-7.0.3-93bbec0d8c-2ef48ccfc6.zip/node_modules/p-map/",\ + "packageDependencies": [\ + ["p-map", "npm:7.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["p-queue", [\ + ["npm:6.6.2", {\ + "packageLocation": "./.yarn/cache/p-queue-npm-6.6.2-b173c5bfa8-60fe227ffc.zip/node_modules/p-queue/",\ + "packageDependencies": [\ + ["eventemitter3", "npm:4.0.7"],\ + ["p-queue", "npm:6.6.2"],\ + ["p-timeout", "npm:3.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["p-timeout", [\ + ["npm:3.2.0", {\ + "packageLocation": "./.yarn/cache/p-timeout-npm-3.2.0-7fdb33f733-3dd0eaa048.zip/node_modules/p-timeout/",\ + "packageDependencies": [\ + ["p-finally", "npm:1.0.0"],\ + ["p-timeout", "npm:3.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["p-try", [\ + ["npm:2.2.0", {\ + "packageLocation": "./.yarn/cache/p-try-npm-2.2.0-e0390dbaf8-f8a8e9a769.zip/node_modules/p-try/",\ + "packageDependencies": [\ + ["p-try", "npm:2.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["packet-reader", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/packet-reader-npm-1.0.0-e93c92246b-8504cc8c32.zip/node_modules/packet-reader/",\ + "packageDependencies": [\ + ["packet-reader", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pako", [\ + ["npm:0.2.9", {\ + "packageLocation": "./.yarn/cache/pako-npm-0.2.9-c88ac0d326-627c6842e9.zip/node_modules/pako/",\ + "packageDependencies": [\ + ["pako", "npm:0.2.9"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["paralint", [\ + ["npm:1.2.1", {\ + "packageLocation": "./.yarn/cache/paralint-npm-1.2.1-f5edc920f2-d5c2df7905.zip/node_modules/paralint/",\ + "packageDependencies": [\ + ["@npmcli/promise-spawn", "npm:6.0.2"],\ + ["dargs", "npm:8.1.0"],\ + ["fast-glob", "npm:3.3.1"],\ + ["minimist", "npm:1.2.8"],\ + ["paralint", "npm:1.2.1"],\ + ["tslib", "npm:2.6.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["parse-duration", [\ + ["npm:1.1.0", {\ + "packageLocation": "./.yarn/cache/parse-duration-npm-1.1.0-cb12528e2a-c26ab1e3fd.zip/node_modules/parse-duration/",\ + "packageDependencies": [\ + ["parse-duration", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["parse-srcset", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/parse-srcset-npm-1.0.2-8acc142245-d40c131cfc.zip/node_modules/parse-srcset/",\ + "packageDependencies": [\ + ["parse-srcset", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["parse5", [\ + ["npm:5.1.1", {\ + "packageLocation": "./.yarn/cache/parse5-npm-5.1.1-8e63d82cff-5b509744cf.zip/node_modules/parse5/",\ + "packageDependencies": [\ + ["parse5", "npm:5.1.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:6.0.1", {\ + "packageLocation": "./.yarn/cache/parse5-npm-6.0.1-70a35a494a-dfb110581f.zip/node_modules/parse5/",\ + "packageDependencies": [\ + ["parse5", "npm:6.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.1.2", {\ + "packageLocation": "./.yarn/cache/parse5-npm-7.1.2-aa9a92c270-3c86806bb0.zip/node_modules/parse5/",\ + "packageDependencies": [\ + ["entities", "npm:4.5.0"],\ + ["parse5", "npm:7.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.3.0", {\ + "packageLocation": "./.yarn/cache/parse5-npm-7.3.0-b0410074a3-b0e48be20b.zip/node_modules/parse5/",\ + "packageDependencies": [\ + ["entities", "npm:6.0.1"],\ + ["parse5", "npm:7.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["parse5-htmlparser2-tree-adapter", [\ + ["npm:6.0.1", {\ + "packageLocation": "./.yarn/cache/parse5-htmlparser2-tree-adapter-npm-6.0.1-60b4888f75-3400a2cd1a.zip/node_modules/parse5-htmlparser2-tree-adapter/",\ + "packageDependencies": [\ + ["parse5", "npm:6.0.1"],\ + ["parse5-htmlparser2-tree-adapter", "npm:6.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["parseurl", [\ + ["npm:1.3.3", {\ + "packageLocation": "./.yarn/cache/parseurl-npm-1.3.3-1542397e00-407cee8e0a.zip/node_modules/parseurl/",\ + "packageDependencies": [\ + ["parseurl", "npm:1.3.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["passthrough-counter", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/passthrough-counter-npm-1.0.0-cbb2a89ced-942a0addeb.zip/node_modules/passthrough-counter/",\ + "packageDependencies": [\ + ["passthrough-counter", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["path-exists", [\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/path-exists-npm-4.0.0-e9e4f63eb0-505807199d.zip/node_modules/path-exists/",\ + "packageDependencies": [\ + ["path-exists", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["path-expression-matcher", [\ + ["npm:1.5.0", {\ + "packageLocation": "./.yarn/cache/path-expression-matcher-npm-1.5.0-73d258a112-28303bb9ee.zip/node_modules/path-expression-matcher/",\ + "packageDependencies": [\ + ["path-expression-matcher", "npm:1.5.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["path-is-absolute", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/path-is-absolute-npm-1.0.1-31bc695ffd-060840f92c.zip/node_modules/path-is-absolute/",\ + "packageDependencies": [\ + ["path-is-absolute", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["path-key", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/path-key-npm-2.0.1-b1a971833d-6e654864e3.zip/node_modules/path-key/",\ + "packageDependencies": [\ + ["path-key", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.1.1", {\ + "packageLocation": "./.yarn/cache/path-key-npm-3.1.1-0e66ea8321-55cd7a9dd4.zip/node_modules/path-key/",\ + "packageDependencies": [\ + ["path-key", "npm:3.1.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/path-key-npm-4.0.0-2bce99f089-8e6c314ae6.zip/node_modules/path-key/",\ + "packageDependencies": [\ + ["path-key", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["path-parse", [\ + ["npm:1.0.7", {\ + "packageLocation": "./.yarn/cache/path-parse-npm-1.0.7-09564527b7-49abf3d811.zip/node_modules/path-parse/",\ + "packageDependencies": [\ + ["path-parse", "npm:1.0.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["path-scurry", [\ + ["npm:1.10.1", {\ + "packageLocation": "./.yarn/cache/path-scurry-npm-1.10.1-52bd946f2e-eebfb8304f.zip/node_modules/path-scurry/",\ + "packageDependencies": [\ + ["lru-cache", "npm:10.0.0"],\ + ["minipass", "npm:7.0.2"],\ + ["path-scurry", "npm:1.10.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.2", {\ + "packageLocation": "./.yarn/cache/path-scurry-npm-2.0.2-f10aa6a77e-2b4257422b.zip/node_modules/path-scurry/",\ + "packageDependencies": [\ + ["lru-cache", "npm:11.3.6"],\ + ["minipass", "npm:7.1.2"],\ + ["path-scurry", "npm:2.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["path-to-regexp", [\ + ["npm:6.2.1", {\ + "packageLocation": "./.yarn/cache/path-to-regexp-npm-6.2.1-8ebfe03654-1e266be712.zip/node_modules/path-to-regexp/",\ + "packageDependencies": [\ + ["path-to-regexp", "npm:6.2.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:8.4.2", {\ + "packageLocation": "./.yarn/cache/path-to-regexp-npm-8.4.2-5602ed344b-70fd2cbce0.zip/node_modules/path-to-regexp/",\ + "packageDependencies": [\ + ["path-to-regexp", "npm:8.4.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pathe", [\ + ["npm:2.0.3", {\ + "packageLocation": "./.yarn/cache/pathe-npm-2.0.3-0924246ee0-01e9a69928.zip/node_modules/pathe/",\ + "packageDependencies": [\ + ["pathe", "npm:2.0.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["peek-readable", [\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/peek-readable-npm-5.0.0-c469f805e3-d342f02dd0.zip/node_modules/peek-readable/",\ + "packageDependencies": [\ + ["peek-readable", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["peek-stream", [\ + ["npm:1.1.3", {\ + "packageLocation": "./.yarn/cache/peek-stream-npm-1.1.3-ff78afd138-a0e09d6d1a.zip/node_modules/peek-stream/",\ + "packageDependencies": [\ + ["buffer-from", "npm:1.1.2"],\ + ["duplexify", "npm:3.7.1"],\ + ["peek-stream", "npm:1.1.3"],\ + ["through2", "npm:2.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pend", [\ + ["npm:1.2.0", {\ + "packageLocation": "./.yarn/cache/pend-npm-1.2.0-7a13d93266-6c72f52433.zip/node_modules/pend/",\ + "packageDependencies": [\ + ["pend", "npm:1.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pg", [\ + ["npm:8.11.1", {\ + "packageLocation": "./.yarn/cache/pg-npm-8.11.1-ed8e244704-3ad52f84c0.zip/node_modules/pg/",\ + "packageDependencies": [\ + ["pg", "npm:8.11.1"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:8.11.1", {\ + "packageLocation": "./.yarn/__virtual__/pg-virtual-bd80eaacab/0/cache/pg-npm-8.11.1-ed8e244704-3ad52f84c0.zip/node_modules/pg/",\ + "packageDependencies": [\ + ["@types/pg-native", null],\ + ["buffer-writer", "npm:2.0.0"],\ + ["packet-reader", "npm:1.0.0"],\ + ["pg", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:8.11.1"],\ + ["pg-cloudflare", "npm:1.1.1"],\ + ["pg-connection-string", "npm:2.6.1"],\ + ["pg-native", null],\ + ["pg-pool", "virtual:bd80eaacaba4ac69e52ab40a70c2c4b13c6ce0a9346f8311e962f3e59c362840bf10abfde728731d045107c0c12c343c12db88e4d4947cbfbe342854b0e454f8#npm:3.6.1"],\ + ["pg-protocol", "npm:1.6.0"],\ + ["pg-types", "npm:2.2.0"],\ + ["pgpass", "npm:1.0.5"]\ + ],\ + "packagePeers": [\ + "@types/pg-native",\ + "pg-native"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pg-cloudflare", [\ + ["npm:1.1.1", {\ + "packageLocation": "./.yarn/cache/pg-cloudflare-npm-1.1.1-fe242426f0-45ca0c7926.zip/node_modules/pg-cloudflare/",\ + "packageDependencies": [\ + ["pg-cloudflare", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pg-connection-string", [\ + ["npm:2.6.1", {\ + "packageLocation": "./.yarn/cache/pg-connection-string-npm-2.6.1-f3f97c91a5-882344a47e.zip/node_modules/pg-connection-string/",\ + "packageDependencies": [\ + ["pg-connection-string", "npm:2.6.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pg-int8", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/pg-int8-npm-1.0.1-5cd67f3e22-a1e3a05a69.zip/node_modules/pg-int8/",\ + "packageDependencies": [\ + ["pg-int8", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pg-numeric", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/pg-numeric-npm-1.0.2-9026ec3427-8899f8200c.zip/node_modules/pg-numeric/",\ + "packageDependencies": [\ + ["pg-numeric", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pg-pool", [\ + ["npm:3.6.1", {\ + "packageLocation": "./.yarn/cache/pg-pool-npm-3.6.1-477c9660b9-5d1b02b959.zip/node_modules/pg-pool/",\ + "packageDependencies": [\ + ["pg-pool", "npm:3.6.1"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:bd80eaacaba4ac69e52ab40a70c2c4b13c6ce0a9346f8311e962f3e59c362840bf10abfde728731d045107c0c12c343c12db88e4d4947cbfbe342854b0e454f8#npm:3.6.1", {\ + "packageLocation": "./.yarn/__virtual__/pg-pool-virtual-8f153f9ee5/0/cache/pg-pool-npm-3.6.1-477c9660b9-5d1b02b959.zip/node_modules/pg-pool/",\ + "packageDependencies": [\ + ["@types/pg", null],\ + ["pg", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:8.11.1"],\ + ["pg-pool", "virtual:bd80eaacaba4ac69e52ab40a70c2c4b13c6ce0a9346f8311e962f3e59c362840bf10abfde728731d045107c0c12c343c12db88e4d4947cbfbe342854b0e454f8#npm:3.6.1"]\ + ],\ + "packagePeers": [\ + "@types/pg",\ + "pg"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pg-protocol", [\ + ["npm:1.6.0", {\ + "packageLocation": "./.yarn/cache/pg-protocol-npm-1.6.0-089a4b1d3c-995864cc2a.zip/node_modules/pg-protocol/",\ + "packageDependencies": [\ + ["pg-protocol", "npm:1.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pg-types", [\ + ["npm:2.2.0", {\ + "packageLocation": "./.yarn/cache/pg-types-npm-2.2.0-a3360226c4-87a84d4baa.zip/node_modules/pg-types/",\ + "packageDependencies": [\ + ["pg-int8", "npm:1.0.1"],\ + ["pg-types", "npm:2.2.0"],\ + ["postgres-array", "npm:2.0.0"],\ + ["postgres-bytea", "npm:1.0.0"],\ + ["postgres-date", "npm:1.0.7"],\ + ["postgres-interval", "npm:1.2.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.1", {\ + "packageLocation": "./.yarn/cache/pg-types-npm-4.0.1-8f922557d3-2c686ef361.zip/node_modules/pg-types/",\ + "packageDependencies": [\ + ["pg-int8", "npm:1.0.1"],\ + ["pg-numeric", "npm:1.0.2"],\ + ["pg-types", "npm:4.0.1"],\ + ["postgres-array", "npm:3.0.2"],\ + ["postgres-bytea", "npm:3.0.0"],\ + ["postgres-date", "npm:2.0.1"],\ + ["postgres-interval", "npm:3.0.0"],\ + ["postgres-range", "npm:1.1.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pgpass", [\ + ["npm:1.0.5", {\ + "packageLocation": "./.yarn/cache/pgpass-npm-1.0.5-653e71ddd8-0a6f3bf76e.zip/node_modules/pgpass/",\ + "packageDependencies": [\ + ["pgpass", "npm:1.0.5"],\ + ["split2", "npm:4.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["photoswipe", [\ + ["npm:5.4.4", {\ + "packageLocation": "./.yarn/cache/photoswipe-npm-5.4.4-f7a755162f-89a2910f01.zip/node_modules/photoswipe/",\ + "packageDependencies": [\ + ["photoswipe", "npm:5.4.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["picocolors", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/picocolors-npm-1.0.0-d81e0b1927-a2e8092dd8.zip/node_modules/picocolors/",\ + "packageDependencies": [\ + ["picocolors", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.1.1", {\ + "packageLocation": "./.yarn/cache/picocolors-npm-1.1.1-4fede47cf1-e1cf46bf84.zip/node_modules/picocolors/",\ + "packageDependencies": [\ + ["picocolors", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["picomatch", [\ + ["npm:2.3.1", {\ + "packageLocation": "./.yarn/cache/picomatch-npm-2.3.1-c782cfd986-60c2595003.zip/node_modules/picomatch/",\ + "packageDependencies": [\ + ["picomatch", "npm:2.3.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.2", {\ + "packageLocation": "./.yarn/cache/picomatch-npm-4.0.2-e93516ddf2-ce617b8da3.zip/node_modules/picomatch/",\ + "packageDependencies": [\ + ["picomatch", "npm:4.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.4", {\ + "packageLocation": "./.yarn/cache/picomatch-npm-4.0.4-e82d450244-f6ef80a359.zip/node_modules/picomatch/",\ + "packageDependencies": [\ + ["picomatch", "npm:4.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pify", [\ + ["npm:2.3.0", {\ + "packageLocation": "./.yarn/cache/pify-npm-2.3.0-8b63310934-9503aaeaf4.zip/node_modules/pify/",\ + "packageDependencies": [\ + ["pify", "npm:2.3.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/pify-npm-3.0.0-679ee405c8-668c1dc8d9.zip/node_modules/pify/",\ + "packageDependencies": [\ + ["pify", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.1", {\ + "packageLocation": "./.yarn/cache/pify-npm-4.0.1-062756097b-8b97cbf9dc.zip/node_modules/pify/",\ + "packageDependencies": [\ + ["pify", "npm:4.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pinkie", [\ + ["npm:2.0.4", {\ + "packageLocation": "./.yarn/cache/pinkie-npm-2.0.4-cffce4fb09-11d207257a.zip/node_modules/pinkie/",\ + "packageDependencies": [\ + ["pinkie", "npm:2.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pinkie-promise", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/pinkie-promise-npm-2.0.1-095439b8c5-b53a4a2e73.zip/node_modules/pinkie-promise/",\ + "packageDependencies": [\ + ["pinkie", "npm:2.0.4"],\ + ["pinkie-promise", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pirates", [\ + ["npm:4.0.7", {\ + "packageLocation": "./.yarn/cache/pirates-npm-4.0.7-5e4ee2f078-2427f37136.zip/node_modules/pirates/",\ + "packageDependencies": [\ + ["pirates", "npm:4.0.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pkg-types", [\ + ["npm:1.3.1", {\ + "packageLocation": "./.yarn/cache/pkg-types-npm-1.3.1-832c9cd162-6d491f2244.zip/node_modules/pkg-types/",\ + "packageDependencies": [\ + ["confbox", "npm:0.1.8"],\ + ["mlly", "npm:1.8.2"],\ + ["pathe", "npm:2.0.3"],\ + ["pkg-types", "npm:1.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["plyr", [\ + ["npm:3.8.4", {\ + "packageLocation": "./.yarn/cache/plyr-npm-3.8.4-89278d8826-fac96d432e.zip/node_modules/plyr/",\ + "packageDependencies": [\ + ["core-js", "npm:3.49.0"],\ + ["custom-event-polyfill", "npm:1.0.7"],\ + ["loadjs", "npm:4.3.0"],\ + ["plyr", "npm:3.8.4"],\ + ["rangetouch", "npm:2.0.1"],\ + ["url-polyfill", "npm:1.1.14"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pngjs", [\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/pngjs-npm-5.0.0-e8ba79f838-3457816447.zip/node_modules/pngjs/",\ + "packageDependencies": [\ + ["pngjs", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.0", {\ + "packageLocation": "./.yarn/cache/pngjs-npm-7.0.0-788de6ba02-e843ebbb0d.zip/node_modules/pngjs/",\ + "packageDependencies": [\ + ["pngjs", "npm:7.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["postcss", [\ + ["npm:8.4.27", {\ + "packageLocation": "./.yarn/cache/postcss-npm-8.4.27-2a9f5f8f40-57143e3c5d.zip/node_modules/postcss/",\ + "packageDependencies": [\ + ["nanoid", "npm:3.3.6"],\ + ["picocolors", "npm:1.0.0"],\ + ["postcss", "npm:8.4.27"],\ + ["source-map-js", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:8.5.14", {\ + "packageLocation": "./.yarn/cache/postcss-npm-8.5.14-1cf8d01c78-2e3f4dea69.zip/node_modules/postcss/",\ + "packageDependencies": [\ + ["nanoid", "npm:3.3.12"],\ + ["picocolors", "npm:1.1.1"],\ + ["postcss", "npm:8.5.14"],\ + ["source-map-js", "npm:1.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["postcss-load-config", [\ + ["npm:6.0.1", {\ + "packageLocation": "./.yarn/cache/postcss-load-config-npm-6.0.1-50722afd05-1691cfc949.zip/node_modules/postcss-load-config/",\ + "packageDependencies": [\ + ["postcss-load-config", "npm:6.0.1"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:4b773fcf67e13422dc15c766b293a59543559d82cada458ec3b1e008dd59d4ec3aa9ee576dd293df519765cd62284cf280961ba91ee4f7468cb4f32aaf42ce24#npm:6.0.1", {\ + "packageLocation": "./.yarn/__virtual__/postcss-load-config-virtual-a9f45cb47b/0/cache/postcss-load-config-npm-6.0.1-50722afd05-1691cfc949.zip/node_modules/postcss-load-config/",\ + "packageDependencies": [\ + ["@types/jiti", null],\ + ["@types/postcss", null],\ + ["@types/tsx", null],\ + ["@types/yaml", null],\ + ["jiti", null],\ + ["lilconfig", "npm:3.1.3"],\ + ["postcss", null],\ + ["postcss-load-config", "virtual:4b773fcf67e13422dc15c766b293a59543559d82cada458ec3b1e008dd59d4ec3aa9ee576dd293df519765cd62284cf280961ba91ee4f7468cb4f32aaf42ce24#npm:6.0.1"],\ + ["tsx", null],\ + ["yaml", null]\ + ],\ + "packagePeers": [\ + "@types/jiti",\ + "@types/postcss",\ + "@types/tsx",\ + "@types/yaml",\ + "jiti",\ + "postcss",\ + "tsx",\ + "yaml"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["postgres-array", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/postgres-array-npm-2.0.0-4f49dc1389-aff99e7971.zip/node_modules/postgres-array/",\ + "packageDependencies": [\ + ["postgres-array", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.2", {\ + "packageLocation": "./.yarn/cache/postgres-array-npm-3.0.2-da6a3f1fed-0159517e4e.zip/node_modules/postgres-array/",\ + "packageDependencies": [\ + ["postgres-array", "npm:3.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["postgres-bytea", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/postgres-bytea-npm-1.0.0-8c2b81fa73-d844ae4ca7.zip/node_modules/postgres-bytea/",\ + "packageDependencies": [\ + ["postgres-bytea", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/postgres-bytea-npm-3.0.0-5de4c664f6-f5c01758fd.zip/node_modules/postgres-bytea/",\ + "packageDependencies": [\ + ["obuf", "npm:1.1.2"],\ + ["postgres-bytea", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["postgres-date", [\ + ["npm:1.0.7", {\ + "packageLocation": "./.yarn/cache/postgres-date-npm-1.0.7-aadfe5531e-571ef45bec.zip/node_modules/postgres-date/",\ + "packageDependencies": [\ + ["postgres-date", "npm:1.0.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/postgres-date-npm-2.0.1-00e0e0bc9e-908eacec35.zip/node_modules/postgres-date/",\ + "packageDependencies": [\ + ["postgres-date", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["postgres-interval", [\ + ["npm:1.2.0", {\ + "packageLocation": "./.yarn/cache/postgres-interval-npm-1.2.0-ca6414744d-746b71f938.zip/node_modules/postgres-interval/",\ + "packageDependencies": [\ + ["postgres-interval", "npm:1.2.0"],\ + ["xtend", "npm:4.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/postgres-interval-npm-3.0.0-936c769b98-c7a1cf006d.zip/node_modules/postgres-interval/",\ + "packageDependencies": [\ + ["postgres-interval", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["postgres-range", [\ + ["npm:1.1.3", {\ + "packageLocation": "./.yarn/cache/postgres-range-npm-1.1.3-46f68e1a9e-356a46d97e.zip/node_modules/postgres-range/",\ + "packageDependencies": [\ + ["postgres-range", "npm:1.1.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["prettier", [\ + ["npm:2.8.8", {\ + "packageLocation": "./.yarn/cache/prettier-npm-2.8.8-430828a36c-00cdb6ab02.zip/node_modules/prettier/",\ + "packageDependencies": [\ + ["prettier", "npm:2.8.8"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/prettier-npm-3.0.0-7ffbcce680-3992926382.zip/node_modules/prettier/",\ + "packageDependencies": [\ + ["prettier", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["prettier-plugin-vue", [\ + ["npm:1.1.6", {\ + "packageLocation": "./.yarn/cache/prettier-plugin-vue-npm-1.1.6-28321c051c-ffd97e85dd.zip/node_modules/prettier-plugin-vue/",\ + "packageDependencies": [\ + ["prettier", "npm:2.8.8"],\ + ["prettier-plugin-vue", "npm:1.1.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pretty", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/pretty-npm-2.0.0-9c25795870-9c41ae0559.zip/node_modules/pretty/",\ + "packageDependencies": [\ + ["condense-newlines", "npm:0.2.1"],\ + ["extend-shallow", "npm:2.0.1"],\ + ["js-beautify", "npm:1.14.9"],\ + ["pretty", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["prismjs", [\ + ["npm:1.29.0", {\ + "packageLocation": "./.yarn/cache/prismjs-npm-1.29.0-6faa5b04b8-2080db382c.zip/node_modules/prismjs/",\ + "packageDependencies": [\ + ["prismjs", "npm:1.29.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["private-ip", [\ + ["npm:2.3.3", {\ + "packageLocation": "./.yarn/cache/private-ip-npm-2.3.3-330bcf2a5d-c362d1b07e.zip/node_modules/private-ip/",\ + "packageDependencies": [\ + ["ip-regex", "npm:4.3.0"],\ + ["ipaddr.js", "npm:2.1.0"],\ + ["is-ip", "npm:3.1.0"],\ + ["netmask", "npm:2.0.2"],\ + ["private-ip", "npm:2.3.3"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.3.4", {\ + "packageLocation": "./.yarn/cache/private-ip-npm-2.3.4-a1b55e33d2-e55874341c.zip/node_modules/private-ip/",\ + "packageDependencies": [\ + ["ip-regex", "npm:4.3.0"],\ + ["ipaddr.js", "npm:2.1.0"],\ + ["is-ip", "npm:3.1.0"],\ + ["netmask", "npm:2.0.2"],\ + ["private-ip", "npm:2.3.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["probe-image-size", [\ + ["npm:7.2.3", {\ + "packageLocation": "./.yarn/cache/probe-image-size-npm-7.2.3-2b6ee36e6f-6df205754d.zip/node_modules/probe-image-size/",\ + "packageDependencies": [\ + ["lodash.merge", "npm:4.6.2"],\ + ["needle", "npm:2.9.1"],\ + ["probe-image-size", "npm:7.2.3"],\ + ["stream-parser", "npm:0.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["proc-log", [\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/proc-log-npm-5.0.0-405173f9b4-35610bdb01.zip/node_modules/proc-log/",\ + "packageDependencies": [\ + ["proc-log", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["process-nextick-args", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/process-nextick-args-npm-2.0.1-b8d7971609-1d38588e52.zip/node_modules/process-nextick-args/",\ + "packageDependencies": [\ + ["process-nextick-args", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["prom-client", [\ + ["npm:15.1.0", {\ + "packageLocation": "./.yarn/cache/prom-client-npm-15.1.0-0b2231d02c-ecb6f40de7.zip/node_modules/prom-client/",\ + "packageDependencies": [\ + ["@opentelemetry/api", "npm:1.7.0"],\ + ["prom-client", "npm:15.1.0"],\ + ["tdigest", "npm:0.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["promise", [\ + ["npm:7.3.1", {\ + "packageLocation": "./.yarn/cache/promise-npm-7.3.1-5d81d474c0-37dbe58ca7.zip/node_modules/promise/",\ + "packageDependencies": [\ + ["asap", "npm:2.0.6"],\ + ["promise", "npm:7.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["promise-limit", [\ + ["npm:2.7.0", {\ + "packageLocation": "./.yarn/cache/promise-limit-npm-2.7.0-cb930a295a-9756d35a70.zip/node_modules/promise-limit/",\ + "packageDependencies": [\ + ["promise-limit", "npm:2.7.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["promise-retry", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/promise-retry-npm-2.0.1-871f0b01b7-96e1a82453.zip/node_modules/promise-retry/",\ + "packageDependencies": [\ + ["err-code", "npm:2.0.3"],\ + ["promise-retry", "npm:2.0.1"],\ + ["retry", "npm:0.12.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["proto-list", [\ + ["npm:1.2.4", {\ + "packageLocation": "./.yarn/cache/proto-list-npm-1.2.4-a96a43df28-9cc3b46d61.zip/node_modules/proto-list/",\ + "packageDependencies": [\ + ["proto-list", "npm:1.2.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pseudomap", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/pseudomap-npm-1.0.2-0d0e40fee0-856c0aae0f.zip/node_modules/pseudomap/",\ + "packageDependencies": [\ + ["pseudomap", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pug", [\ + ["npm:3.0.2", {\ + "packageLocation": "./.yarn/cache/pug-npm-3.0.2-a900d45f03-4bb4cab1ab.zip/node_modules/pug/",\ + "packageDependencies": [\ + ["pug", "npm:3.0.2"],\ + ["pug-code-gen", "npm:3.0.2"],\ + ["pug-filters", "npm:4.0.0"],\ + ["pug-lexer", "npm:5.0.1"],\ + ["pug-linker", "npm:4.0.0"],\ + ["pug-load", "npm:3.0.0"],\ + ["pug-parser", "npm:6.0.0"],\ + ["pug-runtime", "npm:3.0.1"],\ + ["pug-strip-comments", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pug-attrs", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/pug-attrs-npm-3.0.0-31b331fe79-2ca2d34de3.zip/node_modules/pug-attrs/",\ + "packageDependencies": [\ + ["constantinople", "npm:4.0.1"],\ + ["js-stringify", "npm:1.0.2"],\ + ["pug-attrs", "npm:3.0.0"],\ + ["pug-runtime", "npm:3.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pug-code-gen", [\ + ["npm:3.0.2", {\ + "packageLocation": "./.yarn/cache/pug-code-gen-npm-3.0.2-1cc7d40723-8245ba433a.zip/node_modules/pug-code-gen/",\ + "packageDependencies": [\ + ["constantinople", "npm:4.0.1"],\ + ["doctypes", "npm:1.1.0"],\ + ["js-stringify", "npm:1.0.2"],\ + ["pug-attrs", "npm:3.0.0"],\ + ["pug-code-gen", "npm:3.0.2"],\ + ["pug-error", "npm:2.0.0"],\ + ["pug-runtime", "npm:3.0.1"],\ + ["void-elements", "npm:3.1.0"],\ + ["with", "npm:7.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pug-error", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/pug-error-npm-2.0.0-13b776f97b-c5372d018c.zip/node_modules/pug-error/",\ + "packageDependencies": [\ + ["pug-error", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pug-filters", [\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/pug-filters-npm-4.0.0-d2cf0196e7-ca8b7ffede.zip/node_modules/pug-filters/",\ + "packageDependencies": [\ + ["constantinople", "npm:4.0.1"],\ + ["jstransformer", "npm:1.0.0"],\ + ["pug-error", "npm:2.0.0"],\ + ["pug-filters", "npm:4.0.0"],\ + ["pug-walk", "npm:2.0.0"],\ + ["resolve", "patch:resolve@npm%3A1.22.3#optional!builtin::version=1.22.3&hash=c3c19d"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pug-lexer", [\ + ["npm:5.0.1", {\ + "packageLocation": "./.yarn/cache/pug-lexer-npm-5.0.1-3bdff5fe60-18d74a2dfb.zip/node_modules/pug-lexer/",\ + "packageDependencies": [\ + ["character-parser", "npm:2.2.0"],\ + ["is-expression", "npm:4.0.0"],\ + ["pug-error", "npm:2.0.0"],\ + ["pug-lexer", "npm:5.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pug-linker", [\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/pug-linker-npm-4.0.0-b140c7e607-423f62e860.zip/node_modules/pug-linker/",\ + "packageDependencies": [\ + ["pug-error", "npm:2.0.0"],\ + ["pug-linker", "npm:4.0.0"],\ + ["pug-walk", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pug-load", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/pug-load-npm-3.0.0-dc9f2273d3-1800ec5199.zip/node_modules/pug-load/",\ + "packageDependencies": [\ + ["object-assign", "npm:4.1.1"],\ + ["pug-load", "npm:3.0.0"],\ + ["pug-walk", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pug-parser", [\ + ["npm:6.0.0", {\ + "packageLocation": "./.yarn/cache/pug-parser-npm-6.0.0-87b7dc8a83-4c23e154ea.zip/node_modules/pug-parser/",\ + "packageDependencies": [\ + ["pug-error", "npm:2.0.0"],\ + ["pug-parser", "npm:6.0.0"],\ + ["token-stream", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pug-runtime", [\ + ["npm:3.0.1", {\ + "packageLocation": "./.yarn/cache/pug-runtime-npm-3.0.1-13038c62ae-d34ee1b951.zip/node_modules/pug-runtime/",\ + "packageDependencies": [\ + ["pug-runtime", "npm:3.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pug-strip-comments", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/pug-strip-comments-npm-2.0.0-7baa7bca2f-2cfcbf506c.zip/node_modules/pug-strip-comments/",\ + "packageDependencies": [\ + ["pug-error", "npm:2.0.0"],\ + ["pug-strip-comments", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pug-walk", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/pug-walk-npm-2.0.0-a8a11880fc-bee64e133b.zip/node_modules/pug-walk/",\ + "packageDependencies": [\ + ["pug-walk", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pump", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/pump-npm-2.0.1-05afac7fc4-e9f26a17be.zip/node_modules/pump/",\ + "packageDependencies": [\ + ["end-of-stream", "npm:1.4.4"],\ + ["once", "npm:1.4.0"],\ + ["pump", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/pump-npm-3.0.0-0080bf6a7a-e42e9229fb.zip/node_modules/pump/",\ + "packageDependencies": [\ + ["end-of-stream", "npm:1.4.4"],\ + ["once", "npm:1.4.0"],\ + ["pump", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pumpify", [\ + ["npm:1.5.1", {\ + "packageLocation": "./.yarn/cache/pumpify-npm-1.5.1-b928bd877f-5d11a99f32.zip/node_modules/pumpify/",\ + "packageDependencies": [\ + ["duplexify", "npm:3.7.1"],\ + ["inherits", "npm:2.0.4"],\ + ["pump", "npm:2.0.1"],\ + ["pumpify", "npm:1.5.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["punycode", [\ + ["npm:2.3.0", {\ + "packageLocation": "./.yarn/cache/punycode-npm-2.3.0-df4bdce06b-d4e7fbb96f.zip/node_modules/punycode/",\ + "packageDependencies": [\ + ["punycode", "npm:2.3.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.3.1", {\ + "packageLocation": "./.yarn/cache/punycode-npm-2.3.1-97543c420d-febdc4362b.zip/node_modules/punycode/",\ + "packageDependencies": [\ + ["punycode", "npm:2.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["pureimage", [\ + ["npm:0.4.18", {\ + "packageLocation": "./.yarn/cache/pureimage-npm-0.4.18-0b7f51acec-fdf4909bcd.zip/node_modules/pureimage/",\ + "packageDependencies": [\ + ["jpeg-js", "npm:0.4.4"],\ + ["opentype.js", "npm:0.4.11"],\ + ["pngjs", "npm:7.0.0"],\ + ["pureimage", "npm:0.4.18"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["q", [\ + ["npm:1.4.1", {\ + "packageLocation": "./.yarn/cache/q-npm-1.4.1-cc8d344fb7-31ac481a91.zip/node_modules/q/",\ + "packageDependencies": [\ + ["q", "npm:1.4.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["qrcode", [\ + ["npm:1.5.3", {\ + "packageLocation": "./.yarn/cache/qrcode-npm-1.5.3-08da54d76e-823642d59a.zip/node_modules/qrcode/",\ + "packageDependencies": [\ + ["dijkstrajs", "npm:1.0.3"],\ + ["encode-utf8", "npm:1.0.3"],\ + ["pngjs", "npm:5.0.0"],\ + ["qrcode", "npm:1.5.3"],\ + ["yargs", "npm:15.4.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["qs", [\ + ["npm:6.11.2", {\ + "packageLocation": "./.yarn/cache/qs-npm-6.11.2-b118bc1c6f-f2321d0796.zip/node_modules/qs/",\ + "packageDependencies": [\ + ["qs", "npm:6.11.2"],\ + ["side-channel", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["queue-microtask", [\ + ["npm:1.2.3", {\ + "packageLocation": "./.yarn/cache/queue-microtask-npm-1.2.3-fcc98e4e2d-72900df061.zip/node_modules/queue-microtask/",\ + "packageDependencies": [\ + ["queue-microtask", "npm:1.2.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["queue-tick", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/queue-tick-npm-1.0.1-10bd6eaf3d-f447926c51.zip/node_modules/queue-tick/",\ + "packageDependencies": [\ + ["queue-tick", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["quick-lru", [\ + ["npm:5.1.1", {\ + "packageLocation": "./.yarn/cache/quick-lru-npm-5.1.1-e38e0edce3-a516faa255.zip/node_modules/quick-lru/",\ + "packageDependencies": [\ + ["quick-lru", "npm:5.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["random-seed", [\ + ["npm:0.3.0", {\ + "packageLocation": "./.yarn/cache/random-seed-npm-0.3.0-0bd617817e-7e6b395736.zip/node_modules/random-seed/",\ + "packageDependencies": [\ + ["json-stringify-safe", "npm:5.0.1"],\ + ["random-seed", "npm:0.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["rangestr", [\ + ["npm:0.0.1", {\ + "packageLocation": "./.yarn/cache/rangestr-npm-0.0.1-a9c9d08cb3-031a8f0f4d.zip/node_modules/rangestr/",\ + "packageDependencies": [\ + ["rangestr", "npm:0.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["rangetouch", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/rangetouch-npm-2.0.1-b22e78bc49-bf37fac58b.zip/node_modules/rangetouch/",\ + "packageDependencies": [\ + ["rangetouch", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ratelimiter", [\ + ["npm:3.4.1", {\ + "packageLocation": "./.yarn/cache/ratelimiter-npm-3.4.1-5ba5f26b13-00212edbe8.zip/node_modules/ratelimiter/",\ + "packageDependencies": [\ + ["ratelimiter", "npm:3.4.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["raw-body", [\ + ["npm:2.5.2", {\ + "packageLocation": "./.yarn/cache/raw-body-npm-2.5.2-5cb9dfebc1-863b5171e1.zip/node_modules/raw-body/",\ + "packageDependencies": [\ + ["bytes", "npm:3.1.2"],\ + ["http-errors", "npm:2.0.0"],\ + ["iconv-lite", "npm:0.4.24"],\ + ["raw-body", "npm:2.5.2"],\ + ["unpipe", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["rdf-canonize", [\ + ["npm:3.4.0", {\ + "packageLocation": "./.yarn/cache/rdf-canonize-npm-3.4.0-87bb945794-4b77dad3ef.zip/node_modules/rdf-canonize/",\ + "packageDependencies": [\ + ["rdf-canonize", "npm:3.4.0"],\ + ["setimmediate", "npm:1.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["re2", [\ + ["npm:1.22.1", {\ + "packageLocation": "./.yarn/unplugged/re2-npm-1.22.1-6879e9193a/node_modules/re2/",\ + "packageDependencies": [\ + ["install-artifact-from-github", "npm:1.4.0"],\ + ["nan", "npm:2.22.2"],\ + ["node-gyp", "npm:11.2.0"],\ + ["re2", "npm:1.22.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["readable-stream", [\ + ["npm:2.3.8", {\ + "packageLocation": "./.yarn/cache/readable-stream-npm-2.3.8-67a94c2cb1-8500dd3a90.zip/node_modules/readable-stream/",\ + "packageDependencies": [\ + ["core-util-is", "npm:1.0.3"],\ + ["inherits", "npm:2.0.4"],\ + ["isarray", "npm:1.0.0"],\ + ["process-nextick-args", "npm:2.0.1"],\ + ["readable-stream", "npm:2.3.8"],\ + ["safe-buffer", "npm:5.1.2"],\ + ["string_decoder", "npm:1.1.1"],\ + ["util-deprecate", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.6.2", {\ + "packageLocation": "./.yarn/cache/readable-stream-npm-3.6.2-d2a6069158-d9e3e53193.zip/node_modules/readable-stream/",\ + "packageDependencies": [\ + ["inherits", "npm:2.0.4"],\ + ["readable-stream", "npm:3.6.2"],\ + ["string_decoder", "npm:1.3.0"],\ + ["util-deprecate", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["readable-web-to-node-stream", [\ + ["npm:3.0.2", {\ + "packageLocation": "./.yarn/cache/readable-web-to-node-stream-npm-3.0.2-682f5de297-d3a5bf9d70.zip/node_modules/readable-web-to-node-stream/",\ + "packageDependencies": [\ + ["readable-stream", "npm:3.6.2"],\ + ["readable-web-to-node-stream", "npm:3.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["readdir-glob", [\ + ["npm:1.1.3", {\ + "packageLocation": "./.yarn/cache/readdir-glob-npm-1.1.3-ad1a82fc20-ca3a20aa1e.zip/node_modules/readdir-glob/",\ + "packageDependencies": [\ + ["minimatch", "npm:5.1.6"],\ + ["readdir-glob", "npm:1.1.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["readdirp", [\ + ["npm:3.6.0", {\ + "packageLocation": "./.yarn/cache/readdirp-npm-3.6.0-f950cc74ab-196b30ef6c.zip/node_modules/readdirp/",\ + "packageDependencies": [\ + ["picomatch", "npm:2.3.1"],\ + ["readdirp", "npm:3.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["readline-sync", [\ + ["npm:1.4.10", {\ + "packageLocation": "./.yarn/cache/readline-sync-npm-1.4.10-6809f52ca7-5eb6465f5c.zip/node_modules/readline-sync/",\ + "packageDependencies": [\ + ["readline-sync", "npm:1.4.10"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["reconnecting-websocket", [\ + ["npm:4.4.0", {\ + "packageLocation": "./.yarn/cache/reconnecting-websocket-npm-4.4.0-c6f262f5df-542b09b460.zip/node_modules/reconnecting-websocket/",\ + "packageDependencies": [\ + ["reconnecting-websocket", "npm:4.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["redis", [\ + ["npm:4.6.7", {\ + "packageLocation": "./.yarn/cache/redis-npm-4.6.7-d942fc1f0b-514963fd05.zip/node_modules/redis/",\ + "packageDependencies": [\ + ["@redis/bloom", "virtual:d942fc1f0bf76670da5df1cacb3a62b82829e31da6ef87f7962059db1525b7d7191333770989124fc769276f7faa4fcc2c8e31f97960ee5db217048dbc3e0b10#npm:1.2.0"],\ + ["@redis/client", "npm:1.5.8"],\ + ["@redis/graph", "virtual:d942fc1f0bf76670da5df1cacb3a62b82829e31da6ef87f7962059db1525b7d7191333770989124fc769276f7faa4fcc2c8e31f97960ee5db217048dbc3e0b10#npm:1.1.0"],\ + ["@redis/json", "virtual:d942fc1f0bf76670da5df1cacb3a62b82829e31da6ef87f7962059db1525b7d7191333770989124fc769276f7faa4fcc2c8e31f97960ee5db217048dbc3e0b10#npm:1.0.4"],\ + ["@redis/search", "virtual:d942fc1f0bf76670da5df1cacb3a62b82829e31da6ef87f7962059db1525b7d7191333770989124fc769276f7faa4fcc2c8e31f97960ee5db217048dbc3e0b10#npm:1.1.3"],\ + ["@redis/time-series", "virtual:d942fc1f0bf76670da5df1cacb3a62b82829e31da6ef87f7962059db1525b7d7191333770989124fc769276f7faa4fcc2c8e31f97960ee5db217048dbc3e0b10#npm:1.0.4"],\ + ["redis", "npm:4.6.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["redis-errors", [\ + ["npm:1.2.0", {\ + "packageLocation": "./.yarn/cache/redis-errors-npm-1.2.0-a81fd9b0f1-001c11f63d.zip/node_modules/redis-errors/",\ + "packageDependencies": [\ + ["redis-errors", "npm:1.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["redis-info", [\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/redis-info-npm-3.1.0-74f2dc4b4d-92ed68d47e.zip/node_modules/redis-info/",\ + "packageDependencies": [\ + ["lodash", "npm:4.17.21"],\ + ["redis-info", "npm:3.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["redis-lock", [\ + ["npm:0.1.4", {\ + "packageLocation": "./.yarn/cache/redis-lock-npm-0.1.4-bb01cc5915-31a9e9cd54.zip/node_modules/redis-lock/",\ + "packageDependencies": [\ + ["redis-lock", "npm:0.1.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["redis-parser", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/redis-parser-npm-3.0.0-7ebe40abcb-b10846844b.zip/node_modules/redis-parser/",\ + "packageDependencies": [\ + ["redis-errors", "npm:1.2.0"],\ + ["redis-parser", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["redis-semaphore", [\ + ["npm:5.3.1", {\ + "packageLocation": "./.yarn/cache/redis-semaphore-npm-5.3.1-977928cd75-7fcafee7b9.zip/node_modules/redis-semaphore/",\ + "packageDependencies": [\ + ["redis-semaphore", "npm:5.3.1"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:5.3.1", {\ + "packageLocation": "./.yarn/__virtual__/redis-semaphore-virtual-afdd0cddc1/0/cache/redis-semaphore-npm-5.3.1-977928cd75-7fcafee7b9.zip/node_modules/redis-semaphore/",\ + "packageDependencies": [\ + ["@types/ioredis", null],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["ioredis", "npm:5.4.1"],\ + ["redis-semaphore", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:5.3.1"]\ + ],\ + "packagePeers": [\ + "@types/ioredis",\ + "ioredis"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["reflect-metadata", [\ + ["npm:0.1.13", {\ + "packageLocation": "./.yarn/cache/reflect-metadata-npm-0.1.13-c525998e20-732570da35.zip/node_modules/reflect-metadata/",\ + "packageDependencies": [\ + ["reflect-metadata", "npm:0.1.13"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["regenerator-runtime", [\ + ["npm:0.13.11", {\ + "packageLocation": "./.yarn/cache/regenerator-runtime-npm-0.13.11-90bf536060-d493e9e118.zip/node_modules/regenerator-runtime/",\ + "packageDependencies": [\ + ["regenerator-runtime", "npm:0.13.11"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["rename", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/cache/rename-npm-1.0.4-3df037de05-7600876ee2.zip/node_modules/rename/",\ + "packageDependencies": [\ + ["debug", "virtual:0b70187c8540c711e7ec6828978e1d7ecbf862adebfcbfade4bfe1470fbdf59ca56319a056ee7ea510b1dd57a2faea769d1589a1b06d26c3a84a4bd41431045b#npm:2.6.9"],\ + ["rename", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["require-all", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/require-all-npm-3.0.0-a4509266cc-eb79715e07.zip/node_modules/require-all/",\ + "packageDependencies": [\ + ["require-all", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["require-directory", [\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/require-directory-npm-2.1.1-8608aee50b-a72468e258.zip/node_modules/require-directory/",\ + "packageDependencies": [\ + ["require-directory", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["require-from-string", [\ + ["npm:2.0.2", {\ + "packageLocation": "./.yarn/cache/require-from-string-npm-2.0.2-8557e0db12-839a3a8901.zip/node_modules/require-from-string/",\ + "packageDependencies": [\ + ["require-from-string", "npm:2.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["require-main-filename", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/require-main-filename-npm-2.0.0-03eef65c84-8604a570c0.zip/node_modules/require-main-filename/",\ + "packageDependencies": [\ + ["require-main-filename", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["resolve", [\ + ["patch:resolve@npm%3A1.19.0#optional!builtin::version=1.19.0&hash=c3c19d", {\ + "packageLocation": "./.yarn/cache/resolve-patch-0e96ddcab0-eb8853b1b7.zip/node_modules/resolve/",\ + "packageDependencies": [\ + ["is-core-module", "npm:2.12.1"],\ + ["path-parse", "npm:1.0.7"],\ + ["resolve", "patch:resolve@npm%3A1.19.0#optional!builtin::version=1.19.0&hash=c3c19d"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["patch:resolve@npm%3A1.22.2#optional!builtin::version=1.22.2&hash=c3c19d", {\ + "packageLocation": "./.yarn/cache/resolve-patch-07d439312e-14594f99db.zip/node_modules/resolve/",\ + "packageDependencies": [\ + ["is-core-module", "npm:2.12.1"],\ + ["path-parse", "npm:1.0.7"],\ + ["resolve", "patch:resolve@npm%3A1.22.2#optional!builtin::version=1.22.2&hash=c3c19d"],\ + ["supports-preserve-symlinks-flag", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["patch:resolve@npm%3A1.22.3#optional!builtin::version=1.22.3&hash=c3c19d", {\ + "packageLocation": "./.yarn/cache/resolve-patch-ac9d7e4cbf-b775dffbad.zip/node_modules/resolve/",\ + "packageDependencies": [\ + ["is-core-module", "npm:2.12.1"],\ + ["path-parse", "npm:1.0.7"],\ + ["resolve", "patch:resolve@npm%3A1.22.3#optional!builtin::version=1.22.3&hash=c3c19d"],\ + ["supports-preserve-symlinks-flag", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["resolve-alpn", [\ + ["npm:1.2.1", {\ + "packageLocation": "./.yarn/cache/resolve-alpn-npm-1.2.1-af77edd28b-744e87888f.zip/node_modules/resolve-alpn/",\ + "packageDependencies": [\ + ["resolve-alpn", "npm:1.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["resolve-from", [\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/resolve-from-npm-5.0.0-15c9db4d33-be18a5e4d7.zip/node_modules/resolve-from/",\ + "packageDependencies": [\ + ["resolve-from", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["resolve-path", [\ + ["npm:1.4.0", {\ + "packageLocation": "./.yarn/cache/resolve-path-npm-1.4.0-796e63d898-1a39f569ee.zip/node_modules/resolve-path/",\ + "packageDependencies": [\ + ["http-errors", "npm:1.6.3"],\ + ["path-is-absolute", "npm:1.0.1"],\ + ["resolve-path", "npm:1.4.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["responselike", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/responselike-npm-2.0.1-7f64b6e122-b122535466.zip/node_modules/responselike/",\ + "packageDependencies": [\ + ["lowercase-keys", "npm:2.0.0"],\ + ["responselike", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.2", {\ + "packageLocation": "./.yarn/cache/responselike-npm-4.0.2-b396284783-9aabf40177.zip/node_modules/responselike/",\ + "packageDependencies": [\ + ["lowercase-keys", "npm:3.0.0"],\ + ["responselike", "npm:4.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["retry", [\ + ["npm:0.12.0", {\ + "packageLocation": "./.yarn/cache/retry-npm-0.12.0-72ac7fb4cc-1f914879f9.zip/node_modules/retry/",\ + "packageDependencies": [\ + ["retry", "npm:0.12.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["reusify", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/cache/reusify-npm-1.0.4-95ac4aec11-14222c9e1d.zip/node_modules/reusify/",\ + "packageDependencies": [\ + ["reusify", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["rimraf", [\ + ["npm:3.0.2", {\ + "packageLocation": "./.yarn/cache/rimraf-npm-3.0.2-2cb7dac69a-063ffaccaa.zip/node_modules/rimraf/",\ + "packageDependencies": [\ + ["glob", "npm:7.2.3"],\ + ["rimraf", "npm:3.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["rndstr", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/rndstr-npm-1.0.0-df1a4b0cd3-faab72785f.zip/node_modules/rndstr/",\ + "packageDependencies": [\ + ["rangestr", "npm:0.0.1"],\ + ["rndstr", "npm:1.0.0"],\ + ["seedrandom", "npm:2.4.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["rollup", [\ + ["npm:4.6.1", {\ + "packageLocation": "./.yarn/cache/rollup-npm-4.6.1-1f7714a5d3-32fcbb3954.zip/node_modules/rollup/",\ + "packageDependencies": [\ + ["@rollup/rollup-android-arm-eabi", "npm:4.6.1"],\ + ["@rollup/rollup-android-arm64", "npm:4.6.1"],\ + ["@rollup/rollup-darwin-arm64", "npm:4.6.1"],\ + ["@rollup/rollup-darwin-x64", "npm:4.6.1"],\ + ["@rollup/rollup-linux-arm-gnueabihf", "npm:4.6.1"],\ + ["@rollup/rollup-linux-arm64-gnu", "npm:4.6.1"],\ + ["@rollup/rollup-linux-arm64-musl", "npm:4.6.1"],\ + ["@rollup/rollup-linux-x64-gnu", "npm:4.6.1"],\ + ["@rollup/rollup-linux-x64-musl", "npm:4.6.1"],\ + ["@rollup/rollup-win32-arm64-msvc", "npm:4.6.1"],\ + ["@rollup/rollup-win32-ia32-msvc", "npm:4.6.1"],\ + ["@rollup/rollup-win32-x64-msvc", "npm:4.6.1"],\ + ["fsevents", "patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1"],\ + ["rollup", "npm:4.6.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.60.3", {\ + "packageLocation": "./.yarn/cache/rollup-npm-4.60.3-d216502d5d-576a68253d.zip/node_modules/rollup/",\ + "packageDependencies": [\ + ["@rollup/rollup-android-arm-eabi", "npm:4.60.3"],\ + ["@rollup/rollup-android-arm64", "npm:4.60.3"],\ + ["@rollup/rollup-darwin-arm64", "npm:4.60.3"],\ + ["@rollup/rollup-darwin-x64", "npm:4.60.3"],\ + ["@rollup/rollup-freebsd-arm64", "npm:4.60.3"],\ + ["@rollup/rollup-freebsd-x64", "npm:4.60.3"],\ + ["@rollup/rollup-linux-arm-gnueabihf", "npm:4.60.3"],\ + ["@rollup/rollup-linux-arm-musleabihf", "npm:4.60.3"],\ + ["@rollup/rollup-linux-arm64-gnu", "npm:4.60.3"],\ + ["@rollup/rollup-linux-arm64-musl", "npm:4.60.3"],\ + ["@rollup/rollup-linux-loong64-gnu", "npm:4.60.3"],\ + ["@rollup/rollup-linux-loong64-musl", "npm:4.60.3"],\ + ["@rollup/rollup-linux-ppc64-gnu", "npm:4.60.3"],\ + ["@rollup/rollup-linux-ppc64-musl", "npm:4.60.3"],\ + ["@rollup/rollup-linux-riscv64-gnu", "npm:4.60.3"],\ + ["@rollup/rollup-linux-riscv64-musl", "npm:4.60.3"],\ + ["@rollup/rollup-linux-s390x-gnu", "npm:4.60.3"],\ + ["@rollup/rollup-linux-x64-gnu", "npm:4.60.3"],\ + ["@rollup/rollup-linux-x64-musl", "npm:4.60.3"],\ + ["@rollup/rollup-openbsd-x64", "npm:4.60.3"],\ + ["@rollup/rollup-openharmony-arm64", "npm:4.60.3"],\ + ["@rollup/rollup-win32-arm64-msvc", "npm:4.60.3"],\ + ["@rollup/rollup-win32-ia32-msvc", "npm:4.60.3"],\ + ["@rollup/rollup-win32-x64-gnu", "npm:4.60.3"],\ + ["@rollup/rollup-win32-x64-msvc", "npm:4.60.3"],\ + ["@types/estree", "npm:1.0.8"],\ + ["fsevents", "patch:fsevents@npm%3A2.3.2#optional!builtin::version=2.3.2&hash=df0bf1"],\ + ["rollup", "npm:4.60.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["rrweb-cssom", [\ + ["npm:0.8.0", {\ + "packageLocation": "./.yarn/cache/rrweb-cssom-npm-0.8.0-0c92f2366d-07521ee36f.zip/node_modules/rrweb-cssom/",\ + "packageDependencies": [\ + ["rrweb-cssom", "npm:0.8.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["rss-parser", [\ + ["npm:3.13.0", {\ + "packageLocation": "./.yarn/cache/rss-parser-npm-3.13.0-eb7c12b2b0-c27fc0c3c2.zip/node_modules/rss-parser/",\ + "packageDependencies": [\ + ["entities", "npm:2.2.0"],\ + ["rss-parser", "npm:3.13.0"],\ + ["xml2js", "npm:0.5.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["run-parallel", [\ + ["npm:1.2.0", {\ + "packageLocation": "./.yarn/cache/run-parallel-npm-1.2.0-3f47ff2034-cb4f97ad25.zip/node_modules/run-parallel/",\ + "packageDependencies": [\ + ["queue-microtask", "npm:1.2.3"],\ + ["run-parallel", "npm:1.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["s-age", [\ + ["npm:1.1.2", {\ + "packageLocation": "./.yarn/cache/s-age-npm-1.1.2-2f38fd0576-d67a6cc5bc.zip/node_modules/s-age/",\ + "packageDependencies": [\ + ["s-age", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["safe-buffer", [\ + ["npm:5.1.2", {\ + "packageLocation": "./.yarn/cache/safe-buffer-npm-5.1.2-c27fedf6c4-7eb5b48f2e.zip/node_modules/safe-buffer/",\ + "packageDependencies": [\ + ["safe-buffer", "npm:5.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.2.1", {\ + "packageLocation": "./.yarn/cache/safe-buffer-npm-5.2.1-3481c8aa9b-32872cd0ff.zip/node_modules/safe-buffer/",\ + "packageDependencies": [\ + ["safe-buffer", "npm:5.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["safer-buffer", [\ + ["npm:2.1.2", {\ + "packageLocation": "./.yarn/cache/safer-buffer-npm-2.1.2-8d5c0b705e-7eaf7a0cf3.zip/node_modules/safer-buffer/",\ + "packageDependencies": [\ + ["safer-buffer", "npm:2.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["sanitize-html", [\ + ["npm:2.10.0", {\ + "packageLocation": "./.yarn/cache/sanitize-html-npm-2.10.0-78eeb0dd04-16f3214e85.zip/node_modules/sanitize-html/",\ + "packageDependencies": [\ + ["deepmerge", "npm:4.3.1"],\ + ["escape-string-regexp", "npm:4.0.0"],\ + ["htmlparser2", "npm:8.0.2"],\ + ["is-plain-object", "npm:5.0.0"],\ + ["parse-srcset", "npm:1.0.2"],\ + ["postcss", "npm:8.4.27"],\ + ["sanitize-html", "npm:2.10.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["sass", [\ + ["npm:1.99.0", {\ + "packageLocation": "./.yarn/cache/sass-npm-1.99.0-9ed8fe8584-93f9d5c3b3.zip/node_modules/sass/",\ + "packageDependencies": [\ + ["@parcel/watcher", "npm:2.5.6"],\ + ["chokidar", "npm:3.5.3"],\ + ["immutable", "npm:5.1.5"],\ + ["sass", "npm:1.99.0"],\ + ["source-map-js", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["sax", [\ + ["npm:1.2.4", {\ + "packageLocation": "./.yarn/cache/sax-npm-1.2.4-178f05f12f-09b79ff6dc.zip/node_modules/sax/",\ + "packageDependencies": [\ + ["sax", "npm:1.2.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["saxes", [\ + ["npm:6.0.0", {\ + "packageLocation": "./.yarn/cache/saxes-npm-6.0.0-31558949f5-97b50daf6c.zip/node_modules/saxes/",\ + "packageDependencies": [\ + ["saxes", "npm:6.0.0"],\ + ["xmlchars", "npm:2.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["seedrandom", [\ + ["npm:2.4.2", {\ + "packageLocation": "./.yarn/cache/seedrandom-npm-2.4.2-b435b54ae9-a0b6707cb7.zip/node_modules/seedrandom/",\ + "packageDependencies": [\ + ["seedrandom", "npm:2.4.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.5", {\ + "packageLocation": "./.yarn/cache/seedrandom-npm-3.0.5-6946e8f8db-acad5e516c.zip/node_modules/seedrandom/",\ + "packageDependencies": [\ + ["seedrandom", "npm:3.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["seek-bzip", [\ + ["npm:1.0.6", {\ + "packageLocation": "./.yarn/cache/seek-bzip-npm-1.0.6-cb7be69a1d-e47967b694.zip/node_modules/seek-bzip/",\ + "packageDependencies": [\ + ["commander", "npm:2.20.3"],\ + ["seek-bzip", "npm:1.0.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["semver", [\ + ["npm:7.5.4", {\ + "packageLocation": "./.yarn/cache/semver-npm-7.5.4-c4ad957fcd-985dec0d37.zip/node_modules/semver/",\ + "packageDependencies": [\ + ["lru-cache", "npm:6.0.0"],\ + ["semver", "npm:7.5.4"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.7.1", {\ + "packageLocation": "./.yarn/cache/semver-npm-7.7.1-4572475307-4cfa1eb91e.zip/node_modules/semver/",\ + "packageDependencies": [\ + ["semver", "npm:7.7.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["semver-regex", [\ + ["npm:4.0.5", {\ + "packageLocation": "./.yarn/cache/semver-regex-npm-4.0.5-9917344f87-b9e5c0573c.zip/node_modules/semver-regex/",\ + "packageDependencies": [\ + ["semver-regex", "npm:4.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["semver-truncate", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/semver-truncate-npm-3.0.0-356f164bc0-d8c2381221.zip/node_modules/semver-truncate/",\ + "packageDependencies": [\ + ["semver", "npm:7.5.4"],\ + ["semver-truncate", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["set-blocking", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/set-blocking-npm-2.0.0-49e2cffa24-8980ebf7ae.zip/node_modules/set-blocking/",\ + "packageDependencies": [\ + ["set-blocking", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["setimmediate", [\ + ["npm:1.0.5", {\ + "packageLocation": "./.yarn/cache/setimmediate-npm-1.0.5-54587459b6-76e3f5d7f4.zip/node_modules/setimmediate/",\ + "packageDependencies": [\ + ["setimmediate", "npm:1.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["setprototypeof", [\ + ["npm:1.1.0", {\ + "packageLocation": "./.yarn/cache/setprototypeof-npm-1.1.0-7d8becb375-02d2564e02.zip/node_modules/setprototypeof/",\ + "packageDependencies": [\ + ["setprototypeof", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.2.0", {\ + "packageLocation": "./.yarn/cache/setprototypeof-npm-1.2.0-0fedbdcd3a-fde1630422.zip/node_modules/setprototypeof/",\ + "packageDependencies": [\ + ["setprototypeof", "npm:1.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["sha.js", [\ + ["npm:2.4.11", {\ + "packageLocation": "./.yarn/cache/sha.js-npm-2.4.11-14868df4ca-d833bfa3e0.zip/node_modules/sha.js/",\ + "packageDependencies": [\ + ["inherits", "npm:2.0.4"],\ + ["safe-buffer", "npm:5.2.1"],\ + ["sha.js", "npm:2.4.11"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["sharp", [\ + ["npm:0.33.5", {\ + "packageLocation": "./.yarn/unplugged/sharp-npm-0.33.5-a76aeda369/node_modules/sharp/",\ + "packageDependencies": [\ + ["@img/sharp-darwin-arm64", "npm:0.33.5"],\ + ["@img/sharp-darwin-x64", "npm:0.33.5"],\ + ["@img/sharp-libvips-darwin-arm64", "npm:1.0.4"],\ + ["@img/sharp-libvips-darwin-x64", "npm:1.0.4"],\ + ["@img/sharp-libvips-linux-arm", "npm:1.0.5"],\ + ["@img/sharp-libvips-linux-arm64", "npm:1.0.4"],\ + ["@img/sharp-libvips-linux-s390x", "npm:1.0.4"],\ + ["@img/sharp-libvips-linux-x64", "npm:1.0.4"],\ + ["@img/sharp-libvips-linuxmusl-arm64", "npm:1.0.4"],\ + ["@img/sharp-libvips-linuxmusl-x64", "npm:1.0.4"],\ + ["@img/sharp-linux-arm", "npm:0.33.5"],\ + ["@img/sharp-linux-arm64", "npm:0.33.5"],\ + ["@img/sharp-linux-s390x", "npm:0.33.5"],\ + ["@img/sharp-linux-x64", "npm:0.33.5"],\ + ["@img/sharp-linuxmusl-arm64", "npm:0.33.5"],\ + ["@img/sharp-linuxmusl-x64", "npm:0.33.5"],\ + ["@img/sharp-wasm32", "npm:0.33.5"],\ + ["@img/sharp-win32-ia32", "npm:0.33.5"],\ + ["@img/sharp-win32-x64", "npm:0.33.5"],\ + ["color", "npm:4.2.3"],\ + ["detect-libc", "npm:2.0.3"],\ + ["semver", "npm:7.7.1"],\ + ["sharp", "npm:0.33.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["shebang-command", [\ + ["npm:1.2.0", {\ + "packageLocation": "./.yarn/cache/shebang-command-npm-1.2.0-8990ba5d1d-9eed175030.zip/node_modules/shebang-command/",\ + "packageDependencies": [\ + ["shebang-command", "npm:1.2.0"],\ + ["shebang-regex", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/shebang-command-npm-2.0.0-eb2b01921d-6b52fe8727.zip/node_modules/shebang-command/",\ + "packageDependencies": [\ + ["shebang-command", "npm:2.0.0"],\ + ["shebang-regex", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["shebang-regex", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/shebang-regex-npm-1.0.0-c3612b74e9-404c5a752c.zip/node_modules/shebang-regex/",\ + "packageDependencies": [\ + ["shebang-regex", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/shebang-regex-npm-3.0.0-899a0cd65e-1a2bcae50d.zip/node_modules/shebang-regex/",\ + "packageDependencies": [\ + ["shebang-regex", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["shogiops", [\ + ["npm:0.21.0", {\ + "packageLocation": "./.yarn/cache/shogiops-npm-0.21.0-f45b1321cf-b6618f0da4.zip/node_modules/shogiops/",\ + "packageDependencies": [\ + ["@badrap/result", "npm:0.3.1"],\ + ["shogiops", "npm:0.21.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["side-channel", [\ + ["npm:1.0.4", {\ + "packageLocation": "./.yarn/cache/side-channel-npm-1.0.4-e1f38b9e06-c4998d9fc5.zip/node_modules/side-channel/",\ + "packageDependencies": [\ + ["call-bind", "npm:1.0.2"],\ + ["get-intrinsic", "npm:1.2.1"],\ + ["object-inspect", "npm:1.12.3"],\ + ["side-channel", "npm:1.0.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["signal-exit", [\ + ["npm:3.0.7", {\ + "packageLocation": "./.yarn/cache/signal-exit-npm-3.0.7-bd270458a3-a2f098f247.zip/node_modules/signal-exit/",\ + "packageDependencies": [\ + ["signal-exit", "npm:3.0.7"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.2", {\ + "packageLocation": "./.yarn/cache/signal-exit-npm-4.0.2-e3f0e8ed25-99d49eab7f.zip/node_modules/signal-exit/",\ + "packageDependencies": [\ + ["signal-exit", "npm:4.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["simple-swizzle", [\ + ["npm:0.2.2", {\ + "packageLocation": "./.yarn/cache/simple-swizzle-npm-0.2.2-8dee37fad1-c6dffff17a.zip/node_modules/simple-swizzle/",\ + "packageDependencies": [\ + ["is-arrayish", "npm:0.3.2"],\ + ["simple-swizzle", "npm:0.2.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["slash", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/slash-npm-3.0.0-b87de2279a-94a93fff61.zip/node_modules/slash/",\ + "packageDependencies": [\ + ["slash", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["smart-buffer", [\ + ["npm:4.2.0", {\ + "packageLocation": "./.yarn/cache/smart-buffer-npm-4.2.0-5ac3f668bb-927484aa0b.zip/node_modules/smart-buffer/",\ + "packageDependencies": [\ + ["smart-buffer", "npm:4.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["socks", [\ + ["npm:2.7.1", {\ + "packageLocation": "./.yarn/cache/socks-npm-2.7.1-17f2b53052-5074f7d6a1.zip/node_modules/socks/",\ + "packageDependencies": [\ + ["ip", "npm:2.0.0"],\ + ["smart-buffer", "npm:4.2.0"],\ + ["socks", "npm:2.7.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.8.5", {\ + "packageLocation": "./.yarn/cache/socks-npm-2.8.5-d96a42ad79-0109090ec2.zip/node_modules/socks/",\ + "packageDependencies": [\ + ["ip-address", "npm:9.0.5"],\ + ["smart-buffer", "npm:4.2.0"],\ + ["socks", "npm:2.8.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["socks-proxy-agent", [\ + ["npm:7.0.0", {\ + "packageLocation": "./.yarn/cache/socks-proxy-agent-npm-7.0.0-7aacf32ea0-26c75d9c62.zip/node_modules/socks-proxy-agent/",\ + "packageDependencies": [\ + ["agent-base", "npm:6.0.2"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["socks", "npm:2.7.1"],\ + ["socks-proxy-agent", "npm:7.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:8.0.5", {\ + "packageLocation": "./.yarn/cache/socks-proxy-agent-npm-8.0.5-24d77a90dc-ee99e1daca.zip/node_modules/socks-proxy-agent/",\ + "packageDependencies": [\ + ["agent-base", "npm:7.1.3"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["socks", "npm:2.8.5"],\ + ["socks-proxy-agent", "npm:8.0.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["sort-keys", [\ + ["npm:1.1.2", {\ + "packageLocation": "./.yarn/cache/sort-keys-npm-1.1.2-2ac0ab2d94-0ac2ea2327.zip/node_modules/sort-keys/",\ + "packageDependencies": [\ + ["is-plain-obj", "npm:1.1.0"],\ + ["sort-keys", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["sort-keys-length", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/sort-keys-length-npm-1.0.1-e2fe040a06-f9acac5fb3.zip/node_modules/sort-keys-length/",\ + "packageDependencies": [\ + ["sort-keys", "npm:1.1.2"],\ + ["sort-keys-length", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["sortablejs", [\ + ["npm:1.14.0", {\ + "packageLocation": "./.yarn/cache/sortablejs-npm-1.14.0-77f80432c4-434e0caaab.zip/node_modules/sortablejs/",\ + "packageDependencies": [\ + ["sortablejs", "npm:1.14.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["source-map", [\ + ["npm:0.6.1", {\ + "packageLocation": "./.yarn/cache/source-map-npm-0.6.1-1a3621db16-59ef7462f1.zip/node_modules/source-map/",\ + "packageDependencies": [\ + ["source-map", "npm:0.6.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.7.4", {\ + "packageLocation": "./.yarn/cache/source-map-npm-0.7.4-bc8d018ab6-a0f7c9b797.zip/node_modules/source-map/",\ + "packageDependencies": [\ + ["source-map", "npm:0.7.4"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.7.6", {\ + "packageLocation": "./.yarn/cache/source-map-npm-0.7.6-a3854be193-c8d2da7c57.zip/node_modules/source-map/",\ + "packageDependencies": [\ + ["source-map", "npm:0.7.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["source-map-js", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/source-map-js-npm-1.0.2-ee4f9f9b30-38e2d2dd18.zip/node_modules/source-map-js/",\ + "packageDependencies": [\ + ["source-map-js", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.2.1", {\ + "packageLocation": "./.yarn/cache/source-map-js-npm-1.2.1-b9a47d7e1a-ff9d8c8bf0.zip/node_modules/source-map-js/",\ + "packageDependencies": [\ + ["source-map-js", "npm:1.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["split2", [\ + ["npm:4.2.0", {\ + "packageLocation": "./.yarn/cache/split2-npm-4.2.0-16aa3883ba-09bbefc11b.zip/node_modules/split2/",\ + "packageDependencies": [\ + ["split2", "npm:4.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["sprintf-js", [\ + ["npm:1.0.3", {\ + "packageLocation": "./.yarn/cache/sprintf-js-npm-1.0.3-73f0a322fa-c34828732a.zip/node_modules/sprintf-js/",\ + "packageDependencies": [\ + ["sprintf-js", "npm:1.0.3"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.1.2", {\ + "packageLocation": "./.yarn/cache/sprintf-js-npm-1.1.2-ea16269a6d-0044322a25.zip/node_modules/sprintf-js/",\ + "packageDependencies": [\ + ["sprintf-js", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.1.3", {\ + "packageLocation": "./.yarn/cache/sprintf-js-npm-1.1.3-b99efd75b2-e7587128c4.zip/node_modules/sprintf-js/",\ + "packageDependencies": [\ + ["sprintf-js", "npm:1.1.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["sshpk", [\ + ["npm:1.17.0", {\ + "packageLocation": "./.yarn/cache/sshpk-npm-1.17.0-95f17f597f-668c2a279a.zip/node_modules/sshpk/",\ + "packageDependencies": [\ + ["asn1", "npm:0.2.6"],\ + ["assert-plus", "npm:1.0.0"],\ + ["bcrypt-pbkdf", "npm:1.0.2"],\ + ["dashdash", "npm:1.14.1"],\ + ["ecc-jsbn", "npm:0.1.2"],\ + ["getpass", "npm:0.1.7"],\ + ["jsbn", "npm:0.1.1"],\ + ["safer-buffer", "npm:2.1.2"],\ + ["sshpk", "npm:1.17.0"],\ + ["tweetnacl", "npm:0.14.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ssri", [\ + ["npm:10.0.4", {\ + "packageLocation": "./.yarn/cache/ssri-npm-10.0.4-f583dafaf3-3f3dc4a0bb.zip/node_modules/ssri/",\ + "packageDependencies": [\ + ["minipass", "npm:5.0.0"],\ + ["ssri", "npm:10.0.4"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:12.0.0", {\ + "packageLocation": "./.yarn/cache/ssri-npm-12.0.0-97c0e53d2e-7024c1a6e3.zip/node_modules/ssri/",\ + "packageDependencies": [\ + ["minipass", "npm:7.0.4"],\ + ["ssri", "npm:12.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["standard-as-callback", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/standard-as-callback-npm-2.1.0-8e47620bd4-88bec83ee2.zip/node_modules/standard-as-callback/",\ + "packageDependencies": [\ + ["standard-as-callback", "npm:2.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["statuses", [\ + ["npm:1.5.0", {\ + "packageLocation": "./.yarn/cache/statuses-npm-1.5.0-f88f91b2e9-c469b9519d.zip/node_modules/statuses/",\ + "packageDependencies": [\ + ["statuses", "npm:1.5.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/statuses-npm-2.0.1-81d2b97fee-18c7623fdb.zip/node_modules/statuses/",\ + "packageDependencies": [\ + ["statuses", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.2", {\ + "packageLocation": "./.yarn/cache/statuses-npm-2.0.2-2d84c63b8c-6927feb50c.zip/node_modules/statuses/",\ + "packageDependencies": [\ + ["statuses", "npm:2.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["stream-browserify", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/stream-browserify-npm-3.0.0-4c0bd97245-05a3cd0a0c.zip/node_modules/stream-browserify/",\ + "packageDependencies": [\ + ["inherits", "npm:2.0.4"],\ + ["readable-stream", "npm:3.6.2"],\ + ["stream-browserify", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["stream-parser", [\ + ["npm:0.3.1", {\ + "packageLocation": "./.yarn/cache/stream-parser-npm-0.3.1-0b70187c85-1ea2bbbb4e.zip/node_modules/stream-parser/",\ + "packageDependencies": [\ + ["debug", "virtual:0b70187c8540c711e7ec6828978e1d7ecbf862adebfcbfade4bfe1470fbdf59ca56319a056ee7ea510b1dd57a2faea769d1589a1b06d26c3a84a4bd41431045b#npm:2.6.9"],\ + ["stream-parser", "npm:0.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["stream-shift", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/stream-shift-npm-1.0.1-9526210fa7-59b82b44b2.zip/node_modules/stream-shift/",\ + "packageDependencies": [\ + ["stream-shift", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["streamsearch", [\ + ["npm:1.1.0", {\ + "packageLocation": "./.yarn/cache/streamsearch-npm-1.1.0-fc3ad6536d-612c2b2a7d.zip/node_modules/streamsearch/",\ + "packageDependencies": [\ + ["streamsearch", "npm:1.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["streamx", [\ + ["npm:2.15.0", {\ + "packageLocation": "./.yarn/cache/streamx-npm-2.15.0-ea55d22e88-c4d311a4b7.zip/node_modules/streamx/",\ + "packageDependencies": [\ + ["fast-fifo", "npm:1.3.0"],\ + ["queue-tick", "npm:1.0.1"],\ + ["streamx", "npm:2.15.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["strict-event-emitter-types", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/strict-event-emitter-types-npm-2.0.0-f24fda1f61-d7b28708bf.zip/node_modules/strict-event-emitter-types/",\ + "packageDependencies": [\ + ["strict-event-emitter-types", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["string-argv", [\ + ["npm:0.3.2", {\ + "packageLocation": "./.yarn/cache/string-argv-npm-0.3.2-6e057a88f1-f9d3addf88.zip/node_modules/string-argv/",\ + "packageDependencies": [\ + ["string-argv", "npm:0.3.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["string-width", [\ + ["npm:4.2.3", {\ + "packageLocation": "./.yarn/cache/string-width-npm-4.2.3-2c27177bae-e52c10dc3f.zip/node_modules/string-width/",\ + "packageDependencies": [\ + ["emoji-regex", "npm:8.0.0"],\ + ["is-fullwidth-code-point", "npm:3.0.0"],\ + ["string-width", "npm:4.2.3"],\ + ["strip-ansi", "npm:6.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.1.2", {\ + "packageLocation": "./.yarn/cache/string-width-npm-5.1.2-bf60531341-7369deaa29.zip/node_modules/string-width/",\ + "packageDependencies": [\ + ["eastasianwidth", "npm:0.2.0"],\ + ["emoji-regex", "npm:9.2.2"],\ + ["string-width", "npm:5.1.2"],\ + ["strip-ansi", "npm:7.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["string_decoder", [\ + ["npm:1.1.1", {\ + "packageLocation": "./.yarn/cache/string_decoder-npm-1.1.1-e46a6c1353-7c41c17ed4.zip/node_modules/string_decoder/",\ + "packageDependencies": [\ + ["safe-buffer", "npm:5.1.2"],\ + ["string_decoder", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.3.0", {\ + "packageLocation": "./.yarn/cache/string_decoder-npm-1.3.0-2422117fd0-54d23f4a6a.zip/node_modules/string_decoder/",\ + "packageDependencies": [\ + ["safe-buffer", "npm:5.2.1"],\ + ["string_decoder", "npm:1.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["stringz", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/stringz-npm-2.1.0-7e1a5cafed-1745b7d508.zip/node_modules/stringz/",\ + "packageDependencies": [\ + ["char-regex", "npm:1.0.2"],\ + ["stringz", "npm:2.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["strip-ansi", [\ + ["npm:6.0.1", {\ + "packageLocation": "./.yarn/cache/strip-ansi-npm-6.0.1-caddc7cb40-ae3b5436d3.zip/node_modules/strip-ansi/",\ + "packageDependencies": [\ + ["ansi-regex", "npm:5.0.1"],\ + ["strip-ansi", "npm:6.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.1.0", {\ + "packageLocation": "./.yarn/cache/strip-ansi-npm-7.1.0-7453b80b79-475f53e9c4.zip/node_modules/strip-ansi/",\ + "packageDependencies": [\ + ["ansi-regex", "npm:6.0.1"],\ + ["strip-ansi", "npm:7.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["strip-bom", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/strip-bom-npm-3.0.0-71e8f81ff9-8d50ff27b7.zip/node_modules/strip-bom/",\ + "packageDependencies": [\ + ["strip-bom", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["strip-dirs", [\ + ["npm:2.1.0", {\ + "packageLocation": "./.yarn/cache/strip-dirs-npm-2.1.0-f0e727d3fc-7284fc61cf.zip/node_modules/strip-dirs/",\ + "packageDependencies": [\ + ["is-natural-number", "npm:4.0.1"],\ + ["strip-dirs", "npm:2.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["strip-eof", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/strip-eof-npm-1.0.0-d82eaf947c-40bc8ddd7e.zip/node_modules/strip-eof/",\ + "packageDependencies": [\ + ["strip-eof", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["strip-final-newline", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/strip-final-newline-npm-2.0.0-340c4f7c66-69412b5e25.zip/node_modules/strip-final-newline/",\ + "packageDependencies": [\ + ["strip-final-newline", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/strip-final-newline-npm-3.0.0-7972cbec8b-23ee263adf.zip/node_modules/strip-final-newline/",\ + "packageDependencies": [\ + ["strip-final-newline", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["strip-json-comments", [\ + ["npm:3.1.1", {\ + "packageLocation": "./.yarn/cache/strip-json-comments-npm-3.1.1-dcb2324823-492f73e272.zip/node_modules/strip-json-comments/",\ + "packageDependencies": [\ + ["strip-json-comments", "npm:3.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["strip-outer", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/strip-outer-npm-2.0.0-e2e50ba4cc-14ef9fe861.zip/node_modules/strip-outer/",\ + "packageDependencies": [\ + ["strip-outer", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["strnum", [\ + ["npm:1.0.5", {\ + "packageLocation": "./.yarn/cache/strnum-npm-1.0.5-9ba11d2a0a-d3117975db.zip/node_modules/strnum/",\ + "packageDependencies": [\ + ["strnum", "npm:1.0.5"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.3.0", {\ + "packageLocation": "./.yarn/cache/strnum-npm-2.3.0-1b600c4c95-ce79c86bb2.zip/node_modules/strnum/",\ + "packageDependencies": [\ + ["strnum", "npm:2.3.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["strtok3", [\ + ["npm:10.3.5", {\ + "packageLocation": "./.yarn/cache/strtok3-npm-10.3.5-39bf4875d5-7279dc97a7.zip/node_modules/strtok3/",\ + "packageDependencies": [\ + ["@tokenizer/token", "npm:0.3.0"],\ + ["strtok3", "npm:10.3.5"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.0", {\ + "packageLocation": "./.yarn/cache/strtok3-npm-7.0.0-bb1edd9ba5-4f2269679f.zip/node_modules/strtok3/",\ + "packageDependencies": [\ + ["@tokenizer/token", "npm:0.3.0"],\ + ["peek-readable", "npm:5.0.0"],\ + ["strtok3", "npm:7.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["sucrase", [\ + ["npm:3.35.1", {\ + "packageLocation": "./.yarn/cache/sucrase-npm-3.35.1-9a5f68e2af-539f5c6ebc.zip/node_modules/sucrase/",\ + "packageDependencies": [\ + ["@jridgewell/gen-mapping", "npm:0.3.3"],\ + ["commander", "npm:4.1.1"],\ + ["lines-and-columns", "npm:1.2.4"],\ + ["mz", "npm:2.7.0"],\ + ["pirates", "npm:4.0.7"],\ + ["sucrase", "npm:3.35.1"],\ + ["tinyglobby", "npm:0.2.16"],\ + ["ts-interface-checker", "npm:0.1.13"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["supports-color", [\ + ["npm:5.5.0", {\ + "packageLocation": "./.yarn/cache/supports-color-npm-5.5.0-183ac537bc-5f505c6fa3.zip/node_modules/supports-color/",\ + "packageDependencies": [\ + ["has-flag", "npm:3.0.0"],\ + ["supports-color", "npm:5.5.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.2.0", {\ + "packageLocation": "./.yarn/cache/supports-color-npm-7.2.0-606bfcf7da-c8bb7afd56.zip/node_modules/supports-color/",\ + "packageDependencies": [\ + ["has-flag", "npm:4.0.0"],\ + ["supports-color", "npm:7.2.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:8.1.1", {\ + "packageLocation": "./.yarn/cache/supports-color-npm-8.1.1-289e937149-157b534df8.zip/node_modules/supports-color/",\ + "packageDependencies": [\ + ["has-flag", "npm:4.0.0"],\ + ["supports-color", "npm:8.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["supports-preserve-symlinks-flag", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/supports-preserve-symlinks-flag-npm-1.0.0-f17c4d0028-a9dc19ae22.zip/node_modules/supports-preserve-symlinks-flag/",\ + "packageDependencies": [\ + ["supports-preserve-symlinks-flag", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["sw", [\ + ["workspace:packages/sw", {\ + "packageLocation": "./packages/sw/",\ + "packageDependencies": [\ + ["@types/js-yaml", "npm:4.0.5"],\ + ["iceshrimp-sdk", "workspace:packages/iceshrimp-sdk"],\ + ["idb-keyval", "npm:6.2.1"],\ + ["js-yaml", "npm:4.1.0"],\ + ["sw", "workspace:packages/sw"],\ + ["tsup", "virtual:ca97bcf58b4df736e59aebf35e2726a99801473dc9e8d2e8b50d915881743bb290aff0829afda73eac01b177f0dbabc037ad0f5cab207f280afc6224bb84bfd8#npm:8.5.1"]\ + ],\ + "linkType": "SOFT"\ + }]\ + ]],\ + ["swiper", [\ + ["npm:11.2.10", {\ + "packageLocation": "./.yarn/cache/swiper-npm-11.2.10-2bf88a66cf-c3dde83e65.zip/node_modules/swiper/",\ + "packageDependencies": [\ + ["swiper", "npm:11.2.10"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["symbol-tree", [\ + ["npm:3.2.4", {\ + "packageLocation": "./.yarn/cache/symbol-tree-npm-3.2.4-fe70cdb75b-c09a00aadf.zip/node_modules/symbol-tree/",\ + "packageDependencies": [\ + ["symbol-tree", "npm:3.2.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["syslog-pro", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/syslog-pro-npm-1.0.0-ba0abe8320-6253c9c93f.zip/node_modules/syslog-pro/",\ + "packageDependencies": [\ + ["moment", "npm:2.29.4"],\ + ["syslog-pro", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["systeminformation", [\ + ["npm:5.21.12", {\ + "packageLocation": "./.yarn/unplugged/systeminformation-npm-5.21.12-948047a9ef/node_modules/systeminformation/",\ + "packageDependencies": [\ + ["systeminformation", "npm:5.21.12"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["syuilo-password-strength", [\ + ["npm:0.0.1", {\ + "packageLocation": "./.yarn/cache/syuilo-password-strength-npm-0.0.1-5704d45262-1cb0eede89.zip/node_modules/syuilo-password-strength/",\ + "packageDependencies": [\ + ["syuilo-password-strength", "npm:0.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tabbable", [\ + ["npm:6.2.0", {\ + "packageLocation": "./.yarn/cache/tabbable-npm-6.2.0-5a74c8b4e2-980fa73476.zip/node_modules/tabbable/",\ + "packageDependencies": [\ + ["tabbable", "npm:6.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tagged-tag", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/tagged-tag-npm-1.0.0-80e0c0061d-e37653df3e.zip/node_modules/tagged-tag/",\ + "packageDependencies": [\ + ["tagged-tag", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tar", [\ + ["npm:6.1.15", {\ + "packageLocation": "./.yarn/cache/tar-npm-6.1.15-44c3e71720-4848b92da8.zip/node_modules/tar/",\ + "packageDependencies": [\ + ["chownr", "npm:2.0.0"],\ + ["fs-minipass", "npm:2.1.0"],\ + ["minipass", "npm:5.0.0"],\ + ["minizlib", "npm:2.1.2"],\ + ["mkdirp", "npm:1.0.4"],\ + ["tar", "npm:6.1.15"],\ + ["yallist", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.4.3", {\ + "packageLocation": "./.yarn/cache/tar-npm-7.4.3-1dbbd1ffc3-12a2a4fc6d.zip/node_modules/tar/",\ + "packageDependencies": [\ + ["@isaacs/fs-minipass", "npm:4.0.1"],\ + ["chownr", "npm:3.0.0"],\ + ["minipass", "npm:7.1.2"],\ + ["minizlib", "npm:3.0.2"],\ + ["mkdirp", "npm:3.0.1"],\ + ["tar", "npm:7.4.3"],\ + ["yallist", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tar-stream", [\ + ["npm:1.6.2", {\ + "packageLocation": "./.yarn/cache/tar-stream-npm-1.6.2-f4a7fc08e2-ac9b850bd4.zip/node_modules/tar-stream/",\ + "packageDependencies": [\ + ["bl", "npm:1.2.3"],\ + ["buffer-alloc", "npm:1.2.0"],\ + ["end-of-stream", "npm:1.4.4"],\ + ["fs-constants", "npm:1.0.0"],\ + ["readable-stream", "npm:2.3.8"],\ + ["tar-stream", "npm:1.6.2"],\ + ["to-buffer", "npm:1.1.1"],\ + ["xtend", "npm:4.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.2.0", {\ + "packageLocation": "./.yarn/cache/tar-stream-npm-2.2.0-884c79b510-1a52a51d24.zip/node_modules/tar-stream/",\ + "packageDependencies": [\ + ["bl", "npm:4.1.0"],\ + ["end-of-stream", "npm:1.4.4"],\ + ["fs-constants", "npm:1.0.0"],\ + ["inherits", "npm:2.0.4"],\ + ["readable-stream", "npm:3.6.2"],\ + ["tar-stream", "npm:2.2.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.1.6", {\ + "packageLocation": "./.yarn/cache/tar-stream-npm-3.1.6-ce3ac17e49-2c32e0d57d.zip/node_modules/tar-stream/",\ + "packageDependencies": [\ + ["b4a", "npm:1.6.4"],\ + ["fast-fifo", "npm:1.3.0"],\ + ["streamx", "npm:2.15.0"],\ + ["tar-stream", "npm:3.1.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tdigest", [\ + ["npm:0.1.2", {\ + "packageLocation": "./.yarn/cache/tdigest-npm-0.1.2-b73cfcf726-45be99fa52.zip/node_modules/tdigest/",\ + "packageDependencies": [\ + ["bintrees", "npm:1.0.2"],\ + ["tdigest", "npm:0.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tesseract.js", [\ + ["npm:7.0.0", {\ + "packageLocation": "./.yarn/unplugged/tesseract.js-npm-7.0.0-51e9f46b42/node_modules/tesseract.js/",\ + "packageDependencies": [\ + ["bmp-js", "npm:0.1.0"],\ + ["idb-keyval", "npm:6.2.2"],\ + ["is-url", "npm:1.2.4"],\ + ["node-fetch", "virtual:51e9f46b428ea6d95952b3989264341ce06b0a468128a1513d2a677665e3df49f50badc4ffe0876bf3a85788c6bc671492e31321ae54acc78e8c8cca85a3138d#npm:2.7.0"],\ + ["opencollective-postinstall", "npm:2.0.3"],\ + ["regenerator-runtime", "npm:0.13.11"],\ + ["tesseract.js", "npm:7.0.0"],\ + ["tesseract.js-core", "npm:7.0.0"],\ + ["wasm-feature-detect", "npm:1.8.0"],\ + ["zlibjs", "npm:0.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tesseract.js-core", [\ + ["npm:7.0.0", {\ + "packageLocation": "./.yarn/cache/tesseract.js-core-npm-7.0.0-0a6d7b4aee-6eab7ecb46.zip/node_modules/tesseract.js-core/",\ + "packageDependencies": [\ + ["tesseract.js-core", "npm:7.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["textarea-caret", [\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/textarea-caret-npm-3.1.0-3f26f63280-6ea556064a.zip/node_modules/textarea-caret/",\ + "packageDependencies": [\ + ["textarea-caret", "npm:3.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["thenify", [\ + ["npm:3.3.1", {\ + "packageLocation": "./.yarn/cache/thenify-npm-3.3.1-030bedb22c-486e1283a8.zip/node_modules/thenify/",\ + "packageDependencies": [\ + ["any-promise", "npm:1.3.0"],\ + ["thenify", "npm:3.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["thenify-all", [\ + ["npm:1.6.0", {\ + "packageLocation": "./.yarn/cache/thenify-all-npm-1.6.0-96309bbc8b-dba7cc8a23.zip/node_modules/thenify-all/",\ + "packageDependencies": [\ + ["thenify", "npm:3.3.1"],\ + ["thenify-all", "npm:1.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["throttle-debounce", [\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/throttle-debounce-npm-5.0.0-f5662f7aca-bedd5a20cd.zip/node_modules/throttle-debounce/",\ + "packageDependencies": [\ + ["throttle-debounce", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["through", [\ + ["npm:2.3.8", {\ + "packageLocation": "./.yarn/cache/through-npm-2.3.8-df5f72a16e-5da78346f7.zip/node_modules/through/",\ + "packageDependencies": [\ + ["through", "npm:2.3.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["through2", [\ + ["npm:2.0.5", {\ + "packageLocation": "./.yarn/cache/through2-npm-2.0.5-77d90f13cd-cd71f7dcdc.zip/node_modules/through2/",\ + "packageDependencies": [\ + ["readable-stream", "npm:2.3.8"],\ + ["through2", "npm:2.0.5"],\ + ["xtend", "npm:4.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tinycolor2", [\ + ["npm:1.5.2", {\ + "packageLocation": "./.yarn/cache/tinycolor2-npm-1.5.2-0898381e92-8a0cffda1f.zip/node_modules/tinycolor2/",\ + "packageDependencies": [\ + ["tinycolor2", "npm:1.5.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:1.6.0", {\ + "packageLocation": "./.yarn/cache/tinycolor2-npm-1.6.0-8df41252c6-066c3acf4f.zip/node_modules/tinycolor2/",\ + "packageDependencies": [\ + ["tinycolor2", "npm:1.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tinyexec", [\ + ["npm:0.3.2", {\ + "packageLocation": "./.yarn/cache/tinyexec-npm-0.3.2-381b1e349c-b9d5fed316.zip/node_modules/tinyexec/",\ + "packageDependencies": [\ + ["tinyexec", "npm:0.3.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tinyglobby", [\ + ["npm:0.2.14", {\ + "packageLocation": "./.yarn/cache/tinyglobby-npm-0.2.14-d4e4bcf80e-3d306d3197.zip/node_modules/tinyglobby/",\ + "packageDependencies": [\ + ["fdir", "virtual:d4e4bcf80e67f9de0540c123c7c4882e34dce6a8ba807a0a834f267f9132ee6bd264e69a49c6203aa89877ed3a5a5d633bfa002384881be452cc3a2d2fbcce0b#npm:6.4.6"],\ + ["picomatch", "npm:4.0.2"],\ + ["tinyglobby", "npm:0.2.14"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.2.16", {\ + "packageLocation": "./.yarn/cache/tinyglobby-npm-0.2.16-102914a73b-5c2c41b572.zip/node_modules/tinyglobby/",\ + "packageDependencies": [\ + ["fdir", "virtual:102914a73b14bffc325c2cdf701d5ae063b57309ea75829f709b4273a7ea0d0e11784f2d6f2635e156595ab235d9a24869844d54ab73f4ad81d3a7b01b185214#npm:6.5.0"],\ + ["picomatch", "npm:4.0.4"],\ + ["tinyglobby", "npm:0.2.16"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tldts", [\ + ["npm:6.1.86", {\ + "packageLocation": "./.yarn/cache/tldts-npm-6.1.86-45627de9ca-f7e66824e4.zip/node_modules/tldts/",\ + "packageDependencies": [\ + ["tldts", "npm:6.1.86"],\ + ["tldts-core", "npm:6.1.86"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tldts-core", [\ + ["npm:6.1.86", {\ + "packageLocation": "./.yarn/cache/tldts-core-npm-6.1.86-540def5eb4-cb5dff9cc1.zip/node_modules/tldts-core/",\ + "packageDependencies": [\ + ["tldts-core", "npm:6.1.86"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tmp", [\ + ["npm:0.2.1", {\ + "packageLocation": "./.yarn/cache/tmp-npm-0.2.1-a9c8d9c0ca-445148d72d.zip/node_modules/tmp/",\ + "packageDependencies": [\ + ["rimraf", "npm:3.0.2"],\ + ["tmp", "npm:0.2.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:0.2.7", {\ + "packageLocation": "./.yarn/cache/tmp-npm-0.2.7-a6fd3441a0-0a3bc90beb.zip/node_modules/tmp/",\ + "packageDependencies": [\ + ["tmp", "npm:0.2.7"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["to-buffer", [\ + ["npm:1.1.1", {\ + "packageLocation": "./.yarn/cache/to-buffer-npm-1.1.1-0be2cf74fe-8ade59fe04.zip/node_modules/to-buffer/",\ + "packageDependencies": [\ + ["to-buffer", "npm:1.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["to-fast-properties", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/to-fast-properties-npm-2.0.0-0dc60cc481-be2de62fe5.zip/node_modules/to-fast-properties/",\ + "packageDependencies": [\ + ["to-fast-properties", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["to-regex-range", [\ + ["npm:5.0.1", {\ + "packageLocation": "./.yarn/cache/to-regex-range-npm-5.0.1-f1e8263b00-10dda13571.zip/node_modules/to-regex-range/",\ + "packageDependencies": [\ + ["is-number", "npm:7.0.0"],\ + ["to-regex-range", "npm:5.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["toidentifier", [\ + ["npm:1.0.1", {\ + "packageLocation": "./.yarn/cache/toidentifier-npm-1.0.1-f759712599-952c29e2a8.zip/node_modules/toidentifier/",\ + "packageDependencies": [\ + ["toidentifier", "npm:1.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["token-stream", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/token-stream-npm-1.0.0-b6bc01bff8-e8adb56f31.zip/node_modules/token-stream/",\ + "packageDependencies": [\ + ["token-stream", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["token-types", [\ + ["npm:5.0.1", {\ + "packageLocation": "./.yarn/cache/token-types-npm-5.0.1-a86fdb8b12-0985369bbe.zip/node_modules/token-types/",\ + "packageDependencies": [\ + ["@tokenizer/token", "npm:0.3.0"],\ + ["ieee754", "npm:1.2.1"],\ + ["token-types", "npm:5.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:6.1.2", {\ + "packageLocation": "./.yarn/cache/token-types-npm-6.1.2-1f6e70d865-0c7811a2da.zip/node_modules/token-types/",\ + "packageDependencies": [\ + ["@borewit/text-codec", "npm:0.2.2"],\ + ["@tokenizer/token", "npm:0.3.0"],\ + ["ieee754", "npm:1.2.1"],\ + ["token-types", "npm:6.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tough-cookie", [\ + ["npm:5.1.2", {\ + "packageLocation": "./.yarn/cache/tough-cookie-npm-5.1.2-bb11a20ec3-de430e6e6d.zip/node_modules/tough-cookie/",\ + "packageDependencies": [\ + ["tldts", "npm:6.1.86"],\ + ["tough-cookie", "npm:5.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tr46", [\ + ["npm:0.0.3", {\ + "packageLocation": "./.yarn/cache/tr46-npm-0.0.3-de53018915-8f1f5aa6cb.zip/node_modules/tr46/",\ + "packageDependencies": [\ + ["tr46", "npm:0.0.3"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.1.1", {\ + "packageLocation": "./.yarn/cache/tr46-npm-5.1.1-88f3ca645b-833a0e1044.zip/node_modules/tr46/",\ + "packageDependencies": [\ + ["punycode", "npm:2.3.1"],\ + ["tr46", "npm:5.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tree-kill", [\ + ["npm:1.2.2", {\ + "packageLocation": "./.yarn/cache/tree-kill-npm-1.2.2-3da0e5a759-49117f5f41.zip/node_modules/tree-kill/",\ + "packageDependencies": [\ + ["tree-kill", "npm:1.2.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["trim-repeated", [\ + ["npm:2.0.0", {\ + "packageLocation": "./.yarn/cache/trim-repeated-npm-2.0.0-330851499f-4086eb0bc5.zip/node_modules/trim-repeated/",\ + "packageDependencies": [\ + ["escape-string-regexp", "npm:5.0.0"],\ + ["trim-repeated", "npm:2.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ts-interface-checker", [\ + ["npm:0.1.13", {\ + "packageLocation": "./.yarn/cache/ts-interface-checker-npm-0.1.13-0c7b064494-9f7346b9e2.zip/node_modules/ts-interface-checker/",\ + "packageDependencies": [\ + ["ts-interface-checker", "npm:0.1.13"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tsconfig-paths", [\ + ["npm:4.2.0", {\ + "packageLocation": "./.yarn/cache/tsconfig-paths-npm-4.2.0-ac1edf8677-5e55cc2fb6.zip/node_modules/tsconfig-paths/",\ + "packageDependencies": [\ + ["json5", "npm:2.2.3"],\ + ["minimist", "npm:1.2.8"],\ + ["strip-bom", "npm:3.0.0"],\ + ["tsconfig-paths", "npm:4.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tslib", [\ + ["npm:2.6.1", {\ + "packageLocation": "./.yarn/cache/tslib-npm-2.6.1-de28eba753-5cf1aa7ea4.zip/node_modules/tslib/",\ + "packageDependencies": [\ + ["tslib", "npm:2.6.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.6.2", {\ + "packageLocation": "./.yarn/cache/tslib-npm-2.6.2-4fc8c068d9-bd26c22d36.zip/node_modules/tslib/",\ + "packageDependencies": [\ + ["tslib", "npm:2.6.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.7.0", {\ + "packageLocation": "./.yarn/cache/tslib-npm-2.7.0-21668f5c21-9a5b47ddac.zip/node_modules/tslib/",\ + "packageDependencies": [\ + ["tslib", "npm:2.7.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.8.1", {\ + "packageLocation": "./.yarn/cache/tslib-npm-2.8.1-66590b21b8-3e2e043d5c.zip/node_modules/tslib/",\ + "packageDependencies": [\ + ["tslib", "npm:2.8.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tsscmp", [\ + ["npm:1.0.6", {\ + "packageLocation": "./.yarn/cache/tsscmp-npm-1.0.6-3223087558-850405080e.zip/node_modules/tsscmp/",\ + "packageDependencies": [\ + ["tsscmp", "npm:1.0.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tsup", [\ + ["npm:8.5.1", {\ + "packageLocation": "./.yarn/cache/tsup-npm-8.5.1-41f4f7d59b-f1927ec2dd.zip/node_modules/tsup/",\ + "packageDependencies": [\ + ["tsup", "npm:8.5.1"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:9edf5f93d67eba3c8380c148f92c9ef44d49ad903cc76cc912170574219df4f3782593d38e7624104e39c45acd9d7b6cc855f78d7141d92f15dca9bd39617c31#npm:8.5.1", {\ + "packageLocation": "./.yarn/__virtual__/tsup-virtual-4b773fcf67/0/cache/tsup-npm-8.5.1-41f4f7d59b-f1927ec2dd.zip/node_modules/tsup/",\ + "packageDependencies": [\ + ["@microsoft/api-extractor", "npm:7.36.3"],\ + ["@swc/core", null],\ + ["@types/microsoft__api-extractor", null],\ + ["@types/postcss", null],\ + ["@types/swc__core", null],\ + ["@types/typescript", null],\ + ["bundle-require", "virtual:4b773fcf67e13422dc15c766b293a59543559d82cada458ec3b1e008dd59d4ec3aa9ee576dd293df519765cd62284cf280961ba91ee4f7468cb4f32aaf42ce24#npm:5.1.0"],\ + ["cac", "npm:6.7.14"],\ + ["chokidar", "npm:3.5.3"],\ + ["consola", "npm:3.4.2"],\ + ["debug", "virtual:90a0f1fb5c11f2caeade015df18a36b1fbdd43c7dd5da4b8fc27a92da9a256be3f461a218d644a2c25bbbb94dccf8169d67cc52a5c2857f0f996be9f75f65682#npm:4.4.3"],\ + ["esbuild", "npm:0.27.7"],\ + ["fix-dts-default-cjs-exports", "npm:1.0.1"],\ + ["joycon", "npm:3.1.1"],\ + ["picocolors", "npm:1.1.1"],\ + ["postcss", null],\ + ["postcss-load-config", "virtual:4b773fcf67e13422dc15c766b293a59543559d82cada458ec3b1e008dd59d4ec3aa9ee576dd293df519765cd62284cf280961ba91ee4f7468cb4f32aaf42ce24#npm:6.0.1"],\ + ["resolve-from", "npm:5.0.0"],\ + ["rollup", "npm:4.60.3"],\ + ["source-map", "npm:0.7.6"],\ + ["sucrase", "npm:3.35.1"],\ + ["tinyexec", "npm:0.3.2"],\ + ["tinyglobby", "npm:0.2.16"],\ + ["tree-kill", "npm:1.2.2"],\ + ["tsup", "virtual:9edf5f93d67eba3c8380c148f92c9ef44d49ad903cc76cc912170574219df4f3782593d38e7624104e39c45acd9d7b6cc855f78d7141d92f15dca9bd39617c31#npm:8.5.1"],\ + ["typescript", "patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5"]\ + ],\ + "packagePeers": [\ + "@microsoft/api-extractor",\ + "@swc/core",\ + "@types/microsoft__api-extractor",\ + "@types/postcss",\ + "@types/swc__core",\ + "@types/typescript",\ + "postcss",\ + "typescript"\ + ],\ + "linkType": "HARD"\ + }],\ + ["virtual:ca97bcf58b4df736e59aebf35e2726a99801473dc9e8d2e8b50d915881743bb290aff0829afda73eac01b177f0dbabc037ad0f5cab207f280afc6224bb84bfd8#npm:8.5.1", {\ + "packageLocation": "./.yarn/__virtual__/tsup-virtual-8440fd6610/0/cache/tsup-npm-8.5.1-41f4f7d59b-f1927ec2dd.zip/node_modules/tsup/",\ + "packageDependencies": [\ + ["@microsoft/api-extractor", null],\ + ["@swc/core", null],\ + ["@types/microsoft__api-extractor", null],\ + ["@types/postcss", null],\ + ["@types/swc__core", null],\ + ["@types/typescript", null],\ + ["bundle-require", "virtual:4b773fcf67e13422dc15c766b293a59543559d82cada458ec3b1e008dd59d4ec3aa9ee576dd293df519765cd62284cf280961ba91ee4f7468cb4f32aaf42ce24#npm:5.1.0"],\ + ["cac", "npm:6.7.14"],\ + ["chokidar", "npm:3.5.3"],\ + ["consola", "npm:3.4.2"],\ + ["debug", "virtual:90a0f1fb5c11f2caeade015df18a36b1fbdd43c7dd5da4b8fc27a92da9a256be3f461a218d644a2c25bbbb94dccf8169d67cc52a5c2857f0f996be9f75f65682#npm:4.4.3"],\ + ["esbuild", "npm:0.27.7"],\ + ["fix-dts-default-cjs-exports", "npm:1.0.1"],\ + ["joycon", "npm:3.1.1"],\ + ["picocolors", "npm:1.1.1"],\ + ["postcss", null],\ + ["postcss-load-config", "virtual:4b773fcf67e13422dc15c766b293a59543559d82cada458ec3b1e008dd59d4ec3aa9ee576dd293df519765cd62284cf280961ba91ee4f7468cb4f32aaf42ce24#npm:6.0.1"],\ + ["resolve-from", "npm:5.0.0"],\ + ["rollup", "npm:4.60.3"],\ + ["source-map", "npm:0.7.6"],\ + ["sucrase", "npm:3.35.1"],\ + ["tinyexec", "npm:0.3.2"],\ + ["tinyglobby", "npm:0.2.16"],\ + ["tree-kill", "npm:1.2.2"],\ + ["tsup", "virtual:ca97bcf58b4df736e59aebf35e2726a99801473dc9e8d2e8b50d915881743bb290aff0829afda73eac01b177f0dbabc037ad0f5cab207f280afc6224bb84bfd8#npm:8.5.1"],\ + ["typescript", null]\ + ],\ + "packagePeers": [\ + "@microsoft/api-extractor",\ + "@swc/core",\ + "@types/microsoft__api-extractor",\ + "@types/postcss",\ + "@types/swc__core",\ + "@types/typescript",\ + "postcss",\ + "typescript"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["tweetnacl", [\ + ["npm:0.14.5", {\ + "packageLocation": "./.yarn/cache/tweetnacl-npm-0.14.5-a3f766c0d1-04ee27901c.zip/node_modules/tweetnacl/",\ + "packageDependencies": [\ + ["tweetnacl", "npm:0.14.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["type", [\ + ["npm:1.2.0", {\ + "packageLocation": "./.yarn/cache/type-npm-1.2.0-e67311c4b2-b4d4b27d19.zip/node_modules/type/",\ + "packageDependencies": [\ + ["type", "npm:1.2.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.7.2", {\ + "packageLocation": "./.yarn/cache/type-npm-2.7.2-626963ea46-602f1b369f.zip/node_modules/type/",\ + "packageDependencies": [\ + ["type", "npm:2.7.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["type-fest", [\ + ["npm:5.6.0", {\ + "packageLocation": "./.yarn/cache/type-fest-npm-5.6.0-daf055db8b-2cc7a510f4.zip/node_modules/type-fest/",\ + "packageDependencies": [\ + ["tagged-tag", "npm:1.0.0"],\ + ["type-fest", "npm:5.6.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["type-is", [\ + ["npm:1.6.18", {\ + "packageLocation": "./.yarn/cache/type-is-npm-1.6.18-6dee4d4961-0bd9eeae5e.zip/node_modules/type-is/",\ + "packageDependencies": [\ + ["media-typer", "npm:0.3.0"],\ + ["mime-types", "npm:2.1.35"],\ + ["type-is", "npm:1.6.18"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["typedarray", [\ + ["npm:0.0.6", {\ + "packageLocation": "./.yarn/cache/typedarray-npm-0.0.6-37638b2241-2cc1bcf7d8.zip/node_modules/typedarray/",\ + "packageDependencies": [\ + ["typedarray", "npm:0.0.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["typedarray-to-buffer", [\ + ["npm:3.1.5", {\ + "packageLocation": "./.yarn/cache/typedarray-to-buffer-npm-3.1.5-aadc11995e-7c850c3433.zip/node_modules/typedarray-to-buffer/",\ + "packageDependencies": [\ + ["is-typedarray", "npm:1.0.0"],\ + ["typedarray-to-buffer", "npm:3.1.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["typeorm", [\ + ["npm:0.3.17", {\ + "packageLocation": "./.yarn/cache/typeorm-npm-0.3.17-f8c2578e7f-3a7fe2a5e9.zip/node_modules/typeorm/",\ + "packageDependencies": [\ + ["typeorm", "npm:0.3.17"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:0.3.17", {\ + "packageLocation": "./.yarn/__virtual__/typeorm-virtual-f799884fa5/0/cache/typeorm-npm-0.3.17-f8c2578e7f-3a7fe2a5e9.zip/node_modules/typeorm/",\ + "packageDependencies": [\ + ["@google-cloud/spanner", null],\ + ["@sap/hana-client", null],\ + ["@sqltools/formatter", "npm:1.2.5"],\ + ["@types/better-sqlite3", null],\ + ["@types/google-cloud__spanner", null],\ + ["@types/hdb-pool", null],\ + ["@types/ioredis", null],\ + ["@types/mongodb", null],\ + ["@types/mssql", null],\ + ["@types/mysql2", null],\ + ["@types/oracledb", null],\ + ["@types/pg", "npm:8.10.5"],\ + ["@types/pg-native", null],\ + ["@types/pg-query-stream", null],\ + ["@types/redis", "npm:4.0.11"],\ + ["@types/sap__hana-client", null],\ + ["@types/sql.js", null],\ + ["@types/sqlite3", null],\ + ["@types/ts-node", null],\ + ["@types/typeorm-aurora-data-api-driver", null],\ + ["app-root-path", "npm:3.1.0"],\ + ["better-sqlite3", null],\ + ["buffer", "npm:6.0.3"],\ + ["chalk", "npm:4.1.2"],\ + ["cli-highlight", "npm:2.1.11"],\ + ["date-fns", "npm:2.30.0"],\ + ["debug", "virtual:ac3d8e680759ce54399273724d44e041d6c9b73454d191d411a8c44bb27e22f02aaf6ed9d3ad0ac1c298eac4833cff369c9c7b84c573016112c4f84be2cd8543#npm:4.3.4"],\ + ["dotenv", "npm:16.3.1"],\ + ["glob", "npm:8.1.0"],\ + ["hdb-pool", null],\ + ["ioredis", "npm:5.4.1"],\ + ["mkdirp", "npm:2.1.6"],\ + ["mongodb", null],\ + ["mssql", null],\ + ["mysql2", null],\ + ["oracledb", null],\ + ["pg", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:8.11.1"],\ + ["pg-native", null],\ + ["pg-query-stream", null],\ + ["redis", null],\ + ["reflect-metadata", "npm:0.1.13"],\ + ["sha.js", "npm:2.4.11"],\ + ["sql.js", null],\ + ["sqlite3", null],\ + ["ts-node", null],\ + ["tslib", "npm:2.6.1"],\ + ["typeorm", "virtual:aa59773ac87791c4813d53447077fcf8a847d6de5a301d34dc31286584b1dbb26d30d3adb5b4c41c1e8aea04371e926fda05c09c6253647c432e11d872a304ba#npm:0.3.17"],\ + ["typeorm-aurora-data-api-driver", null],\ + ["uuid", "npm:9.0.0"],\ + ["yargs", "npm:17.7.2"]\ + ],\ + "packagePeers": [\ + "@google-cloud/spanner",\ + "@sap/hana-client",\ + "@types/better-sqlite3",\ + "@types/google-cloud__spanner",\ + "@types/hdb-pool",\ + "@types/ioredis",\ + "@types/mongodb",\ + "@types/mssql",\ + "@types/mysql2",\ + "@types/oracledb",\ + "@types/pg-native",\ + "@types/pg-query-stream",\ + "@types/pg",\ + "@types/redis",\ + "@types/sap__hana-client",\ + "@types/sql.js",\ + "@types/sqlite3",\ + "@types/ts-node",\ + "@types/typeorm-aurora-data-api-driver",\ + "better-sqlite3",\ + "hdb-pool",\ + "ioredis",\ + "mongodb",\ + "mssql",\ + "mysql2",\ + "oracledb",\ + "pg-native",\ + "pg-query-stream",\ + "pg",\ + "redis",\ + "sql.js",\ + "sqlite3",\ + "ts-node",\ + "typeorm-aurora-data-api-driver"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["typescript", [\ + ["patch:typescript@npm%3A5.0.4#optional!builtin::version=5.0.4&hash=b5f058", {\ + "packageLocation": "./.yarn/cache/typescript-patch-ce5481e54d-b1b62606c7.zip/node_modules/typescript/",\ + "packageDependencies": [\ + ["typescript", "patch:typescript@npm%3A5.0.4#optional!builtin::version=5.0.4&hash=b5f058"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5", {\ + "packageLocation": "./.yarn/cache/typescript-patch-bfb0cdd3b9-22b67a18da.zip/node_modules/typescript/",\ + "packageDependencies": [\ + ["typescript", "patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ufo", [\ + ["npm:1.6.4", {\ + "packageLocation": "./.yarn/cache/ufo-npm-1.6.4-553560ac30-dbf85425e0.zip/node_modules/ufo/",\ + "packageDependencies": [\ + ["ufo", "npm:1.6.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["uint8array-extras", [\ + ["npm:1.5.0", {\ + "packageLocation": "./.yarn/cache/uint8array-extras-npm-1.5.0-30fc87691c-94fd56a2dd.zip/node_modules/uint8array-extras/",\ + "packageDependencies": [\ + ["uint8array-extras", "npm:1.5.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["unbzip2-stream", [\ + ["npm:1.4.3", {\ + "packageLocation": "./.yarn/cache/unbzip2-stream-npm-1.4.3-c5582d6a9f-4ffc0e14f4.zip/node_modules/unbzip2-stream/",\ + "packageDependencies": [\ + ["buffer", "npm:5.7.1"],\ + ["through", "npm:2.3.8"],\ + ["unbzip2-stream", "npm:1.4.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["undici", [\ + ["npm:5.22.1", {\ + "packageLocation": "./.yarn/cache/undici-npm-5.22.1-ff9b0b961e-4e4ae06137.zip/node_modules/undici/",\ + "packageDependencies": [\ + ["busboy", "npm:1.6.0"],\ + ["undici", "npm:5.22.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["undici-types", [\ + ["npm:6.21.0", {\ + "packageLocation": "./.yarn/cache/undici-types-npm-6.21.0-eb2b0ed56a-ec8f41aa43.zip/node_modules/undici-types/",\ + "packageDependencies": [\ + ["undici-types", "npm:6.21.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.19.2", {\ + "packageLocation": "./.yarn/cache/undici-types-npm-7.19.2-93c792b6dd-05c34c6344.zip/node_modules/undici-types/",\ + "packageDependencies": [\ + ["undici-types", "npm:7.19.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["unicode-emoji-json", [\ + ["npm:0.8.0", {\ + "packageLocation": "./.yarn/cache/unicode-emoji-json-npm-0.8.0-c2d2cdbfaf-eadae6a75d.zip/node_modules/unicode-emoji-json/",\ + "packageDependencies": [\ + ["unicode-emoji-json", "npm:0.8.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["unique-filename", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/unique-filename-npm-3.0.0-77d68e0a45-8e2f59b356.zip/node_modules/unique-filename/",\ + "packageDependencies": [\ + ["unique-filename", "npm:3.0.0"],\ + ["unique-slug", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/unique-filename-npm-4.0.0-bfc100c4e3-6a62094fca.zip/node_modules/unique-filename/",\ + "packageDependencies": [\ + ["unique-filename", "npm:4.0.0"],\ + ["unique-slug", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["unique-slug", [\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/unique-slug-npm-4.0.0-e6b08f28aa-40912a8963.zip/node_modules/unique-slug/",\ + "packageDependencies": [\ + ["imurmurhash", "npm:0.1.4"],\ + ["unique-slug", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/unique-slug-npm-5.0.0-11508c0469-beafdf3d6f.zip/node_modules/unique-slug/",\ + "packageDependencies": [\ + ["imurmurhash", "npm:0.1.4"],\ + ["unique-slug", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["universalify", [\ + ["npm:0.1.2", {\ + "packageLocation": "./.yarn/cache/universalify-npm-0.1.2-9b22d31d2d-40cdc60f6e.zip/node_modules/universalify/",\ + "packageDependencies": [\ + ["universalify", "npm:0.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["unload", [\ + ["npm:2.4.1", {\ + "packageLocation": "./.yarn/cache/unload-npm-2.4.1-6b3398fa29-00b1181eac.zip/node_modules/unload/",\ + "packageDependencies": [\ + ["unload", "npm:2.4.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["unpipe", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/unpipe-npm-1.0.0-2ed2a3c2bf-4fa18d8d8d.zip/node_modules/unpipe/",\ + "packageDependencies": [\ + ["unpipe", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["uri-js", [\ + ["npm:4.4.1", {\ + "packageLocation": "./.yarn/cache/uri-js-npm-4.4.1-66d11cbcaf-b271ca7e3d.zip/node_modules/uri-js/",\ + "packageDependencies": [\ + ["punycode", "npm:2.3.0"],\ + ["uri-js", "npm:4.4.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["url-polyfill", [\ + ["npm:1.1.14", {\ + "packageLocation": "./.yarn/cache/url-polyfill-npm-1.1.14-929f4bb606-871b6fb033.zip/node_modules/url-polyfill/",\ + "packageDependencies": [\ + ["url-polyfill", "npm:1.1.14"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["urlsafe-base64", [\ + ["npm:1.0.0", {\ + "packageLocation": "./.yarn/cache/urlsafe-base64-npm-1.0.0-b7c5cc65ae-41d28a3370.zip/node_modules/urlsafe-base64/",\ + "packageDependencies": [\ + ["urlsafe-base64", "npm:1.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["utf-8-validate", [\ + ["npm:5.0.10", {\ + "packageLocation": "./.yarn/unplugged/utf-8-validate-npm-5.0.10-93e9b6f750/node_modules/utf-8-validate/",\ + "packageDependencies": [\ + ["node-gyp", "npm:9.4.0"],\ + ["node-gyp-build", "npm:4.6.0"],\ + ["utf-8-validate", "npm:5.0.10"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["util-deprecate", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/util-deprecate-npm-1.0.2-e3fe1a219c-474acf1146.zip/node_modules/util-deprecate/",\ + "packageDependencies": [\ + ["util-deprecate", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["uuid", [\ + ["npm:14.0.0", {\ + "packageLocation": "./.yarn/cache/uuid-npm-14.0.0-5e662e945a-8ee9b98f96.zip/node_modules/uuid/",\ + "packageDependencies": [\ + ["uuid", "npm:14.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:9.0.0", {\ + "packageLocation": "./.yarn/cache/uuid-npm-9.0.0-46c41e3e43-23857699a6.zip/node_modules/uuid/",\ + "packageDependencies": [\ + ["uuid", "npm:9.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:9.0.1", {\ + "packageLocation": "./.yarn/cache/uuid-npm-9.0.1-39a8442bc6-9d0b6adb72.zip/node_modules/uuid/",\ + "packageDependencies": [\ + ["uuid", "npm:9.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["validator", [\ + ["npm:13.9.0", {\ + "packageLocation": "./.yarn/cache/validator-npm-13.9.0-54b07e9e81-14f77ff495.zip/node_modules/validator/",\ + "packageDependencies": [\ + ["validator", "npm:13.9.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["vary", [\ + ["npm:1.1.2", {\ + "packageLocation": "./.yarn/cache/vary-npm-1.1.2-b49f70ae63-31389debef.zip/node_modules/vary/",\ + "packageDependencies": [\ + ["vary", "npm:1.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["verror", [\ + ["npm:1.10.0", {\ + "packageLocation": "./.yarn/cache/verror-npm-1.10.0-c3f839c579-da548149dd.zip/node_modules/verror/",\ + "packageDependencies": [\ + ["assert-plus", "npm:1.0.0"],\ + ["core-util-is", "npm:1.0.2"],\ + ["extsprintf", "npm:1.4.1"],\ + ["verror", "npm:1.10.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["vite", [\ + ["npm:7.3.3", {\ + "packageLocation": "./.yarn/cache/vite-npm-7.3.3-85845a2842-c7fa17bc0a.zip/node_modules/vite/",\ + "packageDependencies": [\ + ["vite", "npm:7.3.3"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:7.3.3", {\ + "packageLocation": "./.yarn/__virtual__/vite-virtual-a9bea6a9a0/0/cache/vite-npm-7.3.3-85845a2842-c7fa17bc0a.zip/node_modules/vite/",\ + "packageDependencies": [\ + ["@types/jiti", null],\ + ["@types/less", null],\ + ["@types/lightningcss", null],\ + ["@types/node", null],\ + ["@types/sass", null],\ + ["@types/sass-embedded", null],\ + ["@types/stylus", null],\ + ["@types/sugarss", null],\ + ["@types/terser", null],\ + ["@types/tsx", null],\ + ["@types/yaml", null],\ + ["esbuild", "npm:0.27.7"],\ + ["fdir", "virtual:102914a73b14bffc325c2cdf701d5ae063b57309ea75829f709b4273a7ea0d0e11784f2d6f2635e156595ab235d9a24869844d54ab73f4ad81d3a7b01b185214#npm:6.5.0"],\ + ["fsevents", "patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1"],\ + ["jiti", null],\ + ["less", null],\ + ["lightningcss", null],\ + ["picomatch", "npm:4.0.4"],\ + ["postcss", "npm:8.5.14"],\ + ["rollup", "npm:4.60.3"],\ + ["sass", "npm:1.99.0"],\ + ["sass-embedded", null],\ + ["stylus", null],\ + ["sugarss", null],\ + ["terser", null],\ + ["tinyglobby", "npm:0.2.16"],\ + ["tsx", null],\ + ["vite", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:7.3.3"],\ + ["yaml", null]\ + ],\ + "packagePeers": [\ + "@types/jiti",\ + "@types/less",\ + "@types/lightningcss",\ + "@types/node",\ + "@types/sass-embedded",\ + "@types/sass",\ + "@types/stylus",\ + "@types/sugarss",\ + "@types/terser",\ + "@types/tsx",\ + "@types/yaml",\ + "jiti",\ + "less",\ + "lightningcss",\ + "sass-embedded",\ + "sass",\ + "stylus",\ + "sugarss",\ + "terser",\ + "tsx",\ + "yaml"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["void-elements", [\ + ["npm:3.1.0", {\ + "packageLocation": "./.yarn/cache/void-elements-npm-3.1.0-4f43780839-0390f81810.zip/node_modules/void-elements/",\ + "packageDependencies": [\ + ["void-elements", "npm:3.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["vue", [\ + ["npm:3.5.34", {\ + "packageLocation": "./.yarn/cache/vue-npm-3.5.34-d273dd760e-e75bf0e89d.zip/node_modules/vue/",\ + "packageDependencies": [\ + ["vue", "npm:3.5.34"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:3.5.34", {\ + "packageLocation": "./.yarn/__virtual__/vue-virtual-8fb0bb5e34/0/cache/vue-npm-3.5.34-d273dd760e-e75bf0e89d.zip/node_modules/vue/",\ + "packageDependencies": [\ + ["@types/typescript", null],\ + ["@vue/compiler-dom", "npm:3.5.34"],\ + ["@vue/compiler-sfc", "npm:3.5.34"],\ + ["@vue/runtime-dom", "npm:3.5.34"],\ + ["@vue/server-renderer", "virtual:8fb0bb5e3429bf317458b89e57cf60d21419aa43a9e47d4164e4e90443a816c22614c0c4d180e24dc0203d64f80714ea03fad637b2550e20a4b2ca92d495c552#npm:3.5.34"],\ + ["@vue/shared", "npm:3.5.34"],\ + ["typescript", "patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5"],\ + ["vue", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:3.5.34"]\ + ],\ + "packagePeers": [\ + "@types/typescript",\ + "typescript"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["vue-draggable-plus", [\ + ["npm:0.2.2", {\ + "packageLocation": "./.yarn/cache/vue-draggable-plus-npm-0.2.2-3dc8568cbb-3d171b03c0.zip/node_modules/vue-draggable-plus/",\ + "packageDependencies": [\ + ["vue-draggable-plus", "npm:0.2.2"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:0.2.2", {\ + "packageLocation": "./.yarn/__virtual__/vue-draggable-plus-virtual-f7a319434d/0/cache/vue-draggable-plus-npm-0.2.2-3dc8568cbb-3d171b03c0.zip/node_modules/vue-draggable-plus/",\ + "packageDependencies": [\ + ["@types/vue__composition-api", null],\ + ["@vue/composition-api", null],\ + ["vue-draggable-plus", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:0.2.2"]\ + ],\ + "packagePeers": [\ + "@types/vue__composition-api",\ + "@vue/composition-api"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["vue-prism-editor", [\ + ["npm:2.0.0-alpha.2", {\ + "packageLocation": "./.yarn/cache/vue-prism-editor-npm-2.0.0-alpha.2-f64eb00e30-bee15d410c.zip/node_modules/vue-prism-editor/",\ + "packageDependencies": [\ + ["vue-prism-editor", "npm:2.0.0-alpha.2"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:2.0.0-alpha.2", {\ + "packageLocation": "./.yarn/__virtual__/vue-prism-editor-virtual-3c6bfd6c80/0/cache/vue-prism-editor-npm-2.0.0-alpha.2-f64eb00e30-bee15d410c.zip/node_modules/vue-prism-editor/",\ + "packageDependencies": [\ + ["@types/vue", null],\ + ["vue", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:3.5.34"],\ + ["vue-prism-editor", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:2.0.0-alpha.2"]\ + ],\ + "packagePeers": [\ + "@types/vue",\ + "vue"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["vuedraggable", [\ + ["npm:4.1.0", {\ + "packageLocation": "./.yarn/cache/vuedraggable-npm-4.1.0-785593d488-87d4faba83.zip/node_modules/vuedraggable/",\ + "packageDependencies": [\ + ["vuedraggable", "npm:4.1.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:4.1.0", {\ + "packageLocation": "./.yarn/__virtual__/vuedraggable-virtual-9b1f567764/0/cache/vuedraggable-npm-4.1.0-785593d488-87d4faba83.zip/node_modules/vuedraggable/",\ + "packageDependencies": [\ + ["@types/vue", null],\ + ["sortablejs", "npm:1.14.0"],\ + ["vue", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:3.5.34"],\ + ["vuedraggable", "virtual:658502eb4296e93abedc18b6aa9b26978f434f08d98e21ebb0e725354b8bb54b62db9c4a1893e460c694ff7500ff5cbafa4457b0dfd26b5838868666c861e990#npm:4.1.0"]\ + ],\ + "packagePeers": [\ + "@types/vue",\ + "vue"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["w3c-xmlserializer", [\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/w3c-xmlserializer-npm-5.0.0-589edd7bff-d78f59e6b4.zip/node_modules/w3c-xmlserializer/",\ + "packageDependencies": [\ + ["w3c-xmlserializer", "npm:5.0.0"],\ + ["xml-name-validator", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["wasm-feature-detect", [\ + ["npm:1.8.0", {\ + "packageLocation": "./.yarn/cache/wasm-feature-detect-npm-1.8.0-b3a92ea200-54662197b4.zip/node_modules/wasm-feature-detect/",\ + "packageDependencies": [\ + ["wasm-feature-detect", "npm:1.8.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["web-push", [\ + ["npm:3.6.3", {\ + "packageLocation": "./.yarn/cache/web-push-npm-3.6.3-570abed14f-8b2fda652c.zip/node_modules/web-push/",\ + "packageDependencies": [\ + ["asn1.js", "npm:5.4.1"],\ + ["http_ece", "npm:1.1.0"],\ + ["https-proxy-agent", "npm:7.0.1"],\ + ["jws", "npm:4.0.0"],\ + ["minimist", "npm:1.2.8"],\ + ["web-push", "npm:3.6.3"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["web-streams-polyfill", [\ + ["npm:3.2.1", {\ + "packageLocation": "./.yarn/cache/web-streams-polyfill-npm-3.2.1-835bd3857e-08fcf97b78.zip/node_modules/web-streams-polyfill/",\ + "packageDependencies": [\ + ["web-streams-polyfill", "npm:3.2.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["webidl-conversions", [\ + ["npm:3.0.1", {\ + "packageLocation": "./.yarn/cache/webidl-conversions-npm-3.0.1-60310f6a2b-b65b9f8d68.zip/node_modules/webidl-conversions/",\ + "packageDependencies": [\ + ["webidl-conversions", "npm:3.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.0", {\ + "packageLocation": "./.yarn/cache/webidl-conversions-npm-7.0.0-e8c8e30c68-4c4f65472c.zip/node_modules/webidl-conversions/",\ + "packageDependencies": [\ + ["webidl-conversions", "npm:7.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["websocket", [\ + ["npm:1.0.34", {\ + "packageLocation": "./.yarn/cache/websocket-npm-1.0.34-3aaa6c5dc0-b72e3dcc3f.zip/node_modules/websocket/",\ + "packageDependencies": [\ + ["bufferutil", "npm:4.0.7"],\ + ["debug", "virtual:0b70187c8540c711e7ec6828978e1d7ecbf862adebfcbfade4bfe1470fbdf59ca56319a056ee7ea510b1dd57a2faea769d1589a1b06d26c3a84a4bd41431045b#npm:2.6.9"],\ + ["es5-ext", "npm:0.10.62"],\ + ["typedarray-to-buffer", "npm:3.1.5"],\ + ["utf-8-validate", "npm:5.0.10"],\ + ["websocket", "npm:1.0.34"],\ + ["yaeti", "npm:0.0.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["whatwg-encoding", [\ + ["npm:3.1.1", {\ + "packageLocation": "./.yarn/cache/whatwg-encoding-npm-3.1.1-7dfe21cf7d-bbef815eb6.zip/node_modules/whatwg-encoding/",\ + "packageDependencies": [\ + ["iconv-lite", "npm:0.6.3"],\ + ["whatwg-encoding", "npm:3.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["whatwg-mimetype", [\ + ["npm:3.0.0", {\ + "packageLocation": "./.yarn/cache/whatwg-mimetype-npm-3.0.0-5b617710c1-96f9f628c6.zip/node_modules/whatwg-mimetype/",\ + "packageDependencies": [\ + ["whatwg-mimetype", "npm:3.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/whatwg-mimetype-npm-4.0.0-ebb293a688-894a618e2d.zip/node_modules/whatwg-mimetype/",\ + "packageDependencies": [\ + ["whatwg-mimetype", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["whatwg-url", [\ + ["npm:14.2.0", {\ + "packageLocation": "./.yarn/cache/whatwg-url-npm-14.2.0-67b670990c-f0a95b0601.zip/node_modules/whatwg-url/",\ + "packageDependencies": [\ + ["tr46", "npm:5.1.1"],\ + ["webidl-conversions", "npm:7.0.0"],\ + ["whatwg-url", "npm:14.2.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/whatwg-url-npm-5.0.0-374fb45e60-f95adbc1e8.zip/node_modules/whatwg-url/",\ + "packageDependencies": [\ + ["tr46", "npm:0.0.3"],\ + ["webidl-conversions", "npm:3.0.1"],\ + ["whatwg-url", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["which", [\ + ["npm:1.3.1", {\ + "packageLocation": "./.yarn/cache/which-npm-1.3.1-f0ebb8bdd8-549dcf1752.zip/node_modules/which/",\ + "packageDependencies": [\ + ["isexe", "npm:2.0.0"],\ + ["which", "npm:1.3.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:2.0.2", {\ + "packageLocation": "./.yarn/cache/which-npm-2.0.2-320ddf72f7-4782f8a1d6.zip/node_modules/which/",\ + "packageDependencies": [\ + ["isexe", "npm:2.0.0"],\ + ["which", "npm:2.0.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:3.0.1", {\ + "packageLocation": "./.yarn/cache/which-npm-3.0.1-b2b0f09ace-adf720fe9d.zip/node_modules/which/",\ + "packageDependencies": [\ + ["isexe", "npm:2.0.0"],\ + ["which", "npm:3.0.1"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/which-npm-5.0.0-15aa39eb60-6ec99e89ba.zip/node_modules/which/",\ + "packageDependencies": [\ + ["isexe", "npm:3.1.1"],\ + ["which", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["which-module", [\ + ["npm:2.0.1", {\ + "packageLocation": "./.yarn/cache/which-module-npm-2.0.1-90f889f6f6-1967b7ce17.zip/node_modules/which-module/",\ + "packageDependencies": [\ + ["which-module", "npm:2.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["wide-align", [\ + ["npm:1.1.5", {\ + "packageLocation": "./.yarn/cache/wide-align-npm-1.1.5-889d77e592-d5f8027b9a.zip/node_modules/wide-align/",\ + "packageDependencies": [\ + ["string-width", "npm:4.2.3"],\ + ["wide-align", "npm:1.1.5"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["with", [\ + ["npm:7.0.2", {\ + "packageLocation": "./.yarn/cache/with-npm-7.0.2-135a242adb-06ad978f9a.zip/node_modules/with/",\ + "packageDependencies": [\ + ["@babel/parser", "npm:7.22.7"],\ + ["@babel/types", "npm:7.22.5"],\ + ["assert-never", "npm:1.2.1"],\ + ["babel-walk", "npm:3.0.0-canary-5"],\ + ["with", "npm:7.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["wrap-ansi", [\ + ["npm:6.2.0", {\ + "packageLocation": "./.yarn/cache/wrap-ansi-npm-6.2.0-439a7246d8-0d64f2d438.zip/node_modules/wrap-ansi/",\ + "packageDependencies": [\ + ["ansi-styles", "npm:4.3.0"],\ + ["string-width", "npm:4.2.3"],\ + ["strip-ansi", "npm:6.0.1"],\ + ["wrap-ansi", "npm:6.2.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:7.0.0", {\ + "packageLocation": "./.yarn/cache/wrap-ansi-npm-7.0.0-ad6e1a0554-cebdaeca3a.zip/node_modules/wrap-ansi/",\ + "packageDependencies": [\ + ["ansi-styles", "npm:4.3.0"],\ + ["string-width", "npm:4.2.3"],\ + ["strip-ansi", "npm:6.0.1"],\ + ["wrap-ansi", "npm:7.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:8.1.0", {\ + "packageLocation": "./.yarn/cache/wrap-ansi-npm-8.1.0-26a4e6ae28-7b1e4b35e9.zip/node_modules/wrap-ansi/",\ + "packageDependencies": [\ + ["ansi-styles", "npm:6.2.1"],\ + ["string-width", "npm:5.1.2"],\ + ["strip-ansi", "npm:7.1.0"],\ + ["wrap-ansi", "npm:8.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["wrappy", [\ + ["npm:1.0.2", {\ + "packageLocation": "./.yarn/cache/wrappy-npm-1.0.2-916de4d4b3-159da4805f.zip/node_modules/wrappy/",\ + "packageDependencies": [\ + ["wrappy", "npm:1.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ws", [\ + ["npm:8.20.0", {\ + "packageLocation": "./.yarn/cache/ws-npm-8.20.0-b8684d1fe3-b7ab934b21.zip/node_modules/ws/",\ + "packageDependencies": [\ + ["ws", "npm:8.20.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:480a4fb09e5db13ca37db95ab0b87fa859e5f5a8c839e4ec91bb8bc7cd4c00ab4eb257765778c4c33e94967fef678e7ad3b289430644113a0013a853a4a6552f#npm:8.20.0", {\ + "packageLocation": "./.yarn/__virtual__/ws-virtual-224b7ea7d1/0/cache/ws-npm-8.20.0-b8684d1fe3-b7ab934b21.zip/node_modules/ws/",\ + "packageDependencies": [\ + ["@types/bufferutil", null],\ + ["@types/utf-8-validate", null],\ + ["bufferutil", null],\ + ["utf-8-validate", null],\ + ["ws", "virtual:480a4fb09e5db13ca37db95ab0b87fa859e5f5a8c839e4ec91bb8bc7cd4c00ab4eb257765778c4c33e94967fef678e7ad3b289430644113a0013a853a4a6552f#npm:8.20.0"]\ + ],\ + "packagePeers": [\ + "@types/bufferutil",\ + "@types/utf-8-validate",\ + "bufferutil",\ + "utf-8-validate"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["xev", [\ + ["npm:3.0.2", {\ + "packageLocation": "./.yarn/cache/xev-npm-3.0.2-f116178e64-57655a5430.zip/node_modules/xev/",\ + "packageDependencies": [\ + ["xev", "npm:3.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["xml-js", [\ + ["npm:1.6.11", {\ + "packageLocation": "./.yarn/cache/xml-js-npm-1.6.11-56742b7fb0-55ce342a47.zip/node_modules/xml-js/",\ + "packageDependencies": [\ + ["sax", "npm:1.2.4"],\ + ["xml-js", "npm:1.6.11"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["xml-name-validator", [\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/xml-name-validator-npm-5.0.0-0e0ec66944-43f30f3f67.zip/node_modules/xml-name-validator/",\ + "packageDependencies": [\ + ["xml-name-validator", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["xml-naming", [\ + ["npm:0.1.0", {\ + "packageLocation": "./.yarn/cache/xml-naming-npm-0.1.0-6def6e9a28-45abd94ba6.zip/node_modules/xml-naming/",\ + "packageDependencies": [\ + ["xml-naming", "npm:0.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["xml2js", [\ + ["npm:0.5.0", {\ + "packageLocation": "./.yarn/cache/xml2js-npm-0.5.0-06e57a2771-27c4d75921.zip/node_modules/xml2js/",\ + "packageDependencies": [\ + ["sax", "npm:1.2.4"],\ + ["xml2js", "npm:0.5.0"],\ + ["xmlbuilder", "npm:11.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["xmlbuilder", [\ + ["npm:11.0.1", {\ + "packageLocation": "./.yarn/cache/xmlbuilder-npm-11.0.1-b8b04dc929-c8c3d20878.zip/node_modules/xmlbuilder/",\ + "packageDependencies": [\ + ["xmlbuilder", "npm:11.0.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["xmlchars", [\ + ["npm:2.2.0", {\ + "packageLocation": "./.yarn/cache/xmlchars-npm-2.2.0-8b78f0f5e4-4ad5924974.zip/node_modules/xmlchars/",\ + "packageDependencies": [\ + ["xmlchars", "npm:2.2.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["xtend", [\ + ["npm:4.0.2", {\ + "packageLocation": "./.yarn/cache/xtend-npm-4.0.2-7f2375736e-ac5dfa738b.zip/node_modules/xtend/",\ + "packageDependencies": [\ + ["xtend", "npm:4.0.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["y18n", [\ + ["npm:4.0.3", {\ + "packageLocation": "./.yarn/cache/y18n-npm-4.0.3-ced95acdbc-392870b2a1.zip/node_modules/y18n/",\ + "packageDependencies": [\ + ["y18n", "npm:4.0.3"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.0.8", {\ + "packageLocation": "./.yarn/cache/y18n-npm-5.0.8-5f3a0a7e62-5f1b5f95e3.zip/node_modules/y18n/",\ + "packageDependencies": [\ + ["y18n", "npm:5.0.8"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["yaeti", [\ + ["npm:0.0.6", {\ + "packageLocation": "./.yarn/cache/yaeti-npm-0.0.6-cffd01e35a-6db12c152f.zip/node_modules/yaeti/",\ + "packageDependencies": [\ + ["yaeti", "npm:0.0.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["yallist", [\ + ["npm:2.1.2", {\ + "packageLocation": "./.yarn/cache/yallist-npm-2.1.2-2e38c366a3-75fc7bee48.zip/node_modules/yallist/",\ + "packageDependencies": [\ + ["yallist", "npm:2.1.2"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:4.0.0", {\ + "packageLocation": "./.yarn/cache/yallist-npm-4.0.0-b493d9e907-4cb02b42b8.zip/node_modules/yallist/",\ + "packageDependencies": [\ + ["yallist", "npm:4.0.0"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:5.0.0", {\ + "packageLocation": "./.yarn/cache/yallist-npm-5.0.0-8732dd9f1c-1884d272d4.zip/node_modules/yallist/",\ + "packageDependencies": [\ + ["yallist", "npm:5.0.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["yaml", [\ + ["npm:2.3.4", {\ + "packageLocation": "./.yarn/cache/yaml-npm-2.3.4-8bb6dc2c0d-f8207ce430.zip/node_modules/yaml/",\ + "packageDependencies": [\ + ["yaml", "npm:2.3.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["yargs", [\ + ["npm:15.4.1", {\ + "packageLocation": "./.yarn/cache/yargs-npm-15.4.1-ca1c444de1-bbcc822229.zip/node_modules/yargs/",\ + "packageDependencies": [\ + ["cliui", "npm:6.0.0"],\ + ["decamelize", "npm:1.2.0"],\ + ["find-up", "npm:4.1.0"],\ + ["get-caller-file", "npm:2.0.5"],\ + ["require-directory", "npm:2.1.1"],\ + ["require-main-filename", "npm:2.0.0"],\ + ["set-blocking", "npm:2.0.0"],\ + ["string-width", "npm:4.2.3"],\ + ["which-module", "npm:2.0.1"],\ + ["y18n", "npm:4.0.3"],\ + ["yargs", "npm:15.4.1"],\ + ["yargs-parser", "npm:18.1.3"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:16.2.0", {\ + "packageLocation": "./.yarn/cache/yargs-npm-16.2.0-547873d425-807fa21211.zip/node_modules/yargs/",\ + "packageDependencies": [\ + ["cliui", "npm:7.0.4"],\ + ["escalade", "npm:3.1.1"],\ + ["get-caller-file", "npm:2.0.5"],\ + ["require-directory", "npm:2.1.1"],\ + ["string-width", "npm:4.2.3"],\ + ["y18n", "npm:5.0.8"],\ + ["yargs", "npm:16.2.0"],\ + ["yargs-parser", "npm:20.2.9"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:17.7.2", {\ + "packageLocation": "./.yarn/cache/yargs-npm-17.7.2-80b62638e1-abb3e37678.zip/node_modules/yargs/",\ + "packageDependencies": [\ + ["cliui", "npm:8.0.1"],\ + ["escalade", "npm:3.1.1"],\ + ["get-caller-file", "npm:2.0.5"],\ + ["require-directory", "npm:2.1.1"],\ + ["string-width", "npm:4.2.3"],\ + ["y18n", "npm:5.0.8"],\ + ["yargs", "npm:17.7.2"],\ + ["yargs-parser", "npm:21.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["yargs-parser", [\ + ["npm:18.1.3", {\ + "packageLocation": "./.yarn/cache/yargs-parser-npm-18.1.3-0ba9c4f088-235bcbad5b.zip/node_modules/yargs-parser/",\ + "packageDependencies": [\ + ["camelcase", "npm:5.3.1"],\ + ["decamelize", "npm:1.2.0"],\ + ["yargs-parser", "npm:18.1.3"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:20.2.9", {\ + "packageLocation": "./.yarn/cache/yargs-parser-npm-20.2.9-a1d19e598d-0188f430a0.zip/node_modules/yargs-parser/",\ + "packageDependencies": [\ + ["yargs-parser", "npm:20.2.9"]\ + ],\ + "linkType": "HARD"\ + }],\ + ["npm:21.1.1", {\ + "packageLocation": "./.yarn/cache/yargs-parser-npm-21.1.1-8fdc003314-9dc2c217ea.zip/node_modules/yargs-parser/",\ + "packageDependencies": [\ + ["yargs-parser", "npm:21.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["yauzl", [\ + ["npm:2.10.0", {\ + "packageLocation": "./.yarn/cache/yauzl-npm-2.10.0-72e70ea021-1e4c311050.zip/node_modules/yauzl/",\ + "packageDependencies": [\ + ["buffer-crc32", "npm:0.2.13"],\ + ["fd-slicer", "npm:1.1.0"],\ + ["yauzl", "npm:2.10.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["ylru", [\ + ["npm:1.3.2", {\ + "packageLocation": "./.yarn/cache/ylru-npm-1.3.2-81969d097f-56ea73b6fd.zip/node_modules/ylru/",\ + "packageDependencies": [\ + ["ylru", "npm:1.3.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["yoctocolors", [\ + ["npm:2.1.2", {\ + "packageLocation": "./.yarn/cache/yoctocolors-npm-2.1.2-ba5f016605-6ee42d665a.zip/node_modules/yoctocolors/",\ + "packageDependencies": [\ + ["yoctocolors", "npm:2.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["z-schema", [\ + ["npm:5.0.6", {\ + "packageLocation": "./.yarn/cache/z-schema-npm-5.0.6-c40e5eb576-efcdf8a560.zip/node_modules/z-schema/",\ + "packageDependencies": [\ + ["commander", "npm:10.0.1"],\ + ["lodash.get", "npm:4.4.2"],\ + ["lodash.isequal", "npm:4.5.0"],\ + ["validator", "npm:13.9.0"],\ + ["z-schema", "npm:5.0.6"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["zip-stream", [\ + ["npm:4.1.0", {\ + "packageLocation": "./.yarn/cache/zip-stream-npm-4.1.0-c77601aed4-4a73da8567.zip/node_modules/zip-stream/",\ + "packageDependencies": [\ + ["archiver-utils", "npm:2.1.0"],\ + ["compress-commons", "npm:4.1.1"],\ + ["readable-stream", "npm:3.6.2"],\ + ["zip-stream", "npm:4.1.0"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["zlibjs", [\ + ["npm:0.3.1", {\ + "packageLocation": "./.yarn/cache/zlibjs-npm-0.3.1-abe8aa675c-f7cbf1d224.zip/node_modules/zlibjs/",\ + "packageDependencies": [\ + ["zlibjs", "npm:0.3.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["zod", [\ + ["npm:3.21.4", {\ + "packageLocation": "./.yarn/cache/zod-npm-3.21.4-9f570b215c-03c79fa461.zip/node_modules/zod/",\ + "packageDependencies": [\ + ["zod", "npm:3.21.4"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]]\ + ]\ +}'; + +function $$SETUP_STATE(hydrateRuntimeState, basePath) { + return hydrateRuntimeState(JSON.parse(RAW_RUNTIME_STATE), {basePath: basePath || __dirname}); +} + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const os = require('os'); +const events = require('events'); +const nodeUtils = require('util'); +const stream = require('stream'); +const zlib = require('zlib'); +const require$$0 = require('module'); +const StringDecoder = require('string_decoder'); +const url = require('url'); +const buffer = require('buffer'); +const readline = require('readline'); +const assert = require('assert'); + +const _interopDefaultLegacy = e => e && typeof e === 'object' && 'default' in e ? e : { default: e }; + +function _interopNamespace(e) { + if (e && e.__esModule) return e; + const n = Object.create(null); + if (e) { + for (const k in e) { + if (k !== 'default') { + const d = Object.getOwnPropertyDescriptor(e, k); + Object.defineProperty(n, k, d.get ? d : { + enumerable: true, + get: () => e[k] + }); + } + } + } + n.default = e; + return Object.freeze(n); +} + +const fs__default = /*#__PURE__*/_interopDefaultLegacy(fs); +const path__default = /*#__PURE__*/_interopDefaultLegacy(path); +const nodeUtils__namespace = /*#__PURE__*/_interopNamespace(nodeUtils); +const zlib__default = /*#__PURE__*/_interopDefaultLegacy(zlib); +const require$$0__default = /*#__PURE__*/_interopDefaultLegacy(require$$0); +const StringDecoder__default = /*#__PURE__*/_interopDefaultLegacy(StringDecoder); +const buffer__default = /*#__PURE__*/_interopDefaultLegacy(buffer); +const assert__default = /*#__PURE__*/_interopDefaultLegacy(assert); + +const S_IFMT = 61440; +const S_IFDIR = 16384; +const S_IFREG = 32768; +const S_IFLNK = 40960; +const SAFE_TIME = 456789e3; + +function makeError$1(code, message) { + return Object.assign(new Error(`${code}: ${message}`), { code }); +} +function EBUSY(message) { + return makeError$1(`EBUSY`, message); +} +function ENOSYS(message, reason) { + return makeError$1(`ENOSYS`, `${message}, ${reason}`); +} +function EINVAL(reason) { + return makeError$1(`EINVAL`, `invalid argument, ${reason}`); +} +function EBADF(reason) { + return makeError$1(`EBADF`, `bad file descriptor, ${reason}`); +} +function ENOENT(reason) { + return makeError$1(`ENOENT`, `no such file or directory, ${reason}`); +} +function ENOTDIR(reason) { + return makeError$1(`ENOTDIR`, `not a directory, ${reason}`); +} +function EISDIR(reason) { + return makeError$1(`EISDIR`, `illegal operation on a directory, ${reason}`); +} +function EEXIST(reason) { + return makeError$1(`EEXIST`, `file already exists, ${reason}`); +} +function EROFS(reason) { + return makeError$1(`EROFS`, `read-only filesystem, ${reason}`); +} +function ENOTEMPTY(reason) { + return makeError$1(`ENOTEMPTY`, `directory not empty, ${reason}`); +} +function EOPNOTSUPP(reason) { + return makeError$1(`EOPNOTSUPP`, `operation not supported, ${reason}`); +} +function ERR_DIR_CLOSED() { + return makeError$1(`ERR_DIR_CLOSED`, `Directory handle was closed`); +} + +const DEFAULT_MODE = S_IFREG | 420; +class StatEntry { + uid = 0; + gid = 0; + size = 0; + blksize = 0; + atimeMs = 0; + mtimeMs = 0; + ctimeMs = 0; + birthtimeMs = 0; + atime = /* @__PURE__ */ new Date(0); + mtime = /* @__PURE__ */ new Date(0); + ctime = /* @__PURE__ */ new Date(0); + birthtime = /* @__PURE__ */ new Date(0); + dev = 0; + ino = 0; + mode = DEFAULT_MODE; + nlink = 1; + rdev = 0; + blocks = 1; + isBlockDevice() { + return false; + } + isCharacterDevice() { + return false; + } + isDirectory() { + return (this.mode & S_IFMT) === S_IFDIR; + } + isFIFO() { + return false; + } + isFile() { + return (this.mode & S_IFMT) === S_IFREG; + } + isSocket() { + return false; + } + isSymbolicLink() { + return (this.mode & S_IFMT) === S_IFLNK; + } +} +class BigIntStatsEntry { + uid = BigInt(0); + gid = BigInt(0); + size = BigInt(0); + blksize = BigInt(0); + atimeMs = BigInt(0); + mtimeMs = BigInt(0); + ctimeMs = BigInt(0); + birthtimeMs = BigInt(0); + atimeNs = BigInt(0); + mtimeNs = BigInt(0); + ctimeNs = BigInt(0); + birthtimeNs = BigInt(0); + atime = /* @__PURE__ */ new Date(0); + mtime = /* @__PURE__ */ new Date(0); + ctime = /* @__PURE__ */ new Date(0); + birthtime = /* @__PURE__ */ new Date(0); + dev = BigInt(0); + ino = BigInt(0); + mode = BigInt(DEFAULT_MODE); + nlink = BigInt(1); + rdev = BigInt(0); + blocks = BigInt(1); + isBlockDevice() { + return false; + } + isCharacterDevice() { + return false; + } + isDirectory() { + return (this.mode & BigInt(S_IFMT)) === BigInt(S_IFDIR); + } + isFIFO() { + return false; + } + isFile() { + return (this.mode & BigInt(S_IFMT)) === BigInt(S_IFREG); + } + isSocket() { + return false; + } + isSymbolicLink() { + return (this.mode & BigInt(S_IFMT)) === BigInt(S_IFLNK); + } +} +function makeDefaultStats() { + return new StatEntry(); +} +function clearStats(stats) { + for (const key in stats) { + if (Object.hasOwn(stats, key)) { + const element = stats[key]; + if (typeof element === `number`) { + stats[key] = 0; + } else if (typeof element === `bigint`) { + stats[key] = BigInt(0); + } else if (nodeUtils__namespace.types.isDate(element)) { + stats[key] = /* @__PURE__ */ new Date(0); + } + } + } + return stats; +} +function convertToBigIntStats(stats) { + const bigintStats = new BigIntStatsEntry(); + for (const key in stats) { + if (Object.hasOwn(stats, key)) { + const element = stats[key]; + if (typeof element === `number`) { + bigintStats[key] = BigInt(Math.floor(element)); + } else if (nodeUtils__namespace.types.isDate(element)) { + bigintStats[key] = new Date(element); + } + } + } + bigintStats.atimeNs = bigintStats.atimeMs * BigInt(1e6) + BigInt(Math.floor(stats.atimeMs % 1 * 1e3)) * BigInt(1e3); + bigintStats.mtimeNs = bigintStats.mtimeMs * BigInt(1e6) + BigInt(Math.floor(stats.mtimeMs % 1 * 1e3)) * BigInt(1e3); + bigintStats.ctimeNs = bigintStats.ctimeMs * BigInt(1e6) + BigInt(Math.floor(stats.ctimeMs % 1 * 1e3)) * BigInt(1e3); + bigintStats.birthtimeNs = bigintStats.birthtimeMs * BigInt(1e6) + BigInt(Math.floor(stats.birthtimeMs % 1 * 1e3)) * BigInt(1e3); + return bigintStats; +} +function areStatsEqual(a, b) { + if (a.atimeMs !== b.atimeMs) + return false; + if (a.birthtimeMs !== b.birthtimeMs) + return false; + if (a.blksize !== b.blksize) + return false; + if (a.blocks !== b.blocks) + return false; + if (a.ctimeMs !== b.ctimeMs) + return false; + if (a.dev !== b.dev) + return false; + if (a.gid !== b.gid) + return false; + if (a.ino !== b.ino) + return false; + if (a.isBlockDevice() !== b.isBlockDevice()) + return false; + if (a.isCharacterDevice() !== b.isCharacterDevice()) + return false; + if (a.isDirectory() !== b.isDirectory()) + return false; + if (a.isFIFO() !== b.isFIFO()) + return false; + if (a.isFile() !== b.isFile()) + return false; + if (a.isSocket() !== b.isSocket()) + return false; + if (a.isSymbolicLink() !== b.isSymbolicLink()) + return false; + if (a.mode !== b.mode) + return false; + if (a.mtimeMs !== b.mtimeMs) + return false; + if (a.nlink !== b.nlink) + return false; + if (a.rdev !== b.rdev) + return false; + if (a.size !== b.size) + return false; + if (a.uid !== b.uid) + return false; + const aN = a; + const bN = b; + if (aN.atimeNs !== bN.atimeNs) + return false; + if (aN.mtimeNs !== bN.mtimeNs) + return false; + if (aN.ctimeNs !== bN.ctimeNs) + return false; + if (aN.birthtimeNs !== bN.birthtimeNs) + return false; + return true; +} + +const PortablePath = { + root: `/`, + dot: `.`, + parent: `..` +}; +const Filename = { + home: `~`, + nodeModules: `node_modules`, + manifest: `package.json`, + lockfile: `yarn.lock`, + virtual: `__virtual__`, + /** + * @deprecated + */ + pnpJs: `.pnp.js`, + pnpCjs: `.pnp.cjs`, + pnpData: `.pnp.data.json`, + pnpEsmLoader: `.pnp.loader.mjs`, + rc: `.yarnrc.yml`, + env: `.env` +}; +const npath = Object.create(path__default.default); +const ppath = Object.create(path__default.default.posix); +npath.cwd = () => process.cwd(); +ppath.cwd = process.platform === `win32` ? () => toPortablePath(process.cwd()) : process.cwd; +if (process.platform === `win32`) { + ppath.resolve = (...segments) => { + if (segments.length > 0 && ppath.isAbsolute(segments[0])) { + return path__default.default.posix.resolve(...segments); + } else { + return path__default.default.posix.resolve(ppath.cwd(), ...segments); + } + }; +} +const contains = function(pathUtils, from, to) { + from = pathUtils.normalize(from); + to = pathUtils.normalize(to); + if (from === to) + return `.`; + if (!from.endsWith(pathUtils.sep)) + from = from + pathUtils.sep; + if (to.startsWith(from)) { + return to.slice(from.length); + } else { + return null; + } +}; +npath.contains = (from, to) => contains(npath, from, to); +ppath.contains = (from, to) => contains(ppath, from, to); +const WINDOWS_PATH_REGEXP = /^([a-zA-Z]:.*)$/; +const UNC_WINDOWS_PATH_REGEXP = /^\/\/(\.\/)?(.*)$/; +const PORTABLE_PATH_REGEXP = /^\/([a-zA-Z]:.*)$/; +const UNC_PORTABLE_PATH_REGEXP = /^\/unc\/(\.dot\/)?(.*)$/; +function fromPortablePathWin32(p) { + let portablePathMatch, uncPortablePathMatch; + if (portablePathMatch = p.match(PORTABLE_PATH_REGEXP)) + p = portablePathMatch[1]; + else if (uncPortablePathMatch = p.match(UNC_PORTABLE_PATH_REGEXP)) + p = `\\\\${uncPortablePathMatch[1] ? `.\\` : ``}${uncPortablePathMatch[2]}`; + else + return p; + return p.replace(/\//g, `\\`); +} +function toPortablePathWin32(p) { + p = p.replace(/\\/g, `/`); + let windowsPathMatch, uncWindowsPathMatch; + if (windowsPathMatch = p.match(WINDOWS_PATH_REGEXP)) + p = `/${windowsPathMatch[1]}`; + else if (uncWindowsPathMatch = p.match(UNC_WINDOWS_PATH_REGEXP)) + p = `/unc/${uncWindowsPathMatch[1] ? `.dot/` : ``}${uncWindowsPathMatch[2]}`; + return p; +} +const toPortablePath = process.platform === `win32` ? toPortablePathWin32 : (p) => p; +const fromPortablePath = process.platform === `win32` ? fromPortablePathWin32 : (p) => p; +npath.fromPortablePath = fromPortablePath; +npath.toPortablePath = toPortablePath; +function convertPath(targetPathUtils, sourcePath) { + return targetPathUtils === npath ? fromPortablePath(sourcePath) : toPortablePath(sourcePath); +} + +const defaultTime = new Date(SAFE_TIME * 1e3); +const defaultTimeMs = defaultTime.getTime(); +async function copyPromise(destinationFs, destination, sourceFs, source, opts) { + const normalizedDestination = destinationFs.pathUtils.normalize(destination); + const normalizedSource = sourceFs.pathUtils.normalize(source); + const prelayout = []; + const postlayout = []; + const { atime, mtime } = opts.stableTime ? { atime: defaultTime, mtime: defaultTime } : await sourceFs.lstatPromise(normalizedSource); + await destinationFs.mkdirpPromise(destinationFs.pathUtils.dirname(destination), { utimes: [atime, mtime] }); + await copyImpl(prelayout, postlayout, destinationFs, normalizedDestination, sourceFs, normalizedSource, { ...opts, didParentExist: true }); + for (const operation of prelayout) + await operation(); + await Promise.all(postlayout.map((operation) => { + return operation(); + })); +} +async function copyImpl(prelayout, postlayout, destinationFs, destination, sourceFs, source, opts) { + const destinationStat = opts.didParentExist ? await maybeLStat(destinationFs, destination) : null; + const sourceStat = await sourceFs.lstatPromise(source); + const { atime, mtime } = opts.stableTime ? { atime: defaultTime, mtime: defaultTime } : sourceStat; + let updated; + switch (true) { + case sourceStat.isDirectory(): + { + updated = await copyFolder(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts); + } + break; + case sourceStat.isFile(): + { + updated = await copyFile(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts); + } + break; + case sourceStat.isSymbolicLink(): + { + updated = await copySymlink(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts); + } + break; + default: { + throw new Error(`Unsupported file type (${sourceStat.mode})`); + } + } + if (opts.linkStrategy?.type !== `HardlinkFromIndex` || !sourceStat.isFile()) { + if (updated || destinationStat?.mtime?.getTime() !== mtime.getTime() || destinationStat?.atime?.getTime() !== atime.getTime()) { + postlayout.push(() => destinationFs.lutimesPromise(destination, atime, mtime)); + updated = true; + } + if (destinationStat === null || (destinationStat.mode & 511) !== (sourceStat.mode & 511)) { + postlayout.push(() => destinationFs.chmodPromise(destination, sourceStat.mode & 511)); + updated = true; + } + } + return updated; +} +async function maybeLStat(baseFs, p) { + try { + return await baseFs.lstatPromise(p); + } catch { + return null; + } +} +async function copyFolder(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts) { + if (destinationStat !== null && !destinationStat.isDirectory()) { + if (opts.overwrite) { + prelayout.push(async () => destinationFs.removePromise(destination)); + destinationStat = null; + } else { + return false; + } + } + let updated = false; + if (destinationStat === null) { + prelayout.push(async () => { + try { + await destinationFs.mkdirPromise(destination, { mode: sourceStat.mode }); + } catch (err) { + if (err.code !== `EEXIST`) { + throw err; + } + } + }); + updated = true; + } + const entries = await sourceFs.readdirPromise(source); + const nextOpts = opts.didParentExist && !destinationStat ? { ...opts, didParentExist: false } : opts; + if (opts.stableSort) { + for (const entry of entries.sort()) { + if (await copyImpl(prelayout, postlayout, destinationFs, destinationFs.pathUtils.join(destination, entry), sourceFs, sourceFs.pathUtils.join(source, entry), nextOpts)) { + updated = true; + } + } + } else { + const entriesUpdateStatus = await Promise.all(entries.map(async (entry) => { + await copyImpl(prelayout, postlayout, destinationFs, destinationFs.pathUtils.join(destination, entry), sourceFs, sourceFs.pathUtils.join(source, entry), nextOpts); + })); + if (entriesUpdateStatus.some((status) => status)) { + updated = true; + } + } + return updated; +} +async function copyFileViaIndex(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts, linkStrategy) { + const sourceHash = await sourceFs.checksumFilePromise(source, { algorithm: `sha1` }); + const defaultMode = 420; + const sourceMode = sourceStat.mode & 511; + const indexFileName = `${sourceHash}${sourceMode !== defaultMode ? sourceMode.toString(8) : ``}`; + const indexPath = destinationFs.pathUtils.join(linkStrategy.indexPath, sourceHash.slice(0, 2), `${indexFileName}.dat`); + let AtomicBehavior; + ((AtomicBehavior2) => { + AtomicBehavior2[AtomicBehavior2["Lock"] = 0] = "Lock"; + AtomicBehavior2[AtomicBehavior2["Rename"] = 1] = "Rename"; + })(AtomicBehavior || (AtomicBehavior = {})); + let atomicBehavior = 1 /* Rename */; + let indexStat = await maybeLStat(destinationFs, indexPath); + if (destinationStat) { + const isDestinationHardlinkedFromIndex = indexStat && destinationStat.dev === indexStat.dev && destinationStat.ino === indexStat.ino; + const isIndexModified = indexStat?.mtimeMs !== defaultTimeMs; + if (isDestinationHardlinkedFromIndex) { + if (isIndexModified && linkStrategy.autoRepair) { + atomicBehavior = 0 /* Lock */; + indexStat = null; + } + } + if (!isDestinationHardlinkedFromIndex) { + if (opts.overwrite) { + prelayout.push(async () => destinationFs.removePromise(destination)); + destinationStat = null; + } else { + return false; + } + } + } + const tempPath = !indexStat && atomicBehavior === 1 /* Rename */ ? `${indexPath}.${Math.floor(Math.random() * 4294967296).toString(16).padStart(8, `0`)}` : null; + let tempPathCleaned = false; + prelayout.push(async () => { + if (!indexStat) { + if (atomicBehavior === 0 /* Lock */) { + await destinationFs.lockPromise(indexPath, async () => { + const content = await sourceFs.readFilePromise(source); + await destinationFs.writeFilePromise(indexPath, content); + }); + } + if (atomicBehavior === 1 /* Rename */ && tempPath) { + const content = await sourceFs.readFilePromise(source); + await destinationFs.writeFilePromise(tempPath, content); + try { + await destinationFs.linkPromise(tempPath, indexPath); + } catch (err) { + if (err.code === `EEXIST`) { + tempPathCleaned = true; + await destinationFs.unlinkPromise(tempPath); + } else { + throw err; + } + } + } + } + if (!destinationStat) { + await destinationFs.linkPromise(indexPath, destination); + } + }); + postlayout.push(async () => { + if (!indexStat) { + await destinationFs.lutimesPromise(indexPath, defaultTime, defaultTime); + if (sourceMode !== defaultMode) { + await destinationFs.chmodPromise(indexPath, sourceMode); + } + } + if (tempPath && !tempPathCleaned) { + await destinationFs.unlinkPromise(tempPath); + } + }); + return false; +} +async function copyFileDirect(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts) { + if (destinationStat !== null) { + if (opts.overwrite) { + prelayout.push(async () => destinationFs.removePromise(destination)); + destinationStat = null; + } else { + return false; + } + } + prelayout.push(async () => { + const content = await sourceFs.readFilePromise(source); + await destinationFs.writeFilePromise(destination, content); + }); + return true; +} +async function copyFile(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts) { + if (opts.linkStrategy?.type === `HardlinkFromIndex`) { + return copyFileViaIndex(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts, opts.linkStrategy); + } else { + return copyFileDirect(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts); + } +} +async function copySymlink(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts) { + if (destinationStat !== null) { + if (opts.overwrite) { + prelayout.push(async () => destinationFs.removePromise(destination)); + destinationStat = null; + } else { + return false; + } + } + prelayout.push(async () => { + await destinationFs.symlinkPromise(convertPath(destinationFs.pathUtils, await sourceFs.readlinkPromise(source)), destination); + }); + return true; +} + +class CustomDir { + constructor(path, nextDirent, opts = {}) { + this.path = path; + this.nextDirent = nextDirent; + this.opts = opts; + } + closed = false; + throwIfClosed() { + if (this.closed) { + throw ERR_DIR_CLOSED(); + } + } + async *[Symbol.asyncIterator]() { + try { + let dirent; + while ((dirent = await this.read()) !== null) { + yield dirent; + } + } finally { + await this.close(); + } + } + read(cb) { + const dirent = this.readSync(); + if (typeof cb !== `undefined`) + return cb(null, dirent); + return Promise.resolve(dirent); + } + readSync() { + this.throwIfClosed(); + return this.nextDirent(); + } + close(cb) { + this.closeSync(); + if (typeof cb !== `undefined`) + return cb(null); + return Promise.resolve(); + } + closeSync() { + this.throwIfClosed(); + this.opts.onClose?.(); + this.closed = true; + } +} +function opendir(fakeFs, path, entries, opts) { + const nextDirent = () => { + const filename = entries.shift(); + if (typeof filename === `undefined`) + return null; + const entryPath = fakeFs.pathUtils.join(path, filename); + return Object.assign(fakeFs.statSync(entryPath), { + name: filename, + path: void 0 + }); + }; + return new CustomDir(path, nextDirent, opts); +} + +function assertStatus(current, expected) { + if (current !== expected) { + throw new Error(`Invalid StatWatcher status: expected '${expected}', got '${current}'`); + } +} +class CustomStatWatcher extends events.EventEmitter { + fakeFs; + path; + bigint; + status = "ready" /* Ready */; + changeListeners = /* @__PURE__ */ new Map(); + lastStats; + startTimeout = null; + static create(fakeFs, path, opts) { + const statWatcher = new CustomStatWatcher(fakeFs, path, opts); + statWatcher.start(); + return statWatcher; + } + constructor(fakeFs, path, { bigint = false } = {}) { + super(); + this.fakeFs = fakeFs; + this.path = path; + this.bigint = bigint; + this.lastStats = this.stat(); + } + start() { + assertStatus(this.status, "ready" /* Ready */); + this.status = "running" /* Running */; + this.startTimeout = setTimeout(() => { + this.startTimeout = null; + if (!this.fakeFs.existsSync(this.path)) { + this.emit("change" /* Change */, this.lastStats, this.lastStats); + } + }, 3); + } + stop() { + assertStatus(this.status, "running" /* Running */); + this.status = "stopped" /* Stopped */; + if (this.startTimeout !== null) { + clearTimeout(this.startTimeout); + this.startTimeout = null; + } + this.emit("stop" /* Stop */); + } + stat() { + try { + return this.fakeFs.statSync(this.path, { bigint: this.bigint }); + } catch { + const statInstance = this.bigint ? new BigIntStatsEntry() : new StatEntry(); + return clearStats(statInstance); + } + } + /** + * Creates an interval whose callback compares the current stats with the previous stats and notifies all listeners in case of changes. + * + * @param opts.persistent Decides whether the interval should be immediately unref-ed. + */ + makeInterval(opts) { + const interval = setInterval(() => { + const currentStats = this.stat(); + const previousStats = this.lastStats; + if (areStatsEqual(currentStats, previousStats)) + return; + this.lastStats = currentStats; + this.emit("change" /* Change */, currentStats, previousStats); + }, opts.interval); + return opts.persistent ? interval : interval.unref(); + } + /** + * Registers a listener and assigns it an interval. + */ + registerChangeListener(listener, opts) { + this.addListener("change" /* Change */, listener); + this.changeListeners.set(listener, this.makeInterval(opts)); + } + /** + * Unregisters the listener and clears the assigned interval. + */ + unregisterChangeListener(listener) { + this.removeListener("change" /* Change */, listener); + const interval = this.changeListeners.get(listener); + if (typeof interval !== `undefined`) + clearInterval(interval); + this.changeListeners.delete(listener); + } + /** + * Unregisters all listeners and clears all assigned intervals. + */ + unregisterAllChangeListeners() { + for (const listener of this.changeListeners.keys()) { + this.unregisterChangeListener(listener); + } + } + hasChangeListeners() { + return this.changeListeners.size > 0; + } + /** + * Refs all stored intervals. + */ + ref() { + for (const interval of this.changeListeners.values()) + interval.ref(); + return this; + } + /** + * Unrefs all stored intervals. + */ + unref() { + for (const interval of this.changeListeners.values()) + interval.unref(); + return this; + } +} + +const statWatchersByFakeFS = /* @__PURE__ */ new WeakMap(); +function watchFile(fakeFs, path, a, b) { + let bigint; + let persistent; + let interval; + let listener; + switch (typeof a) { + case `function`: + { + bigint = false; + persistent = true; + interval = 5007; + listener = a; + } + break; + default: + { + ({ + bigint = false, + persistent = true, + interval = 5007 + } = a); + listener = b; + } + break; + } + let statWatchers = statWatchersByFakeFS.get(fakeFs); + if (typeof statWatchers === `undefined`) + statWatchersByFakeFS.set(fakeFs, statWatchers = /* @__PURE__ */ new Map()); + let statWatcher = statWatchers.get(path); + if (typeof statWatcher === `undefined`) { + statWatcher = CustomStatWatcher.create(fakeFs, path, { bigint }); + statWatchers.set(path, statWatcher); + } + statWatcher.registerChangeListener(listener, { persistent, interval }); + return statWatcher; +} +function unwatchFile(fakeFs, path, cb) { + const statWatchers = statWatchersByFakeFS.get(fakeFs); + if (typeof statWatchers === `undefined`) + return; + const statWatcher = statWatchers.get(path); + if (typeof statWatcher === `undefined`) + return; + if (typeof cb === `undefined`) + statWatcher.unregisterAllChangeListeners(); + else + statWatcher.unregisterChangeListener(cb); + if (!statWatcher.hasChangeListeners()) { + statWatcher.stop(); + statWatchers.delete(path); + } +} +function unwatchAllFiles(fakeFs) { + const statWatchers = statWatchersByFakeFS.get(fakeFs); + if (typeof statWatchers === `undefined`) + return; + for (const path of statWatchers.keys()) { + unwatchFile(fakeFs, path); + } +} + +class FakeFS { + pathUtils; + constructor(pathUtils) { + this.pathUtils = pathUtils; + } + async *genTraversePromise(init, { stableSort = false } = {}) { + const stack = [init]; + while (stack.length > 0) { + const p = stack.shift(); + const entry = await this.lstatPromise(p); + if (entry.isDirectory()) { + const entries = await this.readdirPromise(p); + if (stableSort) { + for (const entry2 of entries.sort()) { + stack.push(this.pathUtils.join(p, entry2)); + } + } else { + throw new Error(`Not supported`); + } + } else { + yield p; + } + } + } + async checksumFilePromise(path, { algorithm = `sha512` } = {}) { + const fd = await this.openPromise(path, `r`); + try { + const CHUNK_SIZE = 65536; + const chunk = Buffer.allocUnsafeSlow(CHUNK_SIZE); + const hash = crypto.createHash(algorithm); + let bytesRead = 0; + while ((bytesRead = await this.readPromise(fd, chunk, 0, CHUNK_SIZE)) !== 0) + hash.update(bytesRead === CHUNK_SIZE ? chunk : chunk.slice(0, bytesRead)); + return hash.digest(`hex`); + } finally { + await this.closePromise(fd); + } + } + async removePromise(p, { recursive = true, maxRetries = 5 } = {}) { + let stat; + try { + stat = await this.lstatPromise(p); + } catch (error) { + if (error.code === `ENOENT`) { + return; + } else { + throw error; + } + } + if (stat.isDirectory()) { + if (recursive) { + const entries = await this.readdirPromise(p); + await Promise.all(entries.map((entry) => { + return this.removePromise(this.pathUtils.resolve(p, entry)); + })); + } + for (let t = 0; t <= maxRetries; t++) { + try { + await this.rmdirPromise(p); + break; + } catch (error) { + if (error.code !== `EBUSY` && error.code !== `ENOTEMPTY`) { + throw error; + } else if (t < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, t * 100)); + } + } + } + } else { + await this.unlinkPromise(p); + } + } + removeSync(p, { recursive = true } = {}) { + let stat; + try { + stat = this.lstatSync(p); + } catch (error) { + if (error.code === `ENOENT`) { + return; + } else { + throw error; + } + } + if (stat.isDirectory()) { + if (recursive) + for (const entry of this.readdirSync(p)) + this.removeSync(this.pathUtils.resolve(p, entry)); + this.rmdirSync(p); + } else { + this.unlinkSync(p); + } + } + async mkdirpPromise(p, { chmod, utimes } = {}) { + p = this.resolve(p); + if (p === this.pathUtils.dirname(p)) + return void 0; + const parts = p.split(this.pathUtils.sep); + let createdDirectory; + for (let u = 2; u <= parts.length; ++u) { + const subPath = parts.slice(0, u).join(this.pathUtils.sep); + if (!this.existsSync(subPath)) { + try { + await this.mkdirPromise(subPath); + } catch (error) { + if (error.code === `EEXIST`) { + continue; + } else { + throw error; + } + } + createdDirectory ??= subPath; + if (chmod != null) + await this.chmodPromise(subPath, chmod); + if (utimes != null) { + await this.utimesPromise(subPath, utimes[0], utimes[1]); + } else { + const parentStat = await this.statPromise(this.pathUtils.dirname(subPath)); + await this.utimesPromise(subPath, parentStat.atime, parentStat.mtime); + } + } + } + return createdDirectory; + } + mkdirpSync(p, { chmod, utimes } = {}) { + p = this.resolve(p); + if (p === this.pathUtils.dirname(p)) + return void 0; + const parts = p.split(this.pathUtils.sep); + let createdDirectory; + for (let u = 2; u <= parts.length; ++u) { + const subPath = parts.slice(0, u).join(this.pathUtils.sep); + if (!this.existsSync(subPath)) { + try { + this.mkdirSync(subPath); + } catch (error) { + if (error.code === `EEXIST`) { + continue; + } else { + throw error; + } + } + createdDirectory ??= subPath; + if (chmod != null) + this.chmodSync(subPath, chmod); + if (utimes != null) { + this.utimesSync(subPath, utimes[0], utimes[1]); + } else { + const parentStat = this.statSync(this.pathUtils.dirname(subPath)); + this.utimesSync(subPath, parentStat.atime, parentStat.mtime); + } + } + } + return createdDirectory; + } + async copyPromise(destination, source, { baseFs = this, overwrite = true, stableSort = false, stableTime = false, linkStrategy = null } = {}) { + return await copyPromise(this, destination, baseFs, source, { overwrite, stableSort, stableTime, linkStrategy }); + } + copySync(destination, source, { baseFs = this, overwrite = true } = {}) { + const stat = baseFs.lstatSync(source); + const exists = this.existsSync(destination); + if (stat.isDirectory()) { + this.mkdirpSync(destination); + const directoryListing = baseFs.readdirSync(source); + for (const entry of directoryListing) { + this.copySync(this.pathUtils.join(destination, entry), baseFs.pathUtils.join(source, entry), { baseFs, overwrite }); + } + } else if (stat.isFile()) { + if (!exists || overwrite) { + if (exists) + this.removeSync(destination); + const content = baseFs.readFileSync(source); + this.writeFileSync(destination, content); + } + } else if (stat.isSymbolicLink()) { + if (!exists || overwrite) { + if (exists) + this.removeSync(destination); + const target = baseFs.readlinkSync(source); + this.symlinkSync(convertPath(this.pathUtils, target), destination); + } + } else { + throw new Error(`Unsupported file type (file: ${source}, mode: 0o${stat.mode.toString(8).padStart(6, `0`)})`); + } + const mode = stat.mode & 511; + this.chmodSync(destination, mode); + } + async changeFilePromise(p, content, opts = {}) { + if (Buffer.isBuffer(content)) { + return this.changeFileBufferPromise(p, content, opts); + } else { + return this.changeFileTextPromise(p, content, opts); + } + } + async changeFileBufferPromise(p, content, { mode } = {}) { + let current = Buffer.alloc(0); + try { + current = await this.readFilePromise(p); + } catch { + } + if (Buffer.compare(current, content) === 0) + return; + await this.writeFilePromise(p, content, { mode }); + } + async changeFileTextPromise(p, content, { automaticNewlines, mode } = {}) { + let current = ``; + try { + current = await this.readFilePromise(p, `utf8`); + } catch { + } + const normalizedContent = automaticNewlines ? normalizeLineEndings(current, content) : content; + if (current === normalizedContent) + return; + await this.writeFilePromise(p, normalizedContent, { mode }); + } + changeFileSync(p, content, opts = {}) { + if (Buffer.isBuffer(content)) { + return this.changeFileBufferSync(p, content, opts); + } else { + return this.changeFileTextSync(p, content, opts); + } + } + changeFileBufferSync(p, content, { mode } = {}) { + let current = Buffer.alloc(0); + try { + current = this.readFileSync(p); + } catch { + } + if (Buffer.compare(current, content) === 0) + return; + this.writeFileSync(p, content, { mode }); + } + changeFileTextSync(p, content, { automaticNewlines = false, mode } = {}) { + let current = ``; + try { + current = this.readFileSync(p, `utf8`); + } catch { + } + const normalizedContent = automaticNewlines ? normalizeLineEndings(current, content) : content; + if (current === normalizedContent) + return; + this.writeFileSync(p, normalizedContent, { mode }); + } + async movePromise(fromP, toP) { + try { + await this.renamePromise(fromP, toP); + } catch (error) { + if (error.code === `EXDEV`) { + await this.copyPromise(toP, fromP); + await this.removePromise(fromP); + } else { + throw error; + } + } + } + moveSync(fromP, toP) { + try { + this.renameSync(fromP, toP); + } catch (error) { + if (error.code === `EXDEV`) { + this.copySync(toP, fromP); + this.removeSync(fromP); + } else { + throw error; + } + } + } + async lockPromise(affectedPath, callback) { + const lockPath = `${affectedPath}.flock`; + const interval = 1e3 / 60; + const startTime = Date.now(); + let fd = null; + const isAlive = async () => { + let pid; + try { + [pid] = await this.readJsonPromise(lockPath); + } catch { + return Date.now() - startTime < 500; + } + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + while (fd === null) { + try { + fd = await this.openPromise(lockPath, `wx`); + } catch (error) { + if (error.code === `EEXIST`) { + if (!await isAlive()) { + try { + await this.unlinkPromise(lockPath); + continue; + } catch { + } + } + if (Date.now() - startTime < 60 * 1e3) { + await new Promise((resolve) => setTimeout(resolve, interval)); + } else { + throw new Error(`Couldn't acquire a lock in a reasonable time (via ${lockPath})`); + } + } else { + throw error; + } + } + } + await this.writePromise(fd, JSON.stringify([process.pid])); + try { + return await callback(); + } finally { + try { + await this.closePromise(fd); + await this.unlinkPromise(lockPath); + } catch { + } + } + } + async readJsonPromise(p) { + const content = await this.readFilePromise(p, `utf8`); + try { + return JSON.parse(content); + } catch (error) { + error.message += ` (in ${p})`; + throw error; + } + } + readJsonSync(p) { + const content = this.readFileSync(p, `utf8`); + try { + return JSON.parse(content); + } catch (error) { + error.message += ` (in ${p})`; + throw error; + } + } + async writeJsonPromise(p, data, { compact = false } = {}) { + const space = compact ? 0 : 2; + return await this.writeFilePromise(p, `${JSON.stringify(data, null, space)} +`); + } + writeJsonSync(p, data, { compact = false } = {}) { + const space = compact ? 0 : 2; + return this.writeFileSync(p, `${JSON.stringify(data, null, space)} +`); + } + async preserveTimePromise(p, cb) { + const stat = await this.lstatPromise(p); + const result = await cb(); + if (typeof result !== `undefined`) + p = result; + await this.lutimesPromise(p, stat.atime, stat.mtime); + } + async preserveTimeSync(p, cb) { + const stat = this.lstatSync(p); + const result = cb(); + if (typeof result !== `undefined`) + p = result; + this.lutimesSync(p, stat.atime, stat.mtime); + } +} +class BasePortableFakeFS extends FakeFS { + constructor() { + super(ppath); + } +} +function getEndOfLine(content) { + const matches = content.match(/\r?\n/g); + if (matches === null) + return os.EOL; + const crlf = matches.filter((nl) => nl === `\r +`).length; + const lf = matches.length - crlf; + return crlf > lf ? `\r +` : ` +`; +} +function normalizeLineEndings(originalContent, newContent) { + return newContent.replace(/\r?\n/g, getEndOfLine(originalContent)); +} + +class ProxiedFS extends FakeFS { + getExtractHint(hints) { + return this.baseFs.getExtractHint(hints); + } + resolve(path) { + return this.mapFromBase(this.baseFs.resolve(this.mapToBase(path))); + } + getRealPath() { + return this.mapFromBase(this.baseFs.getRealPath()); + } + async openPromise(p, flags, mode) { + return this.baseFs.openPromise(this.mapToBase(p), flags, mode); + } + openSync(p, flags, mode) { + return this.baseFs.openSync(this.mapToBase(p), flags, mode); + } + async opendirPromise(p, opts) { + return Object.assign(await this.baseFs.opendirPromise(this.mapToBase(p), opts), { path: p }); + } + opendirSync(p, opts) { + return Object.assign(this.baseFs.opendirSync(this.mapToBase(p), opts), { path: p }); + } + async readPromise(fd, buffer, offset, length, position) { + return await this.baseFs.readPromise(fd, buffer, offset, length, position); + } + readSync(fd, buffer, offset, length, position) { + return this.baseFs.readSync(fd, buffer, offset, length, position); + } + async writePromise(fd, buffer, offset, length, position) { + if (typeof buffer === `string`) { + return await this.baseFs.writePromise(fd, buffer, offset); + } else { + return await this.baseFs.writePromise(fd, buffer, offset, length, position); + } + } + writeSync(fd, buffer, offset, length, position) { + if (typeof buffer === `string`) { + return this.baseFs.writeSync(fd, buffer, offset); + } else { + return this.baseFs.writeSync(fd, buffer, offset, length, position); + } + } + async closePromise(fd) { + return this.baseFs.closePromise(fd); + } + closeSync(fd) { + this.baseFs.closeSync(fd); + } + createReadStream(p, opts) { + return this.baseFs.createReadStream(p !== null ? this.mapToBase(p) : p, opts); + } + createWriteStream(p, opts) { + return this.baseFs.createWriteStream(p !== null ? this.mapToBase(p) : p, opts); + } + async realpathPromise(p) { + return this.mapFromBase(await this.baseFs.realpathPromise(this.mapToBase(p))); + } + realpathSync(p) { + return this.mapFromBase(this.baseFs.realpathSync(this.mapToBase(p))); + } + async existsPromise(p) { + return this.baseFs.existsPromise(this.mapToBase(p)); + } + existsSync(p) { + return this.baseFs.existsSync(this.mapToBase(p)); + } + accessSync(p, mode) { + return this.baseFs.accessSync(this.mapToBase(p), mode); + } + async accessPromise(p, mode) { + return this.baseFs.accessPromise(this.mapToBase(p), mode); + } + async statPromise(p, opts) { + return this.baseFs.statPromise(this.mapToBase(p), opts); + } + statSync(p, opts) { + return this.baseFs.statSync(this.mapToBase(p), opts); + } + async fstatPromise(fd, opts) { + return this.baseFs.fstatPromise(fd, opts); + } + fstatSync(fd, opts) { + return this.baseFs.fstatSync(fd, opts); + } + lstatPromise(p, opts) { + return this.baseFs.lstatPromise(this.mapToBase(p), opts); + } + lstatSync(p, opts) { + return this.baseFs.lstatSync(this.mapToBase(p), opts); + } + async fchmodPromise(fd, mask) { + return this.baseFs.fchmodPromise(fd, mask); + } + fchmodSync(fd, mask) { + return this.baseFs.fchmodSync(fd, mask); + } + async chmodPromise(p, mask) { + return this.baseFs.chmodPromise(this.mapToBase(p), mask); + } + chmodSync(p, mask) { + return this.baseFs.chmodSync(this.mapToBase(p), mask); + } + async fchownPromise(fd, uid, gid) { + return this.baseFs.fchownPromise(fd, uid, gid); + } + fchownSync(fd, uid, gid) { + return this.baseFs.fchownSync(fd, uid, gid); + } + async chownPromise(p, uid, gid) { + return this.baseFs.chownPromise(this.mapToBase(p), uid, gid); + } + chownSync(p, uid, gid) { + return this.baseFs.chownSync(this.mapToBase(p), uid, gid); + } + async renamePromise(oldP, newP) { + return this.baseFs.renamePromise(this.mapToBase(oldP), this.mapToBase(newP)); + } + renameSync(oldP, newP) { + return this.baseFs.renameSync(this.mapToBase(oldP), this.mapToBase(newP)); + } + async copyFilePromise(sourceP, destP, flags = 0) { + return this.baseFs.copyFilePromise(this.mapToBase(sourceP), this.mapToBase(destP), flags); + } + copyFileSync(sourceP, destP, flags = 0) { + return this.baseFs.copyFileSync(this.mapToBase(sourceP), this.mapToBase(destP), flags); + } + async appendFilePromise(p, content, opts) { + return this.baseFs.appendFilePromise(this.fsMapToBase(p), content, opts); + } + appendFileSync(p, content, opts) { + return this.baseFs.appendFileSync(this.fsMapToBase(p), content, opts); + } + async writeFilePromise(p, content, opts) { + return this.baseFs.writeFilePromise(this.fsMapToBase(p), content, opts); + } + writeFileSync(p, content, opts) { + return this.baseFs.writeFileSync(this.fsMapToBase(p), content, opts); + } + async unlinkPromise(p) { + return this.baseFs.unlinkPromise(this.mapToBase(p)); + } + unlinkSync(p) { + return this.baseFs.unlinkSync(this.mapToBase(p)); + } + async utimesPromise(p, atime, mtime) { + return this.baseFs.utimesPromise(this.mapToBase(p), atime, mtime); + } + utimesSync(p, atime, mtime) { + return this.baseFs.utimesSync(this.mapToBase(p), atime, mtime); + } + async lutimesPromise(p, atime, mtime) { + return this.baseFs.lutimesPromise(this.mapToBase(p), atime, mtime); + } + lutimesSync(p, atime, mtime) { + return this.baseFs.lutimesSync(this.mapToBase(p), atime, mtime); + } + async mkdirPromise(p, opts) { + return this.baseFs.mkdirPromise(this.mapToBase(p), opts); + } + mkdirSync(p, opts) { + return this.baseFs.mkdirSync(this.mapToBase(p), opts); + } + async rmdirPromise(p, opts) { + return this.baseFs.rmdirPromise(this.mapToBase(p), opts); + } + rmdirSync(p, opts) { + return this.baseFs.rmdirSync(this.mapToBase(p), opts); + } + async rmPromise(p, opts) { + return this.baseFs.rmPromise(this.mapToBase(p), opts); + } + rmSync(p, opts) { + return this.baseFs.rmSync(this.mapToBase(p), opts); + } + async linkPromise(existingP, newP) { + return this.baseFs.linkPromise(this.mapToBase(existingP), this.mapToBase(newP)); + } + linkSync(existingP, newP) { + return this.baseFs.linkSync(this.mapToBase(existingP), this.mapToBase(newP)); + } + async symlinkPromise(target, p, type) { + const mappedP = this.mapToBase(p); + if (this.pathUtils.isAbsolute(target)) + return this.baseFs.symlinkPromise(this.mapToBase(target), mappedP, type); + const mappedAbsoluteTarget = this.mapToBase(this.pathUtils.join(this.pathUtils.dirname(p), target)); + const mappedTarget = this.baseFs.pathUtils.relative(this.baseFs.pathUtils.dirname(mappedP), mappedAbsoluteTarget); + return this.baseFs.symlinkPromise(mappedTarget, mappedP, type); + } + symlinkSync(target, p, type) { + const mappedP = this.mapToBase(p); + if (this.pathUtils.isAbsolute(target)) + return this.baseFs.symlinkSync(this.mapToBase(target), mappedP, type); + const mappedAbsoluteTarget = this.mapToBase(this.pathUtils.join(this.pathUtils.dirname(p), target)); + const mappedTarget = this.baseFs.pathUtils.relative(this.baseFs.pathUtils.dirname(mappedP), mappedAbsoluteTarget); + return this.baseFs.symlinkSync(mappedTarget, mappedP, type); + } + async readFilePromise(p, encoding) { + return this.baseFs.readFilePromise(this.fsMapToBase(p), encoding); + } + readFileSync(p, encoding) { + return this.baseFs.readFileSync(this.fsMapToBase(p), encoding); + } + readdirPromise(p, opts) { + return this.baseFs.readdirPromise(this.mapToBase(p), opts); + } + readdirSync(p, opts) { + return this.baseFs.readdirSync(this.mapToBase(p), opts); + } + async readlinkPromise(p) { + return this.mapFromBase(await this.baseFs.readlinkPromise(this.mapToBase(p))); + } + readlinkSync(p) { + return this.mapFromBase(this.baseFs.readlinkSync(this.mapToBase(p))); + } + async truncatePromise(p, len) { + return this.baseFs.truncatePromise(this.mapToBase(p), len); + } + truncateSync(p, len) { + return this.baseFs.truncateSync(this.mapToBase(p), len); + } + async ftruncatePromise(fd, len) { + return this.baseFs.ftruncatePromise(fd, len); + } + ftruncateSync(fd, len) { + return this.baseFs.ftruncateSync(fd, len); + } + watch(p, a, b) { + return this.baseFs.watch( + this.mapToBase(p), + // @ts-expect-error - reason TBS + a, + b + ); + } + watchFile(p, a, b) { + return this.baseFs.watchFile( + this.mapToBase(p), + // @ts-expect-error - reason TBS + a, + b + ); + } + unwatchFile(p, cb) { + return this.baseFs.unwatchFile(this.mapToBase(p), cb); + } + fsMapToBase(p) { + if (typeof p === `number`) { + return p; + } else { + return this.mapToBase(p); + } + } +} + +function direntToPortable(dirent) { + const portableDirent = dirent; + if (typeof dirent.path === `string`) + portableDirent.path = npath.toPortablePath(dirent.path); + return portableDirent; +} +class NodeFS extends BasePortableFakeFS { + realFs; + constructor(realFs = fs__default.default) { + super(); + this.realFs = realFs; + } + getExtractHint() { + return false; + } + getRealPath() { + return PortablePath.root; + } + resolve(p) { + return ppath.resolve(p); + } + async openPromise(p, flags, mode) { + return await new Promise((resolve, reject) => { + this.realFs.open(npath.fromPortablePath(p), flags, mode, this.makeCallback(resolve, reject)); + }); + } + openSync(p, flags, mode) { + return this.realFs.openSync(npath.fromPortablePath(p), flags, mode); + } + async opendirPromise(p, opts) { + return await new Promise((resolve, reject) => { + if (typeof opts !== `undefined`) { + this.realFs.opendir(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); + } else { + this.realFs.opendir(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); + } + }).then((dir) => { + const dirWithFixedPath = dir; + Object.defineProperty(dirWithFixedPath, `path`, { + value: p, + configurable: true, + writable: true + }); + return dirWithFixedPath; + }); + } + opendirSync(p, opts) { + const dir = typeof opts !== `undefined` ? this.realFs.opendirSync(npath.fromPortablePath(p), opts) : this.realFs.opendirSync(npath.fromPortablePath(p)); + const dirWithFixedPath = dir; + Object.defineProperty(dirWithFixedPath, `path`, { + value: p, + configurable: true, + writable: true + }); + return dirWithFixedPath; + } + async readPromise(fd, buffer, offset = 0, length = 0, position = -1) { + return await new Promise((resolve, reject) => { + this.realFs.read(fd, buffer, offset, length, position, (error, bytesRead) => { + if (error) { + reject(error); + } else { + resolve(bytesRead); + } + }); + }); + } + readSync(fd, buffer, offset, length, position) { + return this.realFs.readSync(fd, buffer, offset, length, position); + } + async writePromise(fd, buffer, offset, length, position) { + return await new Promise((resolve, reject) => { + if (typeof buffer === `string`) { + return this.realFs.write(fd, buffer, offset, this.makeCallback(resolve, reject)); + } else { + return this.realFs.write(fd, buffer, offset, length, position, this.makeCallback(resolve, reject)); + } + }); + } + writeSync(fd, buffer, offset, length, position) { + if (typeof buffer === `string`) { + return this.realFs.writeSync(fd, buffer, offset); + } else { + return this.realFs.writeSync(fd, buffer, offset, length, position); + } + } + async closePromise(fd) { + await new Promise((resolve, reject) => { + this.realFs.close(fd, this.makeCallback(resolve, reject)); + }); + } + closeSync(fd) { + this.realFs.closeSync(fd); + } + createReadStream(p, opts) { + const realPath = p !== null ? npath.fromPortablePath(p) : p; + return this.realFs.createReadStream(realPath, opts); + } + createWriteStream(p, opts) { + const realPath = p !== null ? npath.fromPortablePath(p) : p; + return this.realFs.createWriteStream(realPath, opts); + } + async realpathPromise(p) { + return await new Promise((resolve, reject) => { + this.realFs.realpath(npath.fromPortablePath(p), {}, this.makeCallback(resolve, reject)); + }).then((path) => { + return npath.toPortablePath(path); + }); + } + realpathSync(p) { + return npath.toPortablePath(this.realFs.realpathSync(npath.fromPortablePath(p), {})); + } + async existsPromise(p) { + return await new Promise((resolve) => { + this.realFs.exists(npath.fromPortablePath(p), resolve); + }); + } + accessSync(p, mode) { + return this.realFs.accessSync(npath.fromPortablePath(p), mode); + } + async accessPromise(p, mode) { + return await new Promise((resolve, reject) => { + this.realFs.access(npath.fromPortablePath(p), mode, this.makeCallback(resolve, reject)); + }); + } + existsSync(p) { + return this.realFs.existsSync(npath.fromPortablePath(p)); + } + async statPromise(p, opts) { + return await new Promise((resolve, reject) => { + if (opts) { + this.realFs.stat(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); + } else { + this.realFs.stat(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); + } + }); + } + statSync(p, opts) { + if (opts) { + return this.realFs.statSync(npath.fromPortablePath(p), opts); + } else { + return this.realFs.statSync(npath.fromPortablePath(p)); + } + } + async fstatPromise(fd, opts) { + return await new Promise((resolve, reject) => { + if (opts) { + this.realFs.fstat(fd, opts, this.makeCallback(resolve, reject)); + } else { + this.realFs.fstat(fd, this.makeCallback(resolve, reject)); + } + }); + } + fstatSync(fd, opts) { + if (opts) { + return this.realFs.fstatSync(fd, opts); + } else { + return this.realFs.fstatSync(fd); + } + } + async lstatPromise(p, opts) { + return await new Promise((resolve, reject) => { + if (opts) { + this.realFs.lstat(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); + } else { + this.realFs.lstat(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); + } + }); + } + lstatSync(p, opts) { + if (opts) { + return this.realFs.lstatSync(npath.fromPortablePath(p), opts); + } else { + return this.realFs.lstatSync(npath.fromPortablePath(p)); + } + } + async fchmodPromise(fd, mask) { + return await new Promise((resolve, reject) => { + this.realFs.fchmod(fd, mask, this.makeCallback(resolve, reject)); + }); + } + fchmodSync(fd, mask) { + return this.realFs.fchmodSync(fd, mask); + } + async chmodPromise(p, mask) { + return await new Promise((resolve, reject) => { + this.realFs.chmod(npath.fromPortablePath(p), mask, this.makeCallback(resolve, reject)); + }); + } + chmodSync(p, mask) { + return this.realFs.chmodSync(npath.fromPortablePath(p), mask); + } + async fchownPromise(fd, uid, gid) { + return await new Promise((resolve, reject) => { + this.realFs.fchown(fd, uid, gid, this.makeCallback(resolve, reject)); + }); + } + fchownSync(fd, uid, gid) { + return this.realFs.fchownSync(fd, uid, gid); + } + async chownPromise(p, uid, gid) { + return await new Promise((resolve, reject) => { + this.realFs.chown(npath.fromPortablePath(p), uid, gid, this.makeCallback(resolve, reject)); + }); + } + chownSync(p, uid, gid) { + return this.realFs.chownSync(npath.fromPortablePath(p), uid, gid); + } + async renamePromise(oldP, newP) { + return await new Promise((resolve, reject) => { + this.realFs.rename(npath.fromPortablePath(oldP), npath.fromPortablePath(newP), this.makeCallback(resolve, reject)); + }); + } + renameSync(oldP, newP) { + return this.realFs.renameSync(npath.fromPortablePath(oldP), npath.fromPortablePath(newP)); + } + async copyFilePromise(sourceP, destP, flags = 0) { + return await new Promise((resolve, reject) => { + this.realFs.copyFile(npath.fromPortablePath(sourceP), npath.fromPortablePath(destP), flags, this.makeCallback(resolve, reject)); + }); + } + copyFileSync(sourceP, destP, flags = 0) { + return this.realFs.copyFileSync(npath.fromPortablePath(sourceP), npath.fromPortablePath(destP), flags); + } + async appendFilePromise(p, content, opts) { + return await new Promise((resolve, reject) => { + const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; + if (opts) { + this.realFs.appendFile(fsNativePath, content, opts, this.makeCallback(resolve, reject)); + } else { + this.realFs.appendFile(fsNativePath, content, this.makeCallback(resolve, reject)); + } + }); + } + appendFileSync(p, content, opts) { + const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; + if (opts) { + this.realFs.appendFileSync(fsNativePath, content, opts); + } else { + this.realFs.appendFileSync(fsNativePath, content); + } + } + async writeFilePromise(p, content, opts) { + return await new Promise((resolve, reject) => { + const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; + if (opts) { + this.realFs.writeFile(fsNativePath, content, opts, this.makeCallback(resolve, reject)); + } else { + this.realFs.writeFile(fsNativePath, content, this.makeCallback(resolve, reject)); + } + }); + } + writeFileSync(p, content, opts) { + const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; + if (opts) { + this.realFs.writeFileSync(fsNativePath, content, opts); + } else { + this.realFs.writeFileSync(fsNativePath, content); + } + } + async unlinkPromise(p) { + return await new Promise((resolve, reject) => { + this.realFs.unlink(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); + }); + } + unlinkSync(p) { + return this.realFs.unlinkSync(npath.fromPortablePath(p)); + } + async utimesPromise(p, atime, mtime) { + return await new Promise((resolve, reject) => { + this.realFs.utimes(npath.fromPortablePath(p), atime, mtime, this.makeCallback(resolve, reject)); + }); + } + utimesSync(p, atime, mtime) { + this.realFs.utimesSync(npath.fromPortablePath(p), atime, mtime); + } + async lutimesPromise(p, atime, mtime) { + return await new Promise((resolve, reject) => { + this.realFs.lutimes(npath.fromPortablePath(p), atime, mtime, this.makeCallback(resolve, reject)); + }); + } + lutimesSync(p, atime, mtime) { + this.realFs.lutimesSync(npath.fromPortablePath(p), atime, mtime); + } + async mkdirPromise(p, opts) { + return await new Promise((resolve, reject) => { + this.realFs.mkdir(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); + }); + } + mkdirSync(p, opts) { + return this.realFs.mkdirSync(npath.fromPortablePath(p), opts); + } + async rmdirPromise(p, opts) { + return await new Promise((resolve, reject) => { + if (opts) { + this.realFs.rmdir(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); + } else { + this.realFs.rmdir(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); + } + }); + } + rmdirSync(p, opts) { + return this.realFs.rmdirSync(npath.fromPortablePath(p), opts); + } + async rmPromise(p, opts) { + return await new Promise((resolve, reject) => { + if (opts) { + this.realFs.rm(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); + } else { + this.realFs.rm(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); + } + }); + } + rmSync(p, opts) { + return this.realFs.rmSync(npath.fromPortablePath(p), opts); + } + async linkPromise(existingP, newP) { + return await new Promise((resolve, reject) => { + this.realFs.link(npath.fromPortablePath(existingP), npath.fromPortablePath(newP), this.makeCallback(resolve, reject)); + }); + } + linkSync(existingP, newP) { + return this.realFs.linkSync(npath.fromPortablePath(existingP), npath.fromPortablePath(newP)); + } + async symlinkPromise(target, p, type) { + return await new Promise((resolve, reject) => { + this.realFs.symlink(npath.fromPortablePath(target.replace(/\/+$/, ``)), npath.fromPortablePath(p), type, this.makeCallback(resolve, reject)); + }); + } + symlinkSync(target, p, type) { + return this.realFs.symlinkSync(npath.fromPortablePath(target.replace(/\/+$/, ``)), npath.fromPortablePath(p), type); + } + async readFilePromise(p, encoding) { + return await new Promise((resolve, reject) => { + const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; + this.realFs.readFile(fsNativePath, encoding, this.makeCallback(resolve, reject)); + }); + } + readFileSync(p, encoding) { + const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; + return this.realFs.readFileSync(fsNativePath, encoding); + } + async readdirPromise(p, opts) { + return await new Promise((resolve, reject) => { + if (opts) { + if (opts.recursive && process.platform === `win32`) { + if (opts.withFileTypes) { + this.realFs.readdir(npath.fromPortablePath(p), opts, this.makeCallback((results) => resolve(results.map(direntToPortable)), reject)); + } else { + this.realFs.readdir(npath.fromPortablePath(p), opts, this.makeCallback((results) => resolve(results.map(npath.toPortablePath)), reject)); + } + } else { + this.realFs.readdir(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); + } + } else { + this.realFs.readdir(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); + } + }); + } + readdirSync(p, opts) { + if (opts) { + if (opts.recursive && process.platform === `win32`) { + if (opts.withFileTypes) { + return this.realFs.readdirSync(npath.fromPortablePath(p), opts).map(direntToPortable); + } else { + return this.realFs.readdirSync(npath.fromPortablePath(p), opts).map(npath.toPortablePath); + } + } else { + return this.realFs.readdirSync(npath.fromPortablePath(p), opts); + } + } else { + return this.realFs.readdirSync(npath.fromPortablePath(p)); + } + } + async readlinkPromise(p) { + return await new Promise((resolve, reject) => { + this.realFs.readlink(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); + }).then((path) => { + return npath.toPortablePath(path); + }); + } + readlinkSync(p) { + return npath.toPortablePath(this.realFs.readlinkSync(npath.fromPortablePath(p))); + } + async truncatePromise(p, len) { + return await new Promise((resolve, reject) => { + this.realFs.truncate(npath.fromPortablePath(p), len, this.makeCallback(resolve, reject)); + }); + } + truncateSync(p, len) { + return this.realFs.truncateSync(npath.fromPortablePath(p), len); + } + async ftruncatePromise(fd, len) { + return await new Promise((resolve, reject) => { + this.realFs.ftruncate(fd, len, this.makeCallback(resolve, reject)); + }); + } + ftruncateSync(fd, len) { + return this.realFs.ftruncateSync(fd, len); + } + watch(p, a, b) { + return this.realFs.watch( + npath.fromPortablePath(p), + // @ts-expect-error - reason TBS + a, + b + ); + } + watchFile(p, a, b) { + return this.realFs.watchFile( + npath.fromPortablePath(p), + // @ts-expect-error - reason TBS + a, + b + ); + } + unwatchFile(p, cb) { + return this.realFs.unwatchFile(npath.fromPortablePath(p), cb); + } + makeCallback(resolve, reject) { + return (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }; + } +} + +const MOUNT_MASK = 4278190080; +class MountFS extends BasePortableFakeFS { + baseFs; + mountInstances; + fdMap = /* @__PURE__ */ new Map(); + nextFd = 3; + factoryPromise; + factorySync; + filter; + getMountPoint; + magic; + maxAge; + maxOpenFiles; + typeCheck; + isMount = /* @__PURE__ */ new Set(); + notMount = /* @__PURE__ */ new Set(); + realPaths = /* @__PURE__ */ new Map(); + constructor({ baseFs = new NodeFS(), filter = null, magicByte = 42, maxOpenFiles = Infinity, useCache = true, maxAge = 5e3, typeCheck = fs.constants.S_IFREG, getMountPoint, factoryPromise, factorySync }) { + if (Math.floor(magicByte) !== magicByte || !(magicByte > 1 && magicByte <= 127)) + throw new Error(`The magic byte must be set to a round value between 1 and 127 included`); + super(); + this.baseFs = baseFs; + this.mountInstances = useCache ? /* @__PURE__ */ new Map() : null; + this.factoryPromise = factoryPromise; + this.factorySync = factorySync; + this.filter = filter; + this.getMountPoint = getMountPoint; + this.magic = magicByte << 24; + this.maxAge = maxAge; + this.maxOpenFiles = maxOpenFiles; + this.typeCheck = typeCheck; + } + getExtractHint(hints) { + return this.baseFs.getExtractHint(hints); + } + getRealPath() { + return this.baseFs.getRealPath(); + } + saveAndClose() { + unwatchAllFiles(this); + if (this.mountInstances) { + for (const [path, { childFs }] of this.mountInstances.entries()) { + childFs.saveAndClose?.(); + this.mountInstances.delete(path); + } + } + } + discardAndClose() { + unwatchAllFiles(this); + if (this.mountInstances) { + for (const [path, { childFs }] of this.mountInstances.entries()) { + childFs.discardAndClose?.(); + this.mountInstances.delete(path); + } + } + } + resolve(p) { + return this.baseFs.resolve(p); + } + remapFd(mountFs, fd) { + const remappedFd = this.nextFd++ | this.magic; + this.fdMap.set(remappedFd, [mountFs, fd]); + return remappedFd; + } + async openPromise(p, flags, mode) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.openPromise(p, flags, mode); + }, async (mountFs, { subPath }) => { + return this.remapFd(mountFs, await mountFs.openPromise(subPath, flags, mode)); + }); + } + openSync(p, flags, mode) { + return this.makeCallSync(p, () => { + return this.baseFs.openSync(p, flags, mode); + }, (mountFs, { subPath }) => { + return this.remapFd(mountFs, mountFs.openSync(subPath, flags, mode)); + }); + } + async opendirPromise(p, opts) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.opendirPromise(p, opts); + }, async (mountFs, { subPath }) => { + return await mountFs.opendirPromise(subPath, opts); + }, { + requireSubpath: false + }); + } + opendirSync(p, opts) { + return this.makeCallSync(p, () => { + return this.baseFs.opendirSync(p, opts); + }, (mountFs, { subPath }) => { + return mountFs.opendirSync(subPath, opts); + }, { + requireSubpath: false + }); + } + async readPromise(fd, buffer, offset, length, position) { + if ((fd & MOUNT_MASK) !== this.magic) + return await this.baseFs.readPromise(fd, buffer, offset, length, position); + const entry = this.fdMap.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`read`); + const [mountFs, realFd] = entry; + return await mountFs.readPromise(realFd, buffer, offset, length, position); + } + readSync(fd, buffer, offset, length, position) { + if ((fd & MOUNT_MASK) !== this.magic) + return this.baseFs.readSync(fd, buffer, offset, length, position); + const entry = this.fdMap.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`readSync`); + const [mountFs, realFd] = entry; + return mountFs.readSync(realFd, buffer, offset, length, position); + } + async writePromise(fd, buffer, offset, length, position) { + if ((fd & MOUNT_MASK) !== this.magic) { + if (typeof buffer === `string`) { + return await this.baseFs.writePromise(fd, buffer, offset); + } else { + return await this.baseFs.writePromise(fd, buffer, offset, length, position); + } + } + const entry = this.fdMap.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`write`); + const [mountFs, realFd] = entry; + if (typeof buffer === `string`) { + return await mountFs.writePromise(realFd, buffer, offset); + } else { + return await mountFs.writePromise(realFd, buffer, offset, length, position); + } + } + writeSync(fd, buffer, offset, length, position) { + if ((fd & MOUNT_MASK) !== this.magic) { + if (typeof buffer === `string`) { + return this.baseFs.writeSync(fd, buffer, offset); + } else { + return this.baseFs.writeSync(fd, buffer, offset, length, position); + } + } + const entry = this.fdMap.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`writeSync`); + const [mountFs, realFd] = entry; + if (typeof buffer === `string`) { + return mountFs.writeSync(realFd, buffer, offset); + } else { + return mountFs.writeSync(realFd, buffer, offset, length, position); + } + } + async closePromise(fd) { + if ((fd & MOUNT_MASK) !== this.magic) + return await this.baseFs.closePromise(fd); + const entry = this.fdMap.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`close`); + this.fdMap.delete(fd); + const [mountFs, realFd] = entry; + return await mountFs.closePromise(realFd); + } + closeSync(fd) { + if ((fd & MOUNT_MASK) !== this.magic) + return this.baseFs.closeSync(fd); + const entry = this.fdMap.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`closeSync`); + this.fdMap.delete(fd); + const [mountFs, realFd] = entry; + return mountFs.closeSync(realFd); + } + createReadStream(p, opts) { + if (p === null) + return this.baseFs.createReadStream(p, opts); + return this.makeCallSync(p, () => { + return this.baseFs.createReadStream(p, opts); + }, (mountFs, { archivePath, subPath }) => { + const stream = mountFs.createReadStream(subPath, opts); + stream.path = npath.fromPortablePath(this.pathUtils.join(archivePath, subPath)); + return stream; + }); + } + createWriteStream(p, opts) { + if (p === null) + return this.baseFs.createWriteStream(p, opts); + return this.makeCallSync(p, () => { + return this.baseFs.createWriteStream(p, opts); + }, (mountFs, { subPath }) => { + return mountFs.createWriteStream(subPath, opts); + }); + } + async realpathPromise(p) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.realpathPromise(p); + }, async (mountFs, { archivePath, subPath }) => { + let realArchivePath = this.realPaths.get(archivePath); + if (typeof realArchivePath === `undefined`) { + realArchivePath = await this.baseFs.realpathPromise(archivePath); + this.realPaths.set(archivePath, realArchivePath); + } + return this.pathUtils.join(realArchivePath, this.pathUtils.relative(PortablePath.root, await mountFs.realpathPromise(subPath))); + }); + } + realpathSync(p) { + return this.makeCallSync(p, () => { + return this.baseFs.realpathSync(p); + }, (mountFs, { archivePath, subPath }) => { + let realArchivePath = this.realPaths.get(archivePath); + if (typeof realArchivePath === `undefined`) { + realArchivePath = this.baseFs.realpathSync(archivePath); + this.realPaths.set(archivePath, realArchivePath); + } + return this.pathUtils.join(realArchivePath, this.pathUtils.relative(PortablePath.root, mountFs.realpathSync(subPath))); + }); + } + async existsPromise(p) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.existsPromise(p); + }, async (mountFs, { subPath }) => { + return await mountFs.existsPromise(subPath); + }); + } + existsSync(p) { + return this.makeCallSync(p, () => { + return this.baseFs.existsSync(p); + }, (mountFs, { subPath }) => { + return mountFs.existsSync(subPath); + }); + } + async accessPromise(p, mode) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.accessPromise(p, mode); + }, async (mountFs, { subPath }) => { + return await mountFs.accessPromise(subPath, mode); + }); + } + accessSync(p, mode) { + return this.makeCallSync(p, () => { + return this.baseFs.accessSync(p, mode); + }, (mountFs, { subPath }) => { + return mountFs.accessSync(subPath, mode); + }); + } + async statPromise(p, opts) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.statPromise(p, opts); + }, async (mountFs, { subPath }) => { + return await mountFs.statPromise(subPath, opts); + }); + } + statSync(p, opts) { + return this.makeCallSync(p, () => { + return this.baseFs.statSync(p, opts); + }, (mountFs, { subPath }) => { + return mountFs.statSync(subPath, opts); + }); + } + async fstatPromise(fd, opts) { + if ((fd & MOUNT_MASK) !== this.magic) + return this.baseFs.fstatPromise(fd, opts); + const entry = this.fdMap.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`fstat`); + const [mountFs, realFd] = entry; + return mountFs.fstatPromise(realFd, opts); + } + fstatSync(fd, opts) { + if ((fd & MOUNT_MASK) !== this.magic) + return this.baseFs.fstatSync(fd, opts); + const entry = this.fdMap.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`fstatSync`); + const [mountFs, realFd] = entry; + return mountFs.fstatSync(realFd, opts); + } + async lstatPromise(p, opts) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.lstatPromise(p, opts); + }, async (mountFs, { subPath }) => { + return await mountFs.lstatPromise(subPath, opts); + }); + } + lstatSync(p, opts) { + return this.makeCallSync(p, () => { + return this.baseFs.lstatSync(p, opts); + }, (mountFs, { subPath }) => { + return mountFs.lstatSync(subPath, opts); + }); + } + async fchmodPromise(fd, mask) { + if ((fd & MOUNT_MASK) !== this.magic) + return this.baseFs.fchmodPromise(fd, mask); + const entry = this.fdMap.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`fchmod`); + const [mountFs, realFd] = entry; + return mountFs.fchmodPromise(realFd, mask); + } + fchmodSync(fd, mask) { + if ((fd & MOUNT_MASK) !== this.magic) + return this.baseFs.fchmodSync(fd, mask); + const entry = this.fdMap.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`fchmodSync`); + const [mountFs, realFd] = entry; + return mountFs.fchmodSync(realFd, mask); + } + async chmodPromise(p, mask) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.chmodPromise(p, mask); + }, async (mountFs, { subPath }) => { + return await mountFs.chmodPromise(subPath, mask); + }); + } + chmodSync(p, mask) { + return this.makeCallSync(p, () => { + return this.baseFs.chmodSync(p, mask); + }, (mountFs, { subPath }) => { + return mountFs.chmodSync(subPath, mask); + }); + } + async fchownPromise(fd, uid, gid) { + if ((fd & MOUNT_MASK) !== this.magic) + return this.baseFs.fchownPromise(fd, uid, gid); + const entry = this.fdMap.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`fchown`); + const [zipFs, realFd] = entry; + return zipFs.fchownPromise(realFd, uid, gid); + } + fchownSync(fd, uid, gid) { + if ((fd & MOUNT_MASK) !== this.magic) + return this.baseFs.fchownSync(fd, uid, gid); + const entry = this.fdMap.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`fchownSync`); + const [zipFs, realFd] = entry; + return zipFs.fchownSync(realFd, uid, gid); + } + async chownPromise(p, uid, gid) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.chownPromise(p, uid, gid); + }, async (mountFs, { subPath }) => { + return await mountFs.chownPromise(subPath, uid, gid); + }); + } + chownSync(p, uid, gid) { + return this.makeCallSync(p, () => { + return this.baseFs.chownSync(p, uid, gid); + }, (mountFs, { subPath }) => { + return mountFs.chownSync(subPath, uid, gid); + }); + } + async renamePromise(oldP, newP) { + return await this.makeCallPromise(oldP, async () => { + return await this.makeCallPromise(newP, async () => { + return await this.baseFs.renamePromise(oldP, newP); + }, async () => { + throw Object.assign(new Error(`EEXDEV: cross-device link not permitted`), { code: `EEXDEV` }); + }); + }, async (mountFsO, { subPath: subPathO }) => { + return await this.makeCallPromise(newP, async () => { + throw Object.assign(new Error(`EEXDEV: cross-device link not permitted`), { code: `EEXDEV` }); + }, async (mountFsN, { subPath: subPathN }) => { + if (mountFsO !== mountFsN) { + throw Object.assign(new Error(`EEXDEV: cross-device link not permitted`), { code: `EEXDEV` }); + } else { + return await mountFsO.renamePromise(subPathO, subPathN); + } + }); + }); + } + renameSync(oldP, newP) { + return this.makeCallSync(oldP, () => { + return this.makeCallSync(newP, () => { + return this.baseFs.renameSync(oldP, newP); + }, () => { + throw Object.assign(new Error(`EEXDEV: cross-device link not permitted`), { code: `EEXDEV` }); + }); + }, (mountFsO, { subPath: subPathO }) => { + return this.makeCallSync(newP, () => { + throw Object.assign(new Error(`EEXDEV: cross-device link not permitted`), { code: `EEXDEV` }); + }, (mountFsN, { subPath: subPathN }) => { + if (mountFsO !== mountFsN) { + throw Object.assign(new Error(`EEXDEV: cross-device link not permitted`), { code: `EEXDEV` }); + } else { + return mountFsO.renameSync(subPathO, subPathN); + } + }); + }); + } + async copyFilePromise(sourceP, destP, flags = 0) { + const fallback = async (sourceFs, sourceP2, destFs, destP2) => { + if ((flags & fs.constants.COPYFILE_FICLONE_FORCE) !== 0) + throw Object.assign(new Error(`EXDEV: cross-device clone not permitted, copyfile '${sourceP2}' -> ${destP2}'`), { code: `EXDEV` }); + if (flags & fs.constants.COPYFILE_EXCL && await this.existsPromise(sourceP2)) + throw Object.assign(new Error(`EEXIST: file already exists, copyfile '${sourceP2}' -> '${destP2}'`), { code: `EEXIST` }); + let content; + try { + content = await sourceFs.readFilePromise(sourceP2); + } catch { + throw Object.assign(new Error(`EINVAL: invalid argument, copyfile '${sourceP2}' -> '${destP2}'`), { code: `EINVAL` }); + } + await destFs.writeFilePromise(destP2, content); + }; + return await this.makeCallPromise(sourceP, async () => { + return await this.makeCallPromise(destP, async () => { + return await this.baseFs.copyFilePromise(sourceP, destP, flags); + }, async (mountFsD, { subPath: subPathD }) => { + return await fallback(this.baseFs, sourceP, mountFsD, subPathD); + }); + }, async (mountFsS, { subPath: subPathS }) => { + return await this.makeCallPromise(destP, async () => { + return await fallback(mountFsS, subPathS, this.baseFs, destP); + }, async (mountFsD, { subPath: subPathD }) => { + if (mountFsS !== mountFsD) { + return await fallback(mountFsS, subPathS, mountFsD, subPathD); + } else { + return await mountFsS.copyFilePromise(subPathS, subPathD, flags); + } + }); + }); + } + copyFileSync(sourceP, destP, flags = 0) { + const fallback = (sourceFs, sourceP2, destFs, destP2) => { + if ((flags & fs.constants.COPYFILE_FICLONE_FORCE) !== 0) + throw Object.assign(new Error(`EXDEV: cross-device clone not permitted, copyfile '${sourceP2}' -> ${destP2}'`), { code: `EXDEV` }); + if (flags & fs.constants.COPYFILE_EXCL && this.existsSync(sourceP2)) + throw Object.assign(new Error(`EEXIST: file already exists, copyfile '${sourceP2}' -> '${destP2}'`), { code: `EEXIST` }); + let content; + try { + content = sourceFs.readFileSync(sourceP2); + } catch { + throw Object.assign(new Error(`EINVAL: invalid argument, copyfile '${sourceP2}' -> '${destP2}'`), { code: `EINVAL` }); + } + destFs.writeFileSync(destP2, content); + }; + return this.makeCallSync(sourceP, () => { + return this.makeCallSync(destP, () => { + return this.baseFs.copyFileSync(sourceP, destP, flags); + }, (mountFsD, { subPath: subPathD }) => { + return fallback(this.baseFs, sourceP, mountFsD, subPathD); + }); + }, (mountFsS, { subPath: subPathS }) => { + return this.makeCallSync(destP, () => { + return fallback(mountFsS, subPathS, this.baseFs, destP); + }, (mountFsD, { subPath: subPathD }) => { + if (mountFsS !== mountFsD) { + return fallback(mountFsS, subPathS, mountFsD, subPathD); + } else { + return mountFsS.copyFileSync(subPathS, subPathD, flags); + } + }); + }); + } + async appendFilePromise(p, content, opts) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.appendFilePromise(p, content, opts); + }, async (mountFs, { subPath }) => { + return await mountFs.appendFilePromise(subPath, content, opts); + }); + } + appendFileSync(p, content, opts) { + return this.makeCallSync(p, () => { + return this.baseFs.appendFileSync(p, content, opts); + }, (mountFs, { subPath }) => { + return mountFs.appendFileSync(subPath, content, opts); + }); + } + async writeFilePromise(p, content, opts) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.writeFilePromise(p, content, opts); + }, async (mountFs, { subPath }) => { + return await mountFs.writeFilePromise(subPath, content, opts); + }); + } + writeFileSync(p, content, opts) { + return this.makeCallSync(p, () => { + return this.baseFs.writeFileSync(p, content, opts); + }, (mountFs, { subPath }) => { + return mountFs.writeFileSync(subPath, content, opts); + }); + } + async unlinkPromise(p) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.unlinkPromise(p); + }, async (mountFs, { subPath }) => { + return await mountFs.unlinkPromise(subPath); + }); + } + unlinkSync(p) { + return this.makeCallSync(p, () => { + return this.baseFs.unlinkSync(p); + }, (mountFs, { subPath }) => { + return mountFs.unlinkSync(subPath); + }); + } + async utimesPromise(p, atime, mtime) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.utimesPromise(p, atime, mtime); + }, async (mountFs, { subPath }) => { + return await mountFs.utimesPromise(subPath, atime, mtime); + }); + } + utimesSync(p, atime, mtime) { + return this.makeCallSync(p, () => { + return this.baseFs.utimesSync(p, atime, mtime); + }, (mountFs, { subPath }) => { + return mountFs.utimesSync(subPath, atime, mtime); + }); + } + async lutimesPromise(p, atime, mtime) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.lutimesPromise(p, atime, mtime); + }, async (mountFs, { subPath }) => { + return await mountFs.lutimesPromise(subPath, atime, mtime); + }); + } + lutimesSync(p, atime, mtime) { + return this.makeCallSync(p, () => { + return this.baseFs.lutimesSync(p, atime, mtime); + }, (mountFs, { subPath }) => { + return mountFs.lutimesSync(subPath, atime, mtime); + }); + } + async mkdirPromise(p, opts) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.mkdirPromise(p, opts); + }, async (mountFs, { subPath }) => { + return await mountFs.mkdirPromise(subPath, opts); + }); + } + mkdirSync(p, opts) { + return this.makeCallSync(p, () => { + return this.baseFs.mkdirSync(p, opts); + }, (mountFs, { subPath }) => { + return mountFs.mkdirSync(subPath, opts); + }); + } + async rmdirPromise(p, opts) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.rmdirPromise(p, opts); + }, async (mountFs, { subPath }) => { + return await mountFs.rmdirPromise(subPath, opts); + }); + } + rmdirSync(p, opts) { + return this.makeCallSync(p, () => { + return this.baseFs.rmdirSync(p, opts); + }, (mountFs, { subPath }) => { + return mountFs.rmdirSync(subPath, opts); + }); + } + async rmPromise(p, opts) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.rmPromise(p, opts); + }, async (mountFs, { subPath }) => { + return await mountFs.rmPromise(subPath, opts); + }); + } + rmSync(p, opts) { + return this.makeCallSync(p, () => { + return this.baseFs.rmSync(p, opts); + }, (mountFs, { subPath }) => { + return mountFs.rmSync(subPath, opts); + }); + } + async linkPromise(existingP, newP) { + return await this.makeCallPromise(newP, async () => { + return await this.baseFs.linkPromise(existingP, newP); + }, async (mountFs, { subPath }) => { + return await mountFs.linkPromise(existingP, subPath); + }); + } + linkSync(existingP, newP) { + return this.makeCallSync(newP, () => { + return this.baseFs.linkSync(existingP, newP); + }, (mountFs, { subPath }) => { + return mountFs.linkSync(existingP, subPath); + }); + } + async symlinkPromise(target, p, type) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.symlinkPromise(target, p, type); + }, async (mountFs, { subPath }) => { + return await mountFs.symlinkPromise(target, subPath); + }); + } + symlinkSync(target, p, type) { + return this.makeCallSync(p, () => { + return this.baseFs.symlinkSync(target, p, type); + }, (mountFs, { subPath }) => { + return mountFs.symlinkSync(target, subPath); + }); + } + async readFilePromise(p, encoding) { + return this.makeCallPromise(p, async () => { + return await this.baseFs.readFilePromise(p, encoding); + }, async (mountFs, { subPath }) => { + return await mountFs.readFilePromise(subPath, encoding); + }); + } + readFileSync(p, encoding) { + return this.makeCallSync(p, () => { + return this.baseFs.readFileSync(p, encoding); + }, (mountFs, { subPath }) => { + return mountFs.readFileSync(subPath, encoding); + }); + } + async readdirPromise(p, opts) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.readdirPromise(p, opts); + }, async (mountFs, { subPath }) => { + return await mountFs.readdirPromise(subPath, opts); + }, { + requireSubpath: false + }); + } + readdirSync(p, opts) { + return this.makeCallSync(p, () => { + return this.baseFs.readdirSync(p, opts); + }, (mountFs, { subPath }) => { + return mountFs.readdirSync(subPath, opts); + }, { + requireSubpath: false + }); + } + async readlinkPromise(p) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.readlinkPromise(p); + }, async (mountFs, { subPath }) => { + return await mountFs.readlinkPromise(subPath); + }); + } + readlinkSync(p) { + return this.makeCallSync(p, () => { + return this.baseFs.readlinkSync(p); + }, (mountFs, { subPath }) => { + return mountFs.readlinkSync(subPath); + }); + } + async truncatePromise(p, len) { + return await this.makeCallPromise(p, async () => { + return await this.baseFs.truncatePromise(p, len); + }, async (mountFs, { subPath }) => { + return await mountFs.truncatePromise(subPath, len); + }); + } + truncateSync(p, len) { + return this.makeCallSync(p, () => { + return this.baseFs.truncateSync(p, len); + }, (mountFs, { subPath }) => { + return mountFs.truncateSync(subPath, len); + }); + } + async ftruncatePromise(fd, len) { + if ((fd & MOUNT_MASK) !== this.magic) + return this.baseFs.ftruncatePromise(fd, len); + const entry = this.fdMap.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`ftruncate`); + const [mountFs, realFd] = entry; + return mountFs.ftruncatePromise(realFd, len); + } + ftruncateSync(fd, len) { + if ((fd & MOUNT_MASK) !== this.magic) + return this.baseFs.ftruncateSync(fd, len); + const entry = this.fdMap.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`ftruncateSync`); + const [mountFs, realFd] = entry; + return mountFs.ftruncateSync(realFd, len); + } + watch(p, a, b) { + return this.makeCallSync(p, () => { + return this.baseFs.watch( + p, + // @ts-expect-error - reason TBS + a, + b + ); + }, (mountFs, { subPath }) => { + return mountFs.watch( + subPath, + // @ts-expect-error - reason TBS + a, + b + ); + }); + } + watchFile(p, a, b) { + return this.makeCallSync(p, () => { + return this.baseFs.watchFile( + p, + // @ts-expect-error - reason TBS + a, + b + ); + }, () => { + return watchFile(this, p, a, b); + }); + } + unwatchFile(p, cb) { + return this.makeCallSync(p, () => { + return this.baseFs.unwatchFile(p, cb); + }, () => { + return unwatchFile(this, p, cb); + }); + } + async makeCallPromise(p, discard, accept, { requireSubpath = true } = {}) { + if (typeof p !== `string`) + return await discard(); + const normalizedP = this.resolve(p); + const mountInfo = this.findMount(normalizedP); + if (!mountInfo) + return await discard(); + if (requireSubpath && mountInfo.subPath === `/`) + return await discard(); + return await this.getMountPromise(mountInfo.archivePath, async (mountFs) => await accept(mountFs, mountInfo)); + } + makeCallSync(p, discard, accept, { requireSubpath = true } = {}) { + if (typeof p !== `string`) + return discard(); + const normalizedP = this.resolve(p); + const mountInfo = this.findMount(normalizedP); + if (!mountInfo) + return discard(); + if (requireSubpath && mountInfo.subPath === `/`) + return discard(); + return this.getMountSync(mountInfo.archivePath, (mountFs) => accept(mountFs, mountInfo)); + } + findMount(p) { + if (this.filter && !this.filter.test(p)) + return null; + let filePath = ``; + while (true) { + const pathPartWithArchive = p.substring(filePath.length); + const mountPoint = this.getMountPoint(pathPartWithArchive, filePath); + if (!mountPoint) + return null; + filePath = this.pathUtils.join(filePath, mountPoint); + if (!this.isMount.has(filePath)) { + if (this.notMount.has(filePath)) + continue; + try { + if (this.typeCheck !== null && (this.baseFs.statSync(filePath).mode & fs.constants.S_IFMT) !== this.typeCheck) { + this.notMount.add(filePath); + continue; + } + } catch { + return null; + } + this.isMount.add(filePath); + } + return { + archivePath: filePath, + subPath: this.pathUtils.join(PortablePath.root, p.substring(filePath.length)) + }; + } + } + limitOpenFilesTimeout = null; + limitOpenFiles(max) { + if (this.mountInstances === null) + return; + const now = Date.now(); + let nextExpiresAt = now + this.maxAge; + let closeCount = max === null ? 0 : this.mountInstances.size - max; + for (const [path, { childFs, expiresAt, refCount }] of this.mountInstances.entries()) { + if (refCount !== 0 || childFs.hasOpenFileHandles?.()) { + continue; + } else if (now >= expiresAt) { + childFs.saveAndClose?.(); + this.mountInstances.delete(path); + closeCount -= 1; + continue; + } else if (max === null || closeCount <= 0) { + nextExpiresAt = expiresAt; + break; + } + childFs.saveAndClose?.(); + this.mountInstances.delete(path); + closeCount -= 1; + } + if (this.limitOpenFilesTimeout === null && (max === null && this.mountInstances.size > 0 || max !== null) && isFinite(nextExpiresAt)) { + this.limitOpenFilesTimeout = setTimeout(() => { + this.limitOpenFilesTimeout = null; + this.limitOpenFiles(null); + }, nextExpiresAt - now).unref(); + } + } + async getMountPromise(p, accept) { + if (this.mountInstances) { + let cachedMountFs = this.mountInstances.get(p); + if (!cachedMountFs) { + const createFsInstance = await this.factoryPromise(this.baseFs, p); + cachedMountFs = this.mountInstances.get(p); + if (!cachedMountFs) { + cachedMountFs = { + childFs: createFsInstance(), + expiresAt: 0, + refCount: 0 + }; + } + } + this.mountInstances.delete(p); + this.limitOpenFiles(this.maxOpenFiles - 1); + this.mountInstances.set(p, cachedMountFs); + cachedMountFs.expiresAt = Date.now() + this.maxAge; + cachedMountFs.refCount += 1; + try { + return await accept(cachedMountFs.childFs); + } finally { + cachedMountFs.refCount -= 1; + } + } else { + const mountFs = (await this.factoryPromise(this.baseFs, p))(); + try { + return await accept(mountFs); + } finally { + mountFs.saveAndClose?.(); + } + } + } + getMountSync(p, accept) { + if (this.mountInstances) { + let cachedMountFs = this.mountInstances.get(p); + if (!cachedMountFs) { + cachedMountFs = { + childFs: this.factorySync(this.baseFs, p), + expiresAt: 0, + refCount: 0 + }; + } + this.mountInstances.delete(p); + this.limitOpenFiles(this.maxOpenFiles - 1); + this.mountInstances.set(p, cachedMountFs); + cachedMountFs.expiresAt = Date.now() + this.maxAge; + return accept(cachedMountFs.childFs); + } else { + const childFs = this.factorySync(this.baseFs, p); + try { + return accept(childFs); + } finally { + childFs.saveAndClose?.(); + } + } + } +} + +class PosixFS extends ProxiedFS { + baseFs; + constructor(baseFs) { + super(npath); + this.baseFs = baseFs; + } + mapFromBase(path) { + return npath.fromPortablePath(path); + } + mapToBase(path) { + return npath.toPortablePath(path); + } +} + +const NUMBER_REGEXP = /^[0-9]+$/; +const VIRTUAL_REGEXP = /^(\/(?:[^/]+\/)*?(?:\$\$virtual|__virtual__))((?:\/((?:[^/]+-)?[a-f0-9]+)(?:\/([^/]+))?)?((?:\/.*)?))$/; +const VALID_COMPONENT = /^([^/]+-)?[a-f0-9]+$/; +class VirtualFS extends ProxiedFS { + baseFs; + static makeVirtualPath(base, component, to) { + if (ppath.basename(base) !== `__virtual__`) + throw new Error(`Assertion failed: Virtual folders must be named "__virtual__"`); + if (!ppath.basename(component).match(VALID_COMPONENT)) + throw new Error(`Assertion failed: Virtual components must be ended by an hexadecimal hash`); + const target = ppath.relative(ppath.dirname(base), to); + const segments = target.split(`/`); + let depth = 0; + while (depth < segments.length && segments[depth] === `..`) + depth += 1; + const finalSegments = segments.slice(depth); + const fullVirtualPath = ppath.join(base, component, String(depth), ...finalSegments); + return fullVirtualPath; + } + static resolveVirtual(p) { + const match = p.match(VIRTUAL_REGEXP); + if (!match || !match[3] && match[5]) + return p; + const target = ppath.dirname(match[1]); + if (!match[3] || !match[4]) + return target; + const isnum = NUMBER_REGEXP.test(match[4]); + if (!isnum) + return p; + const depth = Number(match[4]); + const backstep = `../`.repeat(depth); + const subpath = match[5] || `.`; + return VirtualFS.resolveVirtual(ppath.join(target, backstep, subpath)); + } + constructor({ baseFs = new NodeFS() } = {}) { + super(ppath); + this.baseFs = baseFs; + } + getExtractHint(hints) { + return this.baseFs.getExtractHint(hints); + } + getRealPath() { + return this.baseFs.getRealPath(); + } + realpathSync(p) { + const match = p.match(VIRTUAL_REGEXP); + if (!match) + return this.baseFs.realpathSync(p); + if (!match[5]) + return p; + const realpath = this.baseFs.realpathSync(this.mapToBase(p)); + return VirtualFS.makeVirtualPath(match[1], match[3], realpath); + } + async realpathPromise(p) { + const match = p.match(VIRTUAL_REGEXP); + if (!match) + return await this.baseFs.realpathPromise(p); + if (!match[5]) + return p; + const realpath = await this.baseFs.realpathPromise(this.mapToBase(p)); + return VirtualFS.makeVirtualPath(match[1], match[3], realpath); + } + mapToBase(p) { + if (p === ``) + return p; + if (this.pathUtils.isAbsolute(p)) + return VirtualFS.resolveVirtual(p); + const resolvedRoot = VirtualFS.resolveVirtual(this.baseFs.resolve(PortablePath.dot)); + const resolvedP = VirtualFS.resolveVirtual(this.baseFs.resolve(p)); + return ppath.relative(resolvedRoot, resolvedP) || PortablePath.dot; + } + mapFromBase(p) { + return p; + } +} + +const URL = Number(process.versions.node.split('.', 1)[0]) < 20 ? url.URL : globalThis.URL; + +class NodePathFS extends ProxiedFS { + baseFs; + constructor(baseFs) { + super(npath); + this.baseFs = baseFs; + } + mapFromBase(path) { + return path; + } + mapToBase(path) { + if (typeof path === `string`) + return path; + if (path instanceof URL) + return url.fileURLToPath(path); + if (Buffer.isBuffer(path)) { + const str = path.toString(); + if (!isUtf8(path, str)) + throw new Error(`Non-utf8 buffers are not supported at the moment. Please upvote the following issue if you encounter this error: https://github.com/yarnpkg/berry/issues/4942`); + return str; + } + throw new Error(`Unsupported path type: ${nodeUtils.inspect(path)}`); + } +} +function isUtf8(buf, str) { + if (typeof buffer__default.default.isUtf8 !== `undefined`) + return buffer__default.default.isUtf8(buf); + return Buffer.byteLength(str) === buf.byteLength; +} + +const kBaseFs = Symbol(`kBaseFs`); +const kFd = Symbol(`kFd`); +const kClosePromise = Symbol(`kClosePromise`); +const kCloseResolve = Symbol(`kCloseResolve`); +const kCloseReject = Symbol(`kCloseReject`); +const kRefs = Symbol(`kRefs`); +const kRef = Symbol(`kRef`); +const kUnref = Symbol(`kUnref`); +class FileHandle { + [kBaseFs]; + [kFd]; + [kRefs] = 1; + [kClosePromise] = void 0; + [kCloseResolve] = void 0; + [kCloseReject] = void 0; + constructor(fd, baseFs) { + this[kBaseFs] = baseFs; + this[kFd] = fd; + } + get fd() { + return this[kFd]; + } + async appendFile(data, options) { + try { + this[kRef](this.appendFile); + const encoding = (typeof options === `string` ? options : options?.encoding) ?? void 0; + return await this[kBaseFs].appendFilePromise(this.fd, data, encoding ? { encoding } : void 0); + } finally { + this[kUnref](); + } + } + async chown(uid, gid) { + try { + this[kRef](this.chown); + return await this[kBaseFs].fchownPromise(this.fd, uid, gid); + } finally { + this[kUnref](); + } + } + async chmod(mode) { + try { + this[kRef](this.chmod); + return await this[kBaseFs].fchmodPromise(this.fd, mode); + } finally { + this[kUnref](); + } + } + createReadStream(options) { + return this[kBaseFs].createReadStream(null, { ...options, fd: this.fd }); + } + createWriteStream(options) { + return this[kBaseFs].createWriteStream(null, { ...options, fd: this.fd }); + } + // FIXME: Missing FakeFS version + datasync() { + throw new Error(`Method not implemented.`); + } + // FIXME: Missing FakeFS version + sync() { + throw new Error(`Method not implemented.`); + } + async read(bufferOrOptions, offsetOrOptions, length, position) { + try { + this[kRef](this.read); + let buffer; + let offset; + if (!ArrayBuffer.isView(bufferOrOptions)) { + buffer = bufferOrOptions?.buffer ?? Buffer.alloc(16384); + offset = bufferOrOptions?.offset ?? 0; + length = bufferOrOptions?.length ?? buffer.byteLength - offset; + position = bufferOrOptions?.position ?? null; + } else if (typeof offsetOrOptions === `object` && offsetOrOptions !== null) { + buffer = bufferOrOptions; + offset = offsetOrOptions?.offset ?? 0; + length = offsetOrOptions?.length ?? buffer.byteLength - offset; + position = offsetOrOptions?.position ?? null; + } else { + buffer = bufferOrOptions; + offset = offsetOrOptions ?? 0; + length ??= 0; + } + if (length === 0) { + return { + bytesRead: length, + buffer + }; + } + const bytesRead = await this[kBaseFs].readPromise( + this.fd, + // FIXME: FakeFS should support ArrayBufferViews directly + Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer.buffer, buffer.byteOffset, buffer.byteLength), + offset, + length, + position + ); + return { + bytesRead, + buffer + }; + } finally { + this[kUnref](); + } + } + async readFile(options) { + try { + this[kRef](this.readFile); + const encoding = (typeof options === `string` ? options : options?.encoding) ?? void 0; + return await this[kBaseFs].readFilePromise(this.fd, encoding); + } finally { + this[kUnref](); + } + } + readLines(options) { + return readline.createInterface({ + input: this.createReadStream(options), + crlfDelay: Infinity + }); + } + async stat(opts) { + try { + this[kRef](this.stat); + return await this[kBaseFs].fstatPromise(this.fd, opts); + } finally { + this[kUnref](); + } + } + async truncate(len) { + try { + this[kRef](this.truncate); + return await this[kBaseFs].ftruncatePromise(this.fd, len); + } finally { + this[kUnref](); + } + } + // FIXME: Missing FakeFS version + utimes(atime, mtime) { + throw new Error(`Method not implemented.`); + } + async writeFile(data, options) { + try { + this[kRef](this.writeFile); + const encoding = (typeof options === `string` ? options : options?.encoding) ?? void 0; + await this[kBaseFs].writeFilePromise(this.fd, data, encoding); + } finally { + this[kUnref](); + } + } + async write(...args) { + try { + this[kRef](this.write); + if (ArrayBuffer.isView(args[0])) { + const [buffer, offset, length, position] = args; + const bytesWritten = await this[kBaseFs].writePromise(this.fd, buffer, offset ?? void 0, length ?? void 0, position ?? void 0); + return { bytesWritten, buffer }; + } else { + const [data, position, encoding] = args; + const bytesWritten = await this[kBaseFs].writePromise(this.fd, data, position, encoding); + return { bytesWritten, buffer: data }; + } + } finally { + this[kUnref](); + } + } + // TODO: Use writev from FakeFS when that is implemented + async writev(buffers, position) { + try { + this[kRef](this.writev); + let bytesWritten = 0; + if (typeof position !== `undefined`) { + for (const buffer of buffers) { + const writeResult = await this.write(buffer, void 0, void 0, position); + bytesWritten += writeResult.bytesWritten; + position += writeResult.bytesWritten; + } + } else { + for (const buffer of buffers) { + const writeResult = await this.write(buffer); + bytesWritten += writeResult.bytesWritten; + } + } + return { + buffers, + bytesWritten + }; + } finally { + this[kUnref](); + } + } + // FIXME: Missing FakeFS version + readv(buffers, position) { + throw new Error(`Method not implemented.`); + } + close() { + if (this[kFd] === -1) return Promise.resolve(); + if (this[kClosePromise]) return this[kClosePromise]; + this[kRefs]--; + if (this[kRefs] === 0) { + const fd = this[kFd]; + this[kFd] = -1; + this[kClosePromise] = this[kBaseFs].closePromise(fd).finally(() => { + this[kClosePromise] = void 0; + }); + } else { + this[kClosePromise] = new Promise((resolve, reject) => { + this[kCloseResolve] = resolve; + this[kCloseReject] = reject; + }).finally(() => { + this[kClosePromise] = void 0; + this[kCloseReject] = void 0; + this[kCloseResolve] = void 0; + }); + } + return this[kClosePromise]; + } + [kRef](caller) { + if (this[kFd] === -1) { + const err = new Error(`file closed`); + err.code = `EBADF`; + err.syscall = caller.name; + throw err; + } + this[kRefs]++; + } + [kUnref]() { + this[kRefs]--; + if (this[kRefs] === 0) { + const fd = this[kFd]; + this[kFd] = -1; + this[kBaseFs].closePromise(fd).then(this[kCloseResolve], this[kCloseReject]); + } + } +} + +const SYNC_IMPLEMENTATIONS = /* @__PURE__ */ new Set([ + `accessSync`, + `appendFileSync`, + `createReadStream`, + `createWriteStream`, + `chmodSync`, + `fchmodSync`, + `chownSync`, + `fchownSync`, + `closeSync`, + `copyFileSync`, + `linkSync`, + `lstatSync`, + `fstatSync`, + `lutimesSync`, + `mkdirSync`, + `openSync`, + `opendirSync`, + `readlinkSync`, + `readFileSync`, + `readdirSync`, + `readlinkSync`, + `realpathSync`, + `renameSync`, + `rmdirSync`, + `rmSync`, + `statSync`, + `symlinkSync`, + `truncateSync`, + `ftruncateSync`, + `unlinkSync`, + `unwatchFile`, + `utimesSync`, + `watch`, + `watchFile`, + `writeFileSync`, + `writeSync` +]); +const ASYNC_IMPLEMENTATIONS = /* @__PURE__ */ new Set([ + `accessPromise`, + `appendFilePromise`, + `fchmodPromise`, + `chmodPromise`, + `fchownPromise`, + `chownPromise`, + `closePromise`, + `copyFilePromise`, + `linkPromise`, + `fstatPromise`, + `lstatPromise`, + `lutimesPromise`, + `mkdirPromise`, + `openPromise`, + `opendirPromise`, + `readdirPromise`, + `realpathPromise`, + `readFilePromise`, + `readdirPromise`, + `readlinkPromise`, + `renamePromise`, + `rmdirPromise`, + `rmPromise`, + `statPromise`, + `symlinkPromise`, + `truncatePromise`, + `ftruncatePromise`, + `unlinkPromise`, + `utimesPromise`, + `writeFilePromise`, + `writeSync` +]); +function patchFs(patchedFs, fakeFs) { + fakeFs = new NodePathFS(fakeFs); + const setupFn = (target, name, replacement) => { + const orig = target[name]; + target[name] = replacement; + if (typeof orig?.[nodeUtils.promisify.custom] !== `undefined`) { + replacement[nodeUtils.promisify.custom] = orig[nodeUtils.promisify.custom]; + } + }; + { + setupFn(patchedFs, `exists`, (p, ...args) => { + const hasCallback = typeof args[args.length - 1] === `function`; + const callback = hasCallback ? args.pop() : () => { + }; + process.nextTick(() => { + fakeFs.existsPromise(p).then((exists) => { + callback(exists); + }, () => { + callback(false); + }); + }); + }); + setupFn(patchedFs, `read`, (...args) => { + let [fd, buffer, offset, length, position, callback] = args; + if (args.length <= 3) { + let options = {}; + if (args.length < 3) { + callback = args[1]; + } else { + options = args[1]; + callback = args[2]; + } + ({ + buffer = Buffer.alloc(16384), + offset = 0, + length = buffer.byteLength, + position + } = options); + } + if (offset == null) + offset = 0; + length |= 0; + if (length === 0) { + process.nextTick(() => { + callback(null, 0, buffer); + }); + return; + } + if (position == null) + position = -1; + process.nextTick(() => { + fakeFs.readPromise(fd, buffer, offset, length, position).then((bytesRead) => { + callback(null, bytesRead, buffer); + }, (error) => { + callback(error, 0, buffer); + }); + }); + }); + for (const fnName of ASYNC_IMPLEMENTATIONS) { + const origName = fnName.replace(/Promise$/, ``); + if (typeof patchedFs[origName] === `undefined`) + continue; + const fakeImpl = fakeFs[fnName]; + if (typeof fakeImpl === `undefined`) + continue; + const wrapper = (...args) => { + const hasCallback = typeof args[args.length - 1] === `function`; + const callback = hasCallback ? args.pop() : () => { + }; + process.nextTick(() => { + fakeImpl.apply(fakeFs, args).then((result) => { + callback(null, result); + }, (error) => { + callback(error); + }); + }); + }; + setupFn(patchedFs, origName, wrapper); + } + patchedFs.realpath.native = patchedFs.realpath; + } + { + setupFn(patchedFs, `existsSync`, (p) => { + try { + return fakeFs.existsSync(p); + } catch { + return false; + } + }); + setupFn(patchedFs, `readSync`, (...args) => { + let [fd, buffer, offset, length, position] = args; + if (args.length <= 3) { + const options = args[2] || {}; + ({ offset = 0, length = buffer.byteLength, position } = options); + } + if (offset == null) + offset = 0; + length |= 0; + if (length === 0) + return 0; + if (position == null) + position = -1; + return fakeFs.readSync(fd, buffer, offset, length, position); + }); + for (const fnName of SYNC_IMPLEMENTATIONS) { + const origName = fnName; + if (typeof patchedFs[origName] === `undefined`) + continue; + const fakeImpl = fakeFs[fnName]; + if (typeof fakeImpl === `undefined`) + continue; + setupFn(patchedFs, origName, fakeImpl.bind(fakeFs)); + } + patchedFs.realpathSync.native = patchedFs.realpathSync; + } + { + const patchedFsPromises = patchedFs.promises; + for (const fnName of ASYNC_IMPLEMENTATIONS) { + const origName = fnName.replace(/Promise$/, ``); + if (typeof patchedFsPromises[origName] === `undefined`) + continue; + const fakeImpl = fakeFs[fnName]; + if (typeof fakeImpl === `undefined`) + continue; + if (fnName === `open`) + continue; + setupFn(patchedFsPromises, origName, (pathLike, ...args) => { + if (pathLike instanceof FileHandle) { + return pathLike[origName].apply(pathLike, args); + } else { + return fakeImpl.call(fakeFs, pathLike, ...args); + } + }); + } + setupFn(patchedFsPromises, `open`, async (...args) => { + const fd = await fakeFs.openPromise(...args); + return new FileHandle(fd, fakeFs); + }); + } + { + patchedFs.read[nodeUtils.promisify.custom] = async (fd, buffer, ...args) => { + const res = fakeFs.readPromise(fd, buffer, ...args); + return { bytesRead: await res, buffer }; + }; + patchedFs.write[nodeUtils.promisify.custom] = async (fd, buffer, ...args) => { + const res = fakeFs.writePromise(fd, buffer, ...args); + return { bytesWritten: await res, buffer }; + }; + } +} + +let cachedInstance; +let registeredFactory = () => { + throw new Error(`Assertion failed: No libzip instance is available, and no factory was configured`); +}; +function setFactory(factory) { + registeredFactory = factory; +} +function getInstance() { + if (typeof cachedInstance === `undefined`) + cachedInstance = registeredFactory(); + return cachedInstance; +} + +var libzipSync = {exports: {}}; + +(function (module, exports) { +var frozenFs = Object.assign({}, fs__default.default); +var createModule = function() { + var _scriptDir = void 0; + if (typeof __filename !== "undefined") _scriptDir = _scriptDir || __filename; + return function(createModule2) { + createModule2 = createModule2 || {}; + var Module = typeof createModule2 !== "undefined" ? createModule2 : {}; + var readyPromiseResolve, readyPromiseReject; + Module["ready"] = new Promise(function(resolve, reject) { + readyPromiseResolve = resolve; + readyPromiseReject = reject; + }); + var moduleOverrides = {}; + var key; + for (key in Module) { + if (Module.hasOwnProperty(key)) { + moduleOverrides[key] = Module[key]; + } + } + var scriptDirectory = ""; + function locateFile(path) { + if (Module["locateFile"]) { + return Module["locateFile"](path, scriptDirectory); + } + return scriptDirectory + path; + } + var read_, readBinary; + var nodeFS; + var nodePath; + { + { + scriptDirectory = __dirname + "/"; + } + read_ = function shell_read(filename, binary) { + var ret = tryParseAsDataURI(filename); + if (ret) { + return binary ? ret : ret.toString(); + } + if (!nodeFS) nodeFS = frozenFs; + if (!nodePath) nodePath = path__default.default; + filename = nodePath["normalize"](filename); + return nodeFS["readFileSync"](filename, binary ? null : "utf8"); + }; + readBinary = function readBinary2(filename) { + var ret = read_(filename, true); + if (!ret.buffer) { + ret = new Uint8Array(ret); + } + assert(ret.buffer); + return ret; + }; + if (process["argv"].length > 1) { + process["argv"][1].replace(/\\/g, "/"); + } + process["argv"].slice(2); + Module["inspect"] = function() { + return "[Emscripten Module object]"; + }; + } + Module["print"] || console.log.bind(console); + var err = Module["printErr"] || console.warn.bind(console); + for (key in moduleOverrides) { + if (moduleOverrides.hasOwnProperty(key)) { + Module[key] = moduleOverrides[key]; + } + } + moduleOverrides = null; + if (Module["arguments"]) ; + if (Module["thisProgram"]) ; + if (Module["quit"]) ; + var wasmBinary; + if (Module["wasmBinary"]) wasmBinary = Module["wasmBinary"]; + Module["noExitRuntime"] || true; + if (typeof WebAssembly !== "object") { + abort("no native wasm support detected"); + } + function getValue(ptr, type, noSafe) { + type = type || "i8"; + if (type.charAt(type.length - 1) === "*") type = "i32"; + switch (type) { + case "i1": + return HEAP8[ptr >> 0]; + case "i8": + return HEAP8[ptr >> 0]; + case "i16": + return LE_HEAP_LOAD_I16((ptr >> 1) * 2); + case "i32": + return LE_HEAP_LOAD_I32((ptr >> 2) * 4); + case "i64": + return LE_HEAP_LOAD_I32((ptr >> 2) * 4); + case "float": + return LE_HEAP_LOAD_F32((ptr >> 2) * 4); + case "double": + return LE_HEAP_LOAD_F64((ptr >> 3) * 8); + default: + abort("invalid type for getValue: " + type); + } + return null; + } + var wasmMemory; + var ABORT = false; + function assert(condition, text) { + if (!condition) { + abort("Assertion failed: " + text); + } + } + function getCFunc(ident) { + var func = Module["_" + ident]; + assert( + func, + "Cannot call unknown function " + ident + ", make sure it is exported" + ); + return func; + } + function ccall(ident, returnType, argTypes, args, opts) { + var toC = { + string: function(str) { + var ret2 = 0; + if (str !== null && str !== void 0 && str !== 0) { + var len = (str.length << 2) + 1; + ret2 = stackAlloc(len); + stringToUTF8(str, ret2, len); + } + return ret2; + }, + array: function(arr) { + var ret2 = stackAlloc(arr.length); + writeArrayToMemory(arr, ret2); + return ret2; + } + }; + function convertReturnValue(ret2) { + if (returnType === "string") return UTF8ToString(ret2); + if (returnType === "boolean") return Boolean(ret2); + return ret2; + } + var func = getCFunc(ident); + var cArgs = []; + var stack = 0; + if (args) { + for (var i = 0; i < args.length; i++) { + var converter = toC[argTypes[i]]; + if (converter) { + if (stack === 0) stack = stackSave(); + cArgs[i] = converter(args[i]); + } else { + cArgs[i] = args[i]; + } + } + } + var ret = func.apply(null, cArgs); + ret = convertReturnValue(ret); + if (stack !== 0) stackRestore(stack); + return ret; + } + function cwrap(ident, returnType, argTypes, opts) { + argTypes = argTypes || []; + var numericArgs = argTypes.every(function(type) { + return type === "number"; + }); + var numericRet = returnType !== "string"; + if (numericRet && numericArgs && !opts) { + return getCFunc(ident); + } + return function() { + return ccall(ident, returnType, argTypes, arguments); + }; + } + var UTF8Decoder = new TextDecoder("utf8"); + function UTF8ToString(ptr, maxBytesToRead) { + if (!ptr) return ""; + var maxPtr = ptr + maxBytesToRead; + for (var end = ptr; !(end >= maxPtr) && HEAPU8[end]; ) ++end; + return UTF8Decoder.decode(HEAPU8.subarray(ptr, end)); + } + function stringToUTF8Array(str, heap, outIdx, maxBytesToWrite) { + if (!(maxBytesToWrite > 0)) return 0; + var startIdx = outIdx; + var endIdx = outIdx + maxBytesToWrite - 1; + for (var i = 0; i < str.length; ++i) { + var u = str.charCodeAt(i); + if (u >= 55296 && u <= 57343) { + var u1 = str.charCodeAt(++i); + u = 65536 + ((u & 1023) << 10) | u1 & 1023; + } + if (u <= 127) { + if (outIdx >= endIdx) break; + heap[outIdx++] = u; + } else if (u <= 2047) { + if (outIdx + 1 >= endIdx) break; + heap[outIdx++] = 192 | u >> 6; + heap[outIdx++] = 128 | u & 63; + } else if (u <= 65535) { + if (outIdx + 2 >= endIdx) break; + heap[outIdx++] = 224 | u >> 12; + heap[outIdx++] = 128 | u >> 6 & 63; + heap[outIdx++] = 128 | u & 63; + } else { + if (outIdx + 3 >= endIdx) break; + heap[outIdx++] = 240 | u >> 18; + heap[outIdx++] = 128 | u >> 12 & 63; + heap[outIdx++] = 128 | u >> 6 & 63; + heap[outIdx++] = 128 | u & 63; + } + } + heap[outIdx] = 0; + return outIdx - startIdx; + } + function stringToUTF8(str, outPtr, maxBytesToWrite) { + return stringToUTF8Array(str, HEAPU8, outPtr, maxBytesToWrite); + } + function lengthBytesUTF8(str) { + var len = 0; + for (var i = 0; i < str.length; ++i) { + var u = str.charCodeAt(i); + if (u >= 55296 && u <= 57343) + u = 65536 + ((u & 1023) << 10) | str.charCodeAt(++i) & 1023; + if (u <= 127) ++len; + else if (u <= 2047) len += 2; + else if (u <= 65535) len += 3; + else len += 4; + } + return len; + } + function allocateUTF8(str) { + var size = lengthBytesUTF8(str) + 1; + var ret = _malloc(size); + if (ret) stringToUTF8Array(str, HEAP8, ret, size); + return ret; + } + function writeArrayToMemory(array, buffer2) { + HEAP8.set(array, buffer2); + } + function alignUp(x, multiple) { + if (x % multiple > 0) { + x += multiple - x % multiple; + } + return x; + } + var buffer, HEAP8, HEAPU8; + var HEAP_DATA_VIEW; + function updateGlobalBufferAndViews(buf) { + buffer = buf; + Module["HEAP_DATA_VIEW"] = HEAP_DATA_VIEW = new DataView(buf); + Module["HEAP8"] = HEAP8 = new Int8Array(buf); + Module["HEAP16"] = new Int16Array(buf); + Module["HEAP32"] = new Int32Array(buf); + Module["HEAPU8"] = HEAPU8 = new Uint8Array(buf); + Module["HEAPU16"] = new Uint16Array(buf); + Module["HEAPU32"] = new Uint32Array(buf); + Module["HEAPF32"] = new Float32Array(buf); + Module["HEAPF64"] = new Float64Array(buf); + } + Module["INITIAL_MEMORY"] || 16777216; + var wasmTable; + var __ATPRERUN__ = []; + var __ATINIT__ = []; + var __ATPOSTRUN__ = []; + function preRun() { + if (Module["preRun"]) { + if (typeof Module["preRun"] == "function") + Module["preRun"] = [Module["preRun"]]; + while (Module["preRun"].length) { + addOnPreRun(Module["preRun"].shift()); + } + } + callRuntimeCallbacks(__ATPRERUN__); + } + function initRuntime() { + callRuntimeCallbacks(__ATINIT__); + } + function postRun() { + if (Module["postRun"]) { + if (typeof Module["postRun"] == "function") + Module["postRun"] = [Module["postRun"]]; + while (Module["postRun"].length) { + addOnPostRun(Module["postRun"].shift()); + } + } + callRuntimeCallbacks(__ATPOSTRUN__); + } + function addOnPreRun(cb) { + __ATPRERUN__.unshift(cb); + } + function addOnInit(cb) { + __ATINIT__.unshift(cb); + } + function addOnPostRun(cb) { + __ATPOSTRUN__.unshift(cb); + } + var runDependencies = 0; + var dependenciesFulfilled = null; + function addRunDependency(id) { + runDependencies++; + if (Module["monitorRunDependencies"]) { + Module["monitorRunDependencies"](runDependencies); + } + } + function removeRunDependency(id) { + runDependencies--; + if (Module["monitorRunDependencies"]) { + Module["monitorRunDependencies"](runDependencies); + } + if (runDependencies == 0) { + if (dependenciesFulfilled) { + var callback = dependenciesFulfilled; + dependenciesFulfilled = null; + callback(); + } + } + } + Module["preloadedImages"] = {}; + Module["preloadedAudios"] = {}; + function abort(what) { + if (Module["onAbort"]) { + Module["onAbort"](what); + } + what += ""; + err(what); + ABORT = true; + what = "abort(" + what + "). Build with -s ASSERTIONS=1 for more info."; + var e = new WebAssembly.RuntimeError(what); + readyPromiseReject(e); + throw e; + } + var dataURIPrefix = "data:application/octet-stream;base64,"; + function isDataURI(filename) { + return filename.startsWith(dataURIPrefix); + } + var wasmBinaryFile = "data:application/octet-stream;base64,AGFzbQEAAAAB/wEkYAN/f38Bf2ABfwF/YAJ/fwF/YAF/AGAEf39/fwF/YAN/f38AYAV/f39/fwF/YAJ/fwBgBH9/f38AYAABf2AFf39/fn8BfmAEf35/fwF/YAR/f35/AX5gAn9+AX9gA398fwBgA39/fgF/YAF/AX5gBn9/f39/fwF/YAN/fn8Bf2AEf39/fwF+YAV/f35/fwF/YAR/f35/AX9gA39/fgF+YAJ/fgBgAn9/AX5gBX9/f39/AGADf35/AX5gBX5+f35/AX5gA39/fwF+YAZ/fH9/f38Bf2AAAGAHf35/f39+fwF/YAV/fn9/fwF/YAV/f39/fwF+YAJ+fwF/YAJ/fAACJQYBYQFhAAMBYQFiAAEBYQFjAAABYQFkAAEBYQFlAAIBYQFmAAED5wHlAQMAAwEDAwEHDAgDFgcNEgEDDRcFAQ8DEAUQAwIBAhgECxkEAQMBBQsFAwMDARACBAMAAggLBwEAAwADGgQDGwYGABwBBgMTFBEHBwcVCx4ABAgHBAICAgAfAQICAgIGFSAAIQAiAAIBBgIHAg0LEw0FAQUCACMDAQAUAAAGBQECBQUDCwsSAgEDBQIHAQEICAACCQQEAQABCAEBCQoBAwkBAQEBBgEGBgYABAIEBAQGEQQEAAARAAEDCQEJAQAJCQkBAQECCgoAAAMPAQEBAwACAgICBQIABwAKBgwHAAADAgICBQEEBQFwAT8/BQcBAYACgIACBgkBfwFBgInBAgsH+gEzAWcCAAFoAFQBaQDqAQFqALsBAWsAwQEBbACpAQFtAKgBAW4ApwEBbwClAQFwAKMBAXEAoAEBcgCbAQFzAMABAXQAugEBdQC5AQF2AEsBdwDiAQF4AMgBAXkAxwEBegDCAQFBAMkBAUIAuAEBQwAGAUQACQFFAKYBAUYAtwEBRwC2AQFIALUBAUkAtAEBSgCzAQFLALIBAUwAsQEBTQCwAQFOAK8BAU8AvAEBUACuAQFRAK0BAVIArAEBUwAaAVQACwFVAKQBAVYAMgFXAQABWACrAQFZAKoBAVoAxgEBXwDFAQEkAMQBAmFhAL8BAmJhAL4BAmNhAL0BCXgBAEEBCz6iAeMBjgGQAVpbjwFYnwGdAVeeAV1coQFZVlWcAZoBmQGYAZcBlgGVAZQBkwGSAZEB6QHoAecB5gHlAeQB4QHfAeAB3gHdAdwB2gHbAYUB2QHYAdcB1gHVAdQB0wHSAdEB0AHPAc4BzQHMAcsBygE4wwEK1N8G5QHMDAEHfwJAIABFDQAgAEEIayIDIABBBGsoAgAiAUF4cSIAaiEFAkAgAUEBcQ0AIAFBA3FFDQEgAyADKAIAIgFrIgNBxIQBKAIASQ0BIAAgAWohACADQciEASgCAEcEQCABQf8BTQRAIAMoAggiAiABQQN2IgRBA3RB3IQBakYaIAIgAygCDCIBRgRAQbSEAUG0hAEoAgBBfiAEd3E2AgAMAwsgAiABNgIMIAEgAjYCCAwCCyADKAIYIQYCQCADIAMoAgwiAUcEQCADKAIIIgIgATYCDCABIAI2AggMAQsCQCADQRRqIgIoAgAiBA0AIANBEGoiAigCACIEDQBBACEBDAELA0AgAiEHIAQiAUEUaiICKAIAIgQNACABQRBqIQIgASgCECIEDQALIAdBADYCAAsgBkUNAQJAIAMgAygCHCICQQJ0QeSGAWoiBCgCAEYEQCAEIAE2AgAgAQ0BQbiEAUG4hAEoAgBBfiACd3E2AgAMAwsgBkEQQRQgBigCECADRhtqIAE2AgAgAUUNAgsgASAGNgIYIAMoAhAiAgRAIAEgAjYCECACIAE2AhgLIAMoAhQiAkUNASABIAI2AhQgAiABNgIYDAELIAUoAgQiAUEDcUEDRw0AQbyEASAANgIAIAUgAUF+cTYCBCADIABBAXI2AgQgACADaiAANgIADwsgAyAFTw0AIAUoAgQiAUEBcUUNAAJAIAFBAnFFBEAgBUHMhAEoAgBGBEBBzIQBIAM2AgBBwIQBQcCEASgCACAAaiIANgIAIAMgAEEBcjYCBCADQciEASgCAEcNA0G8hAFBADYCAEHIhAFBADYCAA8LIAVByIQBKAIARgRAQciEASADNgIAQbyEAUG8hAEoAgAgAGoiADYCACADIABBAXI2AgQgACADaiAANgIADwsgAUF4cSAAaiEAAkAgAUH/AU0EQCAFKAIIIgIgAUEDdiIEQQN0QdyEAWpGGiACIAUoAgwiAUYEQEG0hAFBtIQBKAIAQX4gBHdxNgIADAILIAIgATYCDCABIAI2AggMAQsgBSgCGCEGAkAgBSAFKAIMIgFHBEAgBSgCCCICQcSEASgCAEkaIAIgATYCDCABIAI2AggMAQsCQCAFQRRqIgIoAgAiBA0AIAVBEGoiAigCACIEDQBBACEBDAELA0AgAiEHIAQiAUEUaiICKAIAIgQNACABQRBqIQIgASgCECIEDQALIAdBADYCAAsgBkUNAAJAIAUgBSgCHCICQQJ0QeSGAWoiBCgCAEYEQCAEIAE2AgAgAQ0BQbiEAUG4hAEoAgBBfiACd3E2AgAMAgsgBkEQQRQgBigCECAFRhtqIAE2AgAgAUUNAQsgASAGNgIYIAUoAhAiAgRAIAEgAjYCECACIAE2AhgLIAUoAhQiAkUNACABIAI2AhQgAiABNgIYCyADIABBAXI2AgQgACADaiAANgIAIANByIQBKAIARw0BQbyEASAANgIADwsgBSABQX5xNgIEIAMgAEEBcjYCBCAAIANqIAA2AgALIABB/wFNBEAgAEEDdiIBQQN0QdyEAWohAAJ/QbSEASgCACICQQEgAXQiAXFFBEBBtIQBIAEgAnI2AgAgAAwBCyAAKAIICyECIAAgAzYCCCACIAM2AgwgAyAANgIMIAMgAjYCCA8LQR8hAiADQgA3AhAgAEH///8HTQRAIABBCHYiASABQYD+P2pBEHZBCHEiAXQiAiACQYDgH2pBEHZBBHEiAnQiBCAEQYCAD2pBEHZBAnEiBHRBD3YgASACciAEcmsiAUEBdCAAIAFBFWp2QQFxckEcaiECCyADIAI2AhwgAkECdEHkhgFqIQECQAJAAkBBuIQBKAIAIgRBASACdCIHcUUEQEG4hAEgBCAHcjYCACABIAM2AgAgAyABNgIYDAELIABBAEEZIAJBAXZrIAJBH0YbdCECIAEoAgAhAQNAIAEiBCgCBEF4cSAARg0CIAJBHXYhASACQQF0IQIgBCABQQRxaiIHQRBqKAIAIgENAAsgByADNgIQIAMgBDYCGAsgAyADNgIMIAMgAzYCCAwBCyAEKAIIIgAgAzYCDCAEIAM2AgggA0EANgIYIAMgBDYCDCADIAA2AggLQdSEAUHUhAEoAgBBAWsiAEF/IAAbNgIACwuDBAEDfyACQYAETwRAIAAgASACEAIaIAAPCyAAIAJqIQMCQCAAIAFzQQNxRQRAAkAgAEEDcUUEQCAAIQIMAQsgAkEBSARAIAAhAgwBCyAAIQIDQCACIAEtAAA6AAAgAUEBaiEBIAJBAWoiAkEDcUUNASACIANJDQALCwJAIANBfHEiBEHAAEkNACACIARBQGoiBUsNAANAIAIgASgCADYCACACIAEoAgQ2AgQgAiABKAIINgIIIAIgASgCDDYCDCACIAEoAhA2AhAgAiABKAIUNgIUIAIgASgCGDYCGCACIAEoAhw2AhwgAiABKAIgNgIgIAIgASgCJDYCJCACIAEoAig2AiggAiABKAIsNgIsIAIgASgCMDYCMCACIAEoAjQ2AjQgAiABKAI4NgI4IAIgASgCPDYCPCABQUBrIQEgAkFAayICIAVNDQALCyACIARPDQEDQCACIAEoAgA2AgAgAUEEaiEBIAJBBGoiAiAESQ0ACwwBCyADQQRJBEAgACECDAELIAAgA0EEayIESwRAIAAhAgwBCyAAIQIDQCACIAEtAAA6AAAgAiABLQABOgABIAIgAS0AAjoAAiACIAEtAAM6AAMgAUEEaiEBIAJBBGoiAiAETQ0ACwsgAiADSQRAA0AgAiABLQAAOgAAIAFBAWohASACQQFqIgIgA0cNAAsLIAALGgAgAARAIAAtAAEEQCAAKAIEEAYLIAAQBgsLoi4BDH8jAEEQayIMJAACQAJAAkACQAJAAkACQAJAAkACQAJAAkAgAEH0AU0EQEG0hAEoAgAiBUEQIABBC2pBeHEgAEELSRsiCEEDdiICdiIBQQNxBEAgAUF/c0EBcSACaiIDQQN0IgFB5IQBaigCACIEQQhqIQACQCAEKAIIIgIgAUHchAFqIgFGBEBBtIQBIAVBfiADd3E2AgAMAQsgAiABNgIMIAEgAjYCCAsgBCADQQN0IgFBA3I2AgQgASAEaiIBIAEoAgRBAXI2AgQMDQsgCEG8hAEoAgAiCk0NASABBEACQEECIAJ0IgBBACAAa3IgASACdHEiAEEAIABrcUEBayIAIABBDHZBEHEiAnYiAUEFdkEIcSIAIAJyIAEgAHYiAUECdkEEcSIAciABIAB2IgFBAXZBAnEiAHIgASAAdiIBQQF2QQFxIgByIAEgAHZqIgNBA3QiAEHkhAFqKAIAIgQoAggiASAAQdyEAWoiAEYEQEG0hAEgBUF+IAN3cSIFNgIADAELIAEgADYCDCAAIAE2AggLIARBCGohACAEIAhBA3I2AgQgBCAIaiICIANBA3QiASAIayIDQQFyNgIEIAEgBGogAzYCACAKBEAgCkEDdiIBQQN0QdyEAWohB0HIhAEoAgAhBAJ/IAVBASABdCIBcUUEQEG0hAEgASAFcjYCACAHDAELIAcoAggLIQEgByAENgIIIAEgBDYCDCAEIAc2AgwgBCABNgIIC0HIhAEgAjYCAEG8hAEgAzYCAAwNC0G4hAEoAgAiBkUNASAGQQAgBmtxQQFrIgAgAEEMdkEQcSICdiIBQQV2QQhxIgAgAnIgASAAdiIBQQJ2QQRxIgByIAEgAHYiAUEBdkECcSIAciABIAB2IgFBAXZBAXEiAHIgASAAdmpBAnRB5IYBaigCACIBKAIEQXhxIAhrIQMgASECA0ACQCACKAIQIgBFBEAgAigCFCIARQ0BCyAAKAIEQXhxIAhrIgIgAyACIANJIgIbIQMgACABIAIbIQEgACECDAELCyABIAhqIgkgAU0NAiABKAIYIQsgASABKAIMIgRHBEAgASgCCCIAQcSEASgCAEkaIAAgBDYCDCAEIAA2AggMDAsgAUEUaiICKAIAIgBFBEAgASgCECIARQ0EIAFBEGohAgsDQCACIQcgACIEQRRqIgIoAgAiAA0AIARBEGohAiAEKAIQIgANAAsgB0EANgIADAsLQX8hCCAAQb9/Sw0AIABBC2oiAEF4cSEIQbiEASgCACIJRQ0AQQAgCGshAwJAAkACQAJ/QQAgCEGAAkkNABpBHyAIQf///wdLDQAaIABBCHYiACAAQYD+P2pBEHZBCHEiAnQiACAAQYDgH2pBEHZBBHEiAXQiACAAQYCAD2pBEHZBAnEiAHRBD3YgASACciAAcmsiAEEBdCAIIABBFWp2QQFxckEcagsiBUECdEHkhgFqKAIAIgJFBEBBACEADAELQQAhACAIQQBBGSAFQQF2ayAFQR9GG3QhAQNAAkAgAigCBEF4cSAIayIHIANPDQAgAiEEIAciAw0AQQAhAyACIQAMAwsgACACKAIUIgcgByACIAFBHXZBBHFqKAIQIgJGGyAAIAcbIQAgAUEBdCEBIAINAAsLIAAgBHJFBEBBAiAFdCIAQQAgAGtyIAlxIgBFDQMgAEEAIABrcUEBayIAIABBDHZBEHEiAnYiAUEFdkEIcSIAIAJyIAEgAHYiAUECdkEEcSIAciABIAB2IgFBAXZBAnEiAHIgASAAdiIBQQF2QQFxIgByIAEgAHZqQQJ0QeSGAWooAgAhAAsgAEUNAQsDQCAAKAIEQXhxIAhrIgEgA0khAiABIAMgAhshAyAAIAQgAhshBCAAKAIQIgEEfyABBSAAKAIUCyIADQALCyAERQ0AIANBvIQBKAIAIAhrTw0AIAQgCGoiBiAETQ0BIAQoAhghBSAEIAQoAgwiAUcEQCAEKAIIIgBBxIQBKAIASRogACABNgIMIAEgADYCCAwKCyAEQRRqIgIoAgAiAEUEQCAEKAIQIgBFDQQgBEEQaiECCwNAIAIhByAAIgFBFGoiAigCACIADQAgAUEQaiECIAEoAhAiAA0ACyAHQQA2AgAMCQsgCEG8hAEoAgAiAk0EQEHIhAEoAgAhAwJAIAIgCGsiAUEQTwRAQbyEASABNgIAQciEASADIAhqIgA2AgAgACABQQFyNgIEIAIgA2ogATYCACADIAhBA3I2AgQMAQtByIQBQQA2AgBBvIQBQQA2AgAgAyACQQNyNgIEIAIgA2oiACAAKAIEQQFyNgIECyADQQhqIQAMCwsgCEHAhAEoAgAiBkkEQEHAhAEgBiAIayIBNgIAQcyEAUHMhAEoAgAiAiAIaiIANgIAIAAgAUEBcjYCBCACIAhBA3I2AgQgAkEIaiEADAsLQQAhACAIQS9qIgkCf0GMiAEoAgAEQEGUiAEoAgAMAQtBmIgBQn83AgBBkIgBQoCggICAgAQ3AgBBjIgBIAxBDGpBcHFB2KrVqgVzNgIAQaCIAUEANgIAQfCHAUEANgIAQYAgCyIBaiIFQQAgAWsiB3EiAiAITQ0KQeyHASgCACIEBEBB5IcBKAIAIgMgAmoiASADTQ0LIAEgBEsNCwtB8IcBLQAAQQRxDQUCQAJAQcyEASgCACIDBEBB9IcBIQADQCADIAAoAgAiAU8EQCABIAAoAgRqIANLDQMLIAAoAggiAA0ACwtBABApIgFBf0YNBiACIQVBkIgBKAIAIgNBAWsiACABcQRAIAIgAWsgACABakEAIANrcWohBQsgBSAITQ0GIAVB/v///wdLDQZB7IcBKAIAIgQEQEHkhwEoAgAiAyAFaiIAIANNDQcgACAESw0HCyAFECkiACABRw0BDAgLIAUgBmsgB3EiBUH+////B0sNBSAFECkiASAAKAIAIAAoAgRqRg0EIAEhAAsCQCAAQX9GDQAgCEEwaiAFTQ0AQZSIASgCACIBIAkgBWtqQQAgAWtxIgFB/v///wdLBEAgACEBDAgLIAEQKUF/RwRAIAEgBWohBSAAIQEMCAtBACAFaxApGgwFCyAAIgFBf0cNBgwECwALQQAhBAwHC0EAIQEMBQsgAUF/Rw0CC0HwhwFB8IcBKAIAQQRyNgIACyACQf7///8HSw0BIAIQKSEBQQAQKSEAIAFBf0YNASAAQX9GDQEgACABTQ0BIAAgAWsiBSAIQShqTQ0BC0HkhwFB5IcBKAIAIAVqIgA2AgBB6IcBKAIAIABJBEBB6IcBIAA2AgALAkACQAJAQcyEASgCACIHBEBB9IcBIQADQCABIAAoAgAiAyAAKAIEIgJqRg0CIAAoAggiAA0ACwwCC0HEhAEoAgAiAEEAIAAgAU0bRQRAQcSEASABNgIAC0EAIQBB+IcBIAU2AgBB9IcBIAE2AgBB1IQBQX82AgBB2IQBQYyIASgCADYCAEGAiAFBADYCAANAIABBA3QiA0HkhAFqIANB3IQBaiICNgIAIANB6IQBaiACNgIAIABBAWoiAEEgRw0AC0HAhAEgBUEoayIDQXggAWtBB3FBACABQQhqQQdxGyIAayICNgIAQcyEASAAIAFqIgA2AgAgACACQQFyNgIEIAEgA2pBKDYCBEHQhAFBnIgBKAIANgIADAILIAAtAAxBCHENACADIAdLDQAgASAHTQ0AIAAgAiAFajYCBEHMhAEgB0F4IAdrQQdxQQAgB0EIakEHcRsiAGoiAjYCAEHAhAFBwIQBKAIAIAVqIgEgAGsiADYCACACIABBAXI2AgQgASAHakEoNgIEQdCEAUGciAEoAgA2AgAMAQtBxIQBKAIAIAFLBEBBxIQBIAE2AgALIAEgBWohAkH0hwEhAAJAAkACQAJAAkACQANAIAIgACgCAEcEQCAAKAIIIgANAQwCCwsgAC0ADEEIcUUNAQtB9IcBIQADQCAHIAAoAgAiAk8EQCACIAAoAgRqIgQgB0sNAwsgACgCCCEADAALAAsgACABNgIAIAAgACgCBCAFajYCBCABQXggAWtBB3FBACABQQhqQQdxG2oiCSAIQQNyNgIEIAJBeCACa0EHcUEAIAJBCGpBB3EbaiIFIAggCWoiBmshAiAFIAdGBEBBzIQBIAY2AgBBwIQBQcCEASgCACACaiIANgIAIAYgAEEBcjYCBAwDCyAFQciEASgCAEYEQEHIhAEgBjYCAEG8hAFBvIQBKAIAIAJqIgA2AgAgBiAAQQFyNgIEIAAgBmogADYCAAwDCyAFKAIEIgBBA3FBAUYEQCAAQXhxIQcCQCAAQf8BTQRAIAUoAggiAyAAQQN2IgBBA3RB3IQBakYaIAMgBSgCDCIBRgRAQbSEAUG0hAEoAgBBfiAAd3E2AgAMAgsgAyABNgIMIAEgAzYCCAwBCyAFKAIYIQgCQCAFIAUoAgwiAUcEQCAFKAIIIgAgATYCDCABIAA2AggMAQsCQCAFQRRqIgAoAgAiAw0AIAVBEGoiACgCACIDDQBBACEBDAELA0AgACEEIAMiAUEUaiIAKAIAIgMNACABQRBqIQAgASgCECIDDQALIARBADYCAAsgCEUNAAJAIAUgBSgCHCIDQQJ0QeSGAWoiACgCAEYEQCAAIAE2AgAgAQ0BQbiEAUG4hAEoAgBBfiADd3E2AgAMAgsgCEEQQRQgCCgCECAFRhtqIAE2AgAgAUUNAQsgASAINgIYIAUoAhAiAARAIAEgADYCECAAIAE2AhgLIAUoAhQiAEUNACABIAA2AhQgACABNgIYCyAFIAdqIQUgAiAHaiECCyAFIAUoAgRBfnE2AgQgBiACQQFyNgIEIAIgBmogAjYCACACQf8BTQRAIAJBA3YiAEEDdEHchAFqIQICf0G0hAEoAgAiAUEBIAB0IgBxRQRAQbSEASAAIAFyNgIAIAIMAQsgAigCCAshACACIAY2AgggACAGNgIMIAYgAjYCDCAGIAA2AggMAwtBHyEAIAJB////B00EQCACQQh2IgAgAEGA/j9qQRB2QQhxIgN0IgAgAEGA4B9qQRB2QQRxIgF0IgAgAEGAgA9qQRB2QQJxIgB0QQ92IAEgA3IgAHJrIgBBAXQgAiAAQRVqdkEBcXJBHGohAAsgBiAANgIcIAZCADcCECAAQQJ0QeSGAWohBAJAQbiEASgCACIDQQEgAHQiAXFFBEBBuIQBIAEgA3I2AgAgBCAGNgIAIAYgBDYCGAwBCyACQQBBGSAAQQF2ayAAQR9GG3QhACAEKAIAIQEDQCABIgMoAgRBeHEgAkYNAyAAQR12IQEgAEEBdCEAIAMgAUEEcWoiBCgCECIBDQALIAQgBjYCECAGIAM2AhgLIAYgBjYCDCAGIAY2AggMAgtBwIQBIAVBKGsiA0F4IAFrQQdxQQAgAUEIakEHcRsiAGsiAjYCAEHMhAEgACABaiIANgIAIAAgAkEBcjYCBCABIANqQSg2AgRB0IQBQZyIASgCADYCACAHIARBJyAEa0EHcUEAIARBJ2tBB3EbakEvayIAIAAgB0EQakkbIgJBGzYCBCACQfyHASkCADcCECACQfSHASkCADcCCEH8hwEgAkEIajYCAEH4hwEgBTYCAEH0hwEgATYCAEGAiAFBADYCACACQRhqIQADQCAAQQc2AgQgAEEIaiEBIABBBGohACABIARJDQALIAIgB0YNAyACIAIoAgRBfnE2AgQgByACIAdrIgRBAXI2AgQgAiAENgIAIARB/wFNBEAgBEEDdiIAQQN0QdyEAWohAgJ/QbSEASgCACIBQQEgAHQiAHFFBEBBtIQBIAAgAXI2AgAgAgwBCyACKAIICyEAIAIgBzYCCCAAIAc2AgwgByACNgIMIAcgADYCCAwEC0EfIQAgB0IANwIQIARB////B00EQCAEQQh2IgAgAEGA/j9qQRB2QQhxIgJ0IgAgAEGA4B9qQRB2QQRxIgF0IgAgAEGAgA9qQRB2QQJxIgB0QQ92IAEgAnIgAHJrIgBBAXQgBCAAQRVqdkEBcXJBHGohAAsgByAANgIcIABBAnRB5IYBaiEDAkBBuIQBKAIAIgJBASAAdCIBcUUEQEG4hAEgASACcjYCACADIAc2AgAgByADNgIYDAELIARBAEEZIABBAXZrIABBH0YbdCEAIAMoAgAhAQNAIAEiAigCBEF4cSAERg0EIABBHXYhASAAQQF0IQAgAiABQQRxaiIDKAIQIgENAAsgAyAHNgIQIAcgAjYCGAsgByAHNgIMIAcgBzYCCAwDCyADKAIIIgAgBjYCDCADIAY2AgggBkEANgIYIAYgAzYCDCAGIAA2AggLIAlBCGohAAwFCyACKAIIIgAgBzYCDCACIAc2AgggB0EANgIYIAcgAjYCDCAHIAA2AggLQcCEASgCACIAIAhNDQBBwIQBIAAgCGsiATYCAEHMhAFBzIQBKAIAIgIgCGoiADYCACAAIAFBAXI2AgQgAiAIQQNyNgIEIAJBCGohAAwDC0GEhAFBMDYCAEEAIQAMAgsCQCAFRQ0AAkAgBCgCHCICQQJ0QeSGAWoiACgCACAERgRAIAAgATYCACABDQFBuIQBIAlBfiACd3EiCTYCAAwCCyAFQRBBFCAFKAIQIARGG2ogATYCACABRQ0BCyABIAU2AhggBCgCECIABEAgASAANgIQIAAgATYCGAsgBCgCFCIARQ0AIAEgADYCFCAAIAE2AhgLAkAgA0EPTQRAIAQgAyAIaiIAQQNyNgIEIAAgBGoiACAAKAIEQQFyNgIEDAELIAQgCEEDcjYCBCAGIANBAXI2AgQgAyAGaiADNgIAIANB/wFNBEAgA0EDdiIAQQN0QdyEAWohAgJ/QbSEASgCACIBQQEgAHQiAHFFBEBBtIQBIAAgAXI2AgAgAgwBCyACKAIICyEAIAIgBjYCCCAAIAY2AgwgBiACNgIMIAYgADYCCAwBC0EfIQAgA0H///8HTQRAIANBCHYiACAAQYD+P2pBEHZBCHEiAnQiACAAQYDgH2pBEHZBBHEiAXQiACAAQYCAD2pBEHZBAnEiAHRBD3YgASACciAAcmsiAEEBdCADIABBFWp2QQFxckEcaiEACyAGIAA2AhwgBkIANwIQIABBAnRB5IYBaiECAkACQCAJQQEgAHQiAXFFBEBBuIQBIAEgCXI2AgAgAiAGNgIAIAYgAjYCGAwBCyADQQBBGSAAQQF2ayAAQR9GG3QhACACKAIAIQgDQCAIIgEoAgRBeHEgA0YNAiAAQR12IQIgAEEBdCEAIAEgAkEEcWoiAigCECIIDQALIAIgBjYCECAGIAE2AhgLIAYgBjYCDCAGIAY2AggMAQsgASgCCCIAIAY2AgwgASAGNgIIIAZBADYCGCAGIAE2AgwgBiAANgIICyAEQQhqIQAMAQsCQCALRQ0AAkAgASgCHCICQQJ0QeSGAWoiACgCACABRgRAIAAgBDYCACAEDQFBuIQBIAZBfiACd3E2AgAMAgsgC0EQQRQgCygCECABRhtqIAQ2AgAgBEUNAQsgBCALNgIYIAEoAhAiAARAIAQgADYCECAAIAQ2AhgLIAEoAhQiAEUNACAEIAA2AhQgACAENgIYCwJAIANBD00EQCABIAMgCGoiAEEDcjYCBCAAIAFqIgAgACgCBEEBcjYCBAwBCyABIAhBA3I2AgQgCSADQQFyNgIEIAMgCWogAzYCACAKBEAgCkEDdiIAQQN0QdyEAWohBEHIhAEoAgAhAgJ/QQEgAHQiACAFcUUEQEG0hAEgACAFcjYCACAEDAELIAQoAggLIQAgBCACNgIIIAAgAjYCDCACIAQ2AgwgAiAANgIIC0HIhAEgCTYCAEG8hAEgAzYCAAsgAUEIaiEACyAMQRBqJAAgAAuJAQEDfyAAKAIcIgEQMAJAIAAoAhAiAiABKAIQIgMgAiADSRsiAkUNACAAKAIMIAEoAgggAhAHGiAAIAAoAgwgAmo2AgwgASABKAIIIAJqNgIIIAAgACgCFCACajYCFCAAIAAoAhAgAms2AhAgASABKAIQIAJrIgA2AhAgAA0AIAEgASgCBDYCCAsLzgEBBX8CQCAARQ0AIAAoAjAiAQRAIAAgAUEBayIBNgIwIAENAQsgACgCIARAIABBATYCICAAEBoaCyAAKAIkQQFGBEAgABBDCwJAIAAoAiwiAUUNACAALQAoDQACQCABKAJEIgNFDQAgASgCTCEEA0AgACAEIAJBAnRqIgUoAgBHBEAgAyACQQFqIgJHDQEMAgsLIAUgBCADQQFrIgJBAnRqKAIANgIAIAEgAjYCRAsLIABBAEIAQQUQDhogACgCACIBBEAgARALCyAAEAYLC1oCAn4BfwJ/AkACQCAALQAARQ0AIAApAxAiAUJ9Vg0AIAFCAnwiAiAAKQMIWA0BCyAAQQA6AABBAAwBC0EAIAAoAgQiA0UNABogACACNwMQIAMgAadqLwAACwthAgJ+AX8CQAJAIAAtAABFDQAgACkDECICQn1WDQAgAkICfCIDIAApAwhYDQELIABBADoAAA8LIAAoAgQiBEUEQA8LIAAgAzcDECAEIAKnaiIAIAFBCHY6AAEgACABOgAAC8wCAQJ/IwBBEGsiBCQAAkAgACkDGCADrYinQQFxRQRAIABBDGoiAARAIABBADYCBCAAQRw2AgALQn8hAgwBCwJ+IAAoAgAiBUUEQCAAKAIIIAEgAiADIAAoAgQRDAAMAQsgBSAAKAIIIAEgAiADIAAoAgQRCgALIgJCf1UNAAJAIANBBGsOCwEAAAAAAAAAAAABAAsCQAJAIAAtABhBEHFFBEAgAEEMaiIBBEAgAUEANgIEIAFBHDYCAAsMAQsCfiAAKAIAIgFFBEAgACgCCCAEQQhqQghBBCAAKAIEEQwADAELIAEgACgCCCAEQQhqQghBBCAAKAIEEQoAC0J/VQ0BCyAAQQxqIgAEQCAAQQA2AgQgAEEUNgIACwwBCyAEKAIIIQEgBCgCDCEDIABBDGoiAARAIAAgAzYCBCAAIAE2AgALCyAEQRBqJAAgAguTFQIOfwN+AkACQAJAAkACQAJAAkACQAJAAkACQCAAKALwLQRAIAAoAogBQQFIDQEgACgCACIEKAIsQQJHDQQgAC8B5AENAyAALwHoAQ0DIAAvAewBDQMgAC8B8AENAyAALwH0AQ0DIAAvAfgBDQMgAC8B/AENAyAALwGcAg0DIAAvAaACDQMgAC8BpAINAyAALwGoAg0DIAAvAawCDQMgAC8BsAINAyAALwG0Ag0DIAAvAbgCDQMgAC8BvAINAyAALwHAAg0DIAAvAcQCDQMgAC8ByAINAyAALwHUAg0DIAAvAdgCDQMgAC8B3AINAyAALwHgAg0DIAAvAYgCDQIgAC8BjAINAiAALwGYAg0CQSAhBgNAIAAgBkECdCIFai8B5AENAyAAIAVBBHJqLwHkAQ0DIAAgBUEIcmovAeQBDQMgACAFQQxyai8B5AENAyAGQQRqIgZBgAJHDQALDAMLIABBBzYC/C0gAkF8Rw0FIAFFDQUMBgsgAkEFaiIEIQcMAwtBASEHCyAEIAc2AiwLIAAgAEHoFmoQUSAAIABB9BZqEFEgAC8B5gEhBCAAIABB7BZqKAIAIgxBAnRqQf//AzsB6gEgAEGQFmohECAAQZQWaiERIABBjBZqIQdBACEGIAxBAE4EQEEHQYoBIAQbIQ1BBEEDIAQbIQpBfyEJA0AgBCEIIAAgCyIOQQFqIgtBAnRqLwHmASEEAkACQCAGQQFqIgVB//8DcSIPIA1B//8DcU8NACAEIAhHDQAgBSEGDAELAn8gACAIQQJ0akHMFWogCkH//wNxIA9LDQAaIAgEQEEBIQUgByAIIAlGDQEaIAAgCEECdGpBzBVqIgYgBi8BAEEBajsBACAHDAELQQEhBSAQIBEgBkH//wNxQQpJGwsiBiAGLwEAIAVqOwEAQQAhBgJ/IARFBEBBAyEKQYoBDAELQQNBBCAEIAhGIgUbIQpBBkEHIAUbCyENIAghCQsgDCAORw0ACwsgAEHaE2ovAQAhBCAAIABB+BZqKAIAIgxBAnRqQd4TakH//wM7AQBBACEGIAxBAE4EQEEHQYoBIAQbIQ1BBEEDIAQbIQpBfyEJQQAhCwNAIAQhCCAAIAsiDkEBaiILQQJ0akHaE2ovAQAhBAJAAkAgBkEBaiIFQf//A3EiDyANQf//A3FPDQAgBCAIRw0AIAUhBgwBCwJ/IAAgCEECdGpBzBVqIApB//8DcSAPSw0AGiAIBEBBASEFIAcgCCAJRg0BGiAAIAhBAnRqQcwVaiIGIAYvAQBBAWo7AQAgBwwBC0EBIQUgECARIAZB//8DcUEKSRsLIgYgBi8BACAFajsBAEEAIQYCfyAERQRAQQMhCkGKAQwBC0EDQQQgBCAIRiIFGyEKQQZBByAFGwshDSAIIQkLIAwgDkcNAAsLIAAgAEGAF2oQUSAAIAAoAvgtAn9BEiAAQYoWai8BAA0AGkERIABB0hVqLwEADQAaQRAgAEGGFmovAQANABpBDyAAQdYVai8BAA0AGkEOIABBghZqLwEADQAaQQ0gAEHaFWovAQANABpBDCAAQf4Vai8BAA0AGkELIABB3hVqLwEADQAaQQogAEH6FWovAQANABpBCSAAQeIVai8BAA0AGkEIIABB9hVqLwEADQAaQQcgAEHmFWovAQANABpBBiAAQfIVai8BAA0AGkEFIABB6hVqLwEADQAaQQQgAEHuFWovAQANABpBA0ECIABBzhVqLwEAGwsiBkEDbGoiBEERajYC+C0gACgC/C1BCmpBA3YiByAEQRtqQQN2IgRNBEAgByEEDAELIAAoAowBQQRHDQAgByEECyAEIAJBBGpPQQAgARsNASAEIAdHDQQLIANBAmqtIRIgACkDmC4hFCAAKAKgLiIBQQNqIgdBP0sNASASIAGthiAUhCESDAILIAAgASACIAMQOQwDCyABQcAARgRAIAAoAgQgACgCEGogFDcAACAAIAAoAhBBCGo2AhBBAyEHDAELIAAoAgQgACgCEGogEiABrYYgFIQ3AAAgACAAKAIQQQhqNgIQIAFBPWshByASQcAAIAFrrYghEgsgACASNwOYLiAAIAc2AqAuIABBgMEAQYDKABCHAQwBCyADQQRqrSESIAApA5guIRQCQCAAKAKgLiIBQQNqIgRBP00EQCASIAGthiAUhCESDAELIAFBwABGBEAgACgCBCAAKAIQaiAUNwAAIAAgACgCEEEIajYCEEEDIQQMAQsgACgCBCAAKAIQaiASIAGthiAUhDcAACAAIAAoAhBBCGo2AhAgAUE9ayEEIBJBwAAgAWutiCESCyAAIBI3A5guIAAgBDYCoC4gAEHsFmooAgAiC6xCgAJ9IRMgAEH4FmooAgAhCQJAAkACfwJ+AkACfwJ/IARBOk0EQCATIASthiAShCETIARBBWoMAQsgBEHAAEYEQCAAKAIEIAAoAhBqIBI3AAAgACAAKAIQQQhqNgIQIAmsIRJCBSEUQQoMAgsgACgCBCAAKAIQaiATIASthiAShDcAACAAIAAoAhBBCGo2AhAgE0HAACAEa62IIRMgBEE7awshBSAJrCESIAVBOksNASAFrSEUIAVBBWoLIQcgEiAUhiAThAwBCyAFQcAARgRAIAAoAgQgACgCEGogEzcAACAAIAAoAhBBCGo2AhAgBq1CA30hE0IFIRRBCQwCCyAAKAIEIAAoAhBqIBIgBa2GIBOENwAAIAAgACgCEEEIajYCECAFQTtrIQcgEkHAACAFa62ICyESIAatQgN9IRMgB0E7Sw0BIAetIRQgB0EEagshBCATIBSGIBKEIRMMAQsgB0HAAEYEQCAAKAIEIAAoAhBqIBI3AAAgACAAKAIQQQhqNgIQQQQhBAwBCyAAKAIEIAAoAhBqIBMgB62GIBKENwAAIAAgACgCEEEIajYCECAHQTxrIQQgE0HAACAHa62IIRMLQQAhBQNAIAAgBSIBQZDWAGotAABBAnRqQc4VajMBACEUAn8gBEE8TQRAIBQgBK2GIBOEIRMgBEEDagwBCyAEQcAARgRAIAAoAgQgACgCEGogEzcAACAAIAAoAhBBCGo2AhAgFCETQQMMAQsgACgCBCAAKAIQaiAUIASthiAThDcAACAAIAAoAhBBCGo2AhAgFEHAACAEa62IIRMgBEE9awshBCABQQFqIQUgASAGRw0ACyAAIAQ2AqAuIAAgEzcDmC4gACAAQeQBaiICIAsQhgEgACAAQdgTaiIBIAkQhgEgACACIAEQhwELIAAQiAEgAwRAAkAgACgCoC4iBEE5TgRAIAAoAgQgACgCEGogACkDmC43AAAgACAAKAIQQQhqNgIQDAELIARBGU4EQCAAKAIEIAAoAhBqIAApA5guPgAAIAAgAEGcLmo1AgA3A5guIAAgACgCEEEEajYCECAAIAAoAqAuQSBrIgQ2AqAuCyAEQQlOBH8gACgCBCAAKAIQaiAAKQOYLj0AACAAIAAoAhBBAmo2AhAgACAAKQOYLkIQiDcDmC4gACgCoC5BEGsFIAQLQQFIDQAgACAAKAIQIgFBAWo2AhAgASAAKAIEaiAAKQOYLjwAAAsgAEEANgKgLiAAQgA3A5guCwsZACAABEAgACgCABAGIAAoAgwQBiAAEAYLC6wBAQJ+Qn8hAwJAIAAtACgNAAJAAkAgACgCIEUNACACQgBTDQAgAlANASABDQELIABBDGoiAARAIABBADYCBCAAQRI2AgALQn8PCyAALQA1DQBCACEDIAAtADQNACACUA0AA0AgACABIAOnaiACIAN9QQEQDiIEQn9XBEAgAEEBOgA1Qn8gAyADUBsPCyAEUEUEQCADIAR8IgMgAloNAgwBCwsgAEEBOgA0CyADC3UCAn4BfwJAAkAgAC0AAEUNACAAKQMQIgJCe1YNACACQgR8IgMgACkDCFgNAQsgAEEAOgAADwsgACgCBCIERQRADwsgACADNwMQIAQgAqdqIgAgAUEYdjoAAyAAIAFBEHY6AAIgACABQQh2OgABIAAgAToAAAtUAgF+AX8CQAJAIAAtAABFDQAgASAAKQMQIgF8IgIgAVQNACACIAApAwhYDQELIABBADoAAEEADwsgACgCBCIDRQRAQQAPCyAAIAI3AxAgAyABp2oLdwECfyMAQRBrIgMkAEF/IQQCQCAALQAoDQAgACgCIEEAIAJBA0kbRQRAIABBDGoiAARAIABBADYCBCAAQRI2AgALDAELIAMgAjYCCCADIAE3AwAgACADQhBBBhAOQgBTDQBBACEEIABBADoANAsgA0EQaiQAIAQLVwICfgF/AkACQCAALQAARQ0AIAApAxAiAUJ7Vg0AIAFCBHwiAiAAKQMIWA0BCyAAQQA6AABBAA8LIAAoAgQiA0UEQEEADwsgACACNwMQIAMgAadqKAAAC1UCAX4BfyAABEACQCAAKQMIUA0AQgEhAQNAIAAoAgAgAkEEdGoQPiABIAApAwhaDQEgAachAiABQgF8IQEMAAsACyAAKAIAEAYgACgCKBAQIAAQBgsLZAECfwJAAkACQCAARQRAIAGnEAkiA0UNAkEYEAkiAkUNAQwDCyAAIQNBGBAJIgINAkEADwsgAxAGC0EADwsgAkIANwMQIAIgATcDCCACIAM2AgQgAkEBOgAAIAIgAEU6AAEgAgudAQICfgF/AkACQCAALQAARQ0AIAApAxAiAkJ3Vg0AIAJCCHwiAyAAKQMIWA0BCyAAQQA6AAAPCyAAKAIEIgRFBEAPCyAAIAM3AxAgBCACp2oiACABQjiIPAAHIAAgAUIwiDwABiAAIAFCKIg8AAUgACABQiCIPAAEIAAgAUIYiDwAAyAAIAFCEIg8AAIgACABQgiIPAABIAAgATwAAAvwAgICfwF+AkAgAkUNACAAIAJqIgNBAWsgAToAACAAIAE6AAAgAkEDSQ0AIANBAmsgAToAACAAIAE6AAEgA0EDayABOgAAIAAgAToAAiACQQdJDQAgA0EEayABOgAAIAAgAToAAyACQQlJDQAgAEEAIABrQQNxIgRqIgMgAUH/AXFBgYKECGwiADYCACADIAIgBGtBfHEiAmoiAUEEayAANgIAIAJBCUkNACADIAA2AgggAyAANgIEIAFBCGsgADYCACABQQxrIAA2AgAgAkEZSQ0AIAMgADYCGCADIAA2AhQgAyAANgIQIAMgADYCDCABQRBrIAA2AgAgAUEUayAANgIAIAFBGGsgADYCACABQRxrIAA2AgAgAiADQQRxQRhyIgFrIgJBIEkNACAArUKBgICAEH4hBSABIANqIQEDQCABIAU3AxggASAFNwMQIAEgBTcDCCABIAU3AwAgAUEgaiEBIAJBIGsiAkEfSw0ACwsLbwEDfyAAQQxqIQICQAJ/IAAoAiAiAUUEQEF/IQFBEgwBCyAAIAFBAWsiAzYCIEEAIQEgAw0BIABBAEIAQQIQDhogACgCACIARQ0BIAAQGkF/Sg0BQRQLIQAgAgRAIAJBADYCBCACIAA2AgALCyABC58BAgF/AX4CfwJAAn4gACgCACIDKAIkQQFGQQAgAkJ/VRtFBEAgA0EMaiIBBEAgAUEANgIEIAFBEjYCAAtCfwwBCyADIAEgAkELEA4LIgRCf1cEQCAAKAIAIQEgAEEIaiIABEAgACABKAIMNgIAIAAgASgCEDYCBAsMAQtBACACIARRDQEaIABBCGoEQCAAQRs2AgwgAEEGNgIICwtBfwsLJAEBfyAABEADQCAAKAIAIQEgACgCDBAGIAAQBiABIgANAAsLC5gBAgJ+AX8CQAJAIAAtAABFDQAgACkDECIBQndWDQAgAUIIfCICIAApAwhYDQELIABBADoAAEIADwsgACgCBCIDRQRAQgAPCyAAIAI3AxAgAyABp2oiADEABkIwhiAAMQAHQjiGhCAAMQAFQiiGhCAAMQAEQiCGhCAAMQADQhiGhCAAMQACQhCGhCAAMQABQgiGhCAAMQAAfAsjACAAQShGBEAgAhAGDwsgAgRAIAEgAkEEaygCACAAEQcACwsyACAAKAIkQQFHBEAgAEEMaiIABEAgAEEANgIEIABBEjYCAAtCfw8LIABBAEIAQQ0QDgsPACAABEAgABA2IAAQBgsLgAEBAX8gAC0AKAR/QX8FIAFFBEAgAEEMagRAIABBADYCECAAQRI2AgwLQX8PCyABECoCQCAAKAIAIgJFDQAgAiABECFBf0oNACAAKAIAIQEgAEEMaiIABEAgACABKAIMNgIAIAAgASgCEDYCBAtBfw8LIAAgAUI4QQMQDkI/h6cLC38BA38gACEBAkAgAEEDcQRAA0AgAS0AAEUNAiABQQFqIgFBA3ENAAsLA0AgASICQQRqIQEgAigCACIDQX9zIANBgYKECGtxQYCBgoR4cUUNAAsgA0H/AXFFBEAgAiAAaw8LA0AgAi0AASEDIAJBAWoiASECIAMNAAsLIAEgAGsL3wIBCH8gAEUEQEEBDwsCQCAAKAIIIgINAEEBIQQgAC8BBCIHRQRAQQEhAgwBCyAAKAIAIQgDQAJAIAMgCGoiBS0AACICQSBPBEAgAkEYdEEYdUF/Sg0BCyACQQ1NQQBBASACdEGAzABxGw0AAn8CfyACQeABcUHAAUYEQEEBIQYgA0EBagwBCyACQfABcUHgAUYEQCADQQJqIQNBACEGQQEMAgsgAkH4AXFB8AFHBEBBBCECDAULQQAhBiADQQNqCyEDQQALIQlBBCECIAMgB08NAiAFLQABQcABcUGAAUcNAkEDIQQgBg0AIAUtAAJBwAFxQYABRw0CIAkNACAFLQADQcABcUGAAUcNAgsgBCECIANBAWoiAyAHSQ0ACwsgACACNgIIAn8CQCABRQ0AAkAgAUECRw0AIAJBA0cNAEECIQIgAEECNgIICyABIAJGDQBBBSACQQFHDQEaCyACCwtIAgJ+An8jAEEQayIEIAE2AgxCASAArYYhAgNAIAQgAUEEaiIANgIMIAIiA0IBIAEoAgAiBa2GhCECIAAhASAFQX9KDQALIAMLhwUBB38CQAJAIABFBEBBxRQhAiABRQ0BIAFBADYCAEHFFA8LIAJBwABxDQEgACgCCEUEQCAAQQAQIxoLIAAoAgghBAJAIAJBgAFxBEAgBEEBa0ECTw0BDAMLIARBBEcNAgsCQCAAKAIMIgINACAAAn8gACgCACEIIABBEGohCUEAIQICQAJAAkACQCAALwEEIgUEQEEBIQQgBUEBcSEHIAVBAUcNAQwCCyAJRQ0CIAlBADYCAEEADAQLIAVBfnEhBgNAIARBAUECQQMgAiAIai0AAEEBdEHQFGovAQAiCkGAEEkbIApBgAFJG2pBAUECQQMgCCACQQFyai0AAEEBdEHQFGovAQAiBEGAEEkbIARBgAFJG2ohBCACQQJqIQIgBkECayIGDQALCwJ/IAcEQCAEQQFBAkEDIAIgCGotAABBAXRB0BRqLwEAIgJBgBBJGyACQYABSRtqIQQLIAQLEAkiB0UNASAFQQEgBUEBSxshCkEAIQVBACEGA0AgBSAHaiEDAn8gBiAIai0AAEEBdEHQFGovAQAiAkH/AE0EQCADIAI6AAAgBUEBagwBCyACQf8PTQRAIAMgAkE/cUGAAXI6AAEgAyACQQZ2QcABcjoAACAFQQJqDAELIAMgAkE/cUGAAXI6AAIgAyACQQx2QeABcjoAACADIAJBBnZBP3FBgAFyOgABIAVBA2oLIQUgBkEBaiIGIApHDQALIAcgBEEBayICakEAOgAAIAlFDQAgCSACNgIACyAHDAELIAMEQCADQQA2AgQgA0EONgIAC0EACyICNgIMIAINAEEADwsgAUUNACABIAAoAhA2AgALIAIPCyABBEAgASAALwEENgIACyAAKAIAC4MBAQR/QRIhBQJAAkAgACkDMCABWA0AIAGnIQYgACgCQCEEIAJBCHEiB0UEQCAEIAZBBHRqKAIEIgINAgsgBCAGQQR0aiIEKAIAIgJFDQAgBC0ADEUNAUEXIQUgBw0BC0EAIQIgAyAAQQhqIAMbIgAEQCAAQQA2AgQgACAFNgIACwsgAgtuAQF/IwBBgAJrIgUkAAJAIARBgMAEcQ0AIAIgA0wNACAFIAFB/wFxIAIgA2siAkGAAiACQYACSSIBGxAZIAFFBEADQCAAIAVBgAIQLiACQYACayICQf8BSw0ACwsgACAFIAIQLgsgBUGAAmokAAuBAQEBfyMAQRBrIgQkACACIANsIQICQCAAQSdGBEAgBEEMaiACEIwBIQBBACAEKAIMIAAbIQAMAQsgAUEBIAJBxABqIAARAAAiAUUEQEEAIQAMAQtBwAAgAUE/cWsiACABakHAAEEAIABBBEkbaiIAQQRrIAE2AAALIARBEGokACAAC1IBAn9BhIEBKAIAIgEgAEEDakF8cSICaiEAAkAgAkEAIAAgAU0bDQAgAD8AQRB0SwRAIAAQA0UNAQtBhIEBIAA2AgAgAQ8LQYSEAUEwNgIAQX8LNwAgAEJ/NwMQIABBADYCCCAAQgA3AwAgAEEANgIwIABC/////w83AyggAEIANwMYIABCADcDIAulAQEBf0HYABAJIgFFBEBBAA8LAkAgAARAIAEgAEHYABAHGgwBCyABQgA3AyAgAUEANgIYIAFC/////w83AxAgAUEAOwEMIAFBv4YoNgIIIAFBAToABiABQQA6AAQgAUIANwNIIAFBgIDYjXg2AkQgAUIANwMoIAFCADcDMCABQgA3AzggAUFAa0EAOwEAIAFCADcDUAsgAUEBOgAFIAFBADYCACABC1gCAn4BfwJAAkAgAC0AAEUNACAAKQMQIgMgAq18IgQgA1QNACAEIAApAwhYDQELIABBADoAAA8LIAAoAgQiBUUEQA8LIAAgBDcDECAFIAOnaiABIAIQBxoLlgEBAn8CQAJAIAJFBEAgAacQCSIFRQ0BQRgQCSIEDQIgBRAGDAELIAIhBUEYEAkiBA0BCyADBEAgA0EANgIEIANBDjYCAAtBAA8LIARCADcDECAEIAE3AwggBCAFNgIEIARBAToAACAEIAJFOgABIAAgBSABIAMQZUEASAR/IAQtAAEEQCAEKAIEEAYLIAQQBkEABSAECwubAgEDfyAALQAAQSBxRQRAAkAgASEDAkAgAiAAIgEoAhAiAAR/IAAFAn8gASABLQBKIgBBAWsgAHI6AEogASgCACIAQQhxBEAgASAAQSByNgIAQX8MAQsgAUIANwIEIAEgASgCLCIANgIcIAEgADYCFCABIAAgASgCMGo2AhBBAAsNASABKAIQCyABKAIUIgVrSwRAIAEgAyACIAEoAiQRAAAaDAILAn8gASwAS0F/SgRAIAIhAANAIAIgACIERQ0CGiADIARBAWsiAGotAABBCkcNAAsgASADIAQgASgCJBEAACAESQ0CIAMgBGohAyABKAIUIQUgAiAEawwBCyACCyEAIAUgAyAAEAcaIAEgASgCFCAAajYCFAsLCwvNBQEGfyAAKAIwIgNBhgJrIQYgACgCPCECIAMhAQNAIAAoAkQgAiAAKAJoIgRqayECIAEgBmogBE0EQCAAKAJIIgEgASADaiADEAcaAkAgAyAAKAJsIgFNBEAgACABIANrNgJsDAELIABCADcCbAsgACAAKAJoIANrIgE2AmggACAAKAJYIANrNgJYIAEgACgChC5JBEAgACABNgKELgsgAEH8gAEoAgARAwAgAiADaiECCwJAIAAoAgAiASgCBCIERQ0AIAAoAjwhBSAAIAIgBCACIARJGyICBH8gACgCSCAAKAJoaiAFaiEFIAEgBCACazYCBAJAAkACQAJAIAEoAhwiBCgCFEEBaw4CAQACCyAEQaABaiAFIAEoAgAgAkHcgAEoAgARCAAMAgsgASABKAIwIAUgASgCACACQcSAASgCABEEADYCMAwBCyAFIAEoAgAgAhAHGgsgASABKAIAIAJqNgIAIAEgASgCCCACajYCCCAAKAI8BSAFCyACaiICNgI8AkAgACgChC4iASACakEDSQ0AIAAoAmggAWshAQJAIAAoAnRBgQhPBEAgACAAIAAoAkggAWoiAi0AACACLQABIAAoAnwRAAA2AlQMAQsgAUUNACAAIAFBAWsgACgChAERAgAaCyAAKAKELiAAKAI8IgJBAUZrIgRFDQAgACABIAQgACgCgAERBQAgACAAKAKELiAEazYChC4gACgCPCECCyACQYUCSw0AIAAoAgAoAgRFDQAgACgCMCEBDAELCwJAIAAoAkQiAiAAKAJAIgNNDQAgAAJ/IAAoAjwgACgCaGoiASADSwRAIAAoAkggAWpBACACIAFrIgNBggIgA0GCAkkbIgMQGSABIANqDAELIAFBggJqIgEgA00NASAAKAJIIANqQQAgAiADayICIAEgA2siAyACIANJGyIDEBkgACgCQCADags2AkALC50CAQF/AkAgAAJ/IAAoAqAuIgFBwABGBEAgACgCBCAAKAIQaiAAKQOYLjcAACAAQgA3A5guIAAgACgCEEEIajYCEEEADAELIAFBIE4EQCAAKAIEIAAoAhBqIAApA5guPgAAIAAgAEGcLmo1AgA3A5guIAAgACgCEEEEajYCECAAIAAoAqAuQSBrIgE2AqAuCyABQRBOBEAgACgCBCAAKAIQaiAAKQOYLj0AACAAIAAoAhBBAmo2AhAgACAAKQOYLkIQiDcDmC4gACAAKAKgLkEQayIBNgKgLgsgAUEISA0BIAAgACgCECIBQQFqNgIQIAEgACgCBGogACkDmC48AAAgACAAKQOYLkIIiDcDmC4gACgCoC5BCGsLNgKgLgsLEAAgACgCCBAGIABBADYCCAvwAQECf0F/IQECQCAALQAoDQAgACgCJEEDRgRAIABBDGoEQCAAQQA2AhAgAEEXNgIMC0F/DwsCQCAAKAIgBEAgACkDGELAAINCAFINASAAQQxqBEAgAEEANgIQIABBHTYCDAtBfw8LAkAgACgCACICRQ0AIAIQMkF/Sg0AIAAoAgAhASAAQQxqIgAEQCAAIAEoAgw2AgAgACABKAIQNgIEC0F/DwsgAEEAQgBBABAOQn9VDQAgACgCACIARQ0BIAAQGhpBfw8LQQAhASAAQQA7ATQgAEEMagRAIABCADcCDAsgACAAKAIgQQFqNgIgCyABCzsAIAAtACgEfkJ/BSAAKAIgRQRAIABBDGoiAARAIABBADYCBCAAQRI2AgALQn8PCyAAQQBCAEEHEA4LC5oIAQt/IABFBEAgARAJDwsgAUFATwRAQYSEAUEwNgIAQQAPCwJ/QRAgAUELakF4cSABQQtJGyEGIABBCGsiBSgCBCIJQXhxIQQCQCAJQQNxRQRAQQAgBkGAAkkNAhogBkEEaiAETQRAIAUhAiAEIAZrQZSIASgCAEEBdE0NAgtBAAwCCyAEIAVqIQcCQCAEIAZPBEAgBCAGayIDQRBJDQEgBSAJQQFxIAZyQQJyNgIEIAUgBmoiAiADQQNyNgIEIAcgBygCBEEBcjYCBCACIAMQOwwBCyAHQcyEASgCAEYEQEHAhAEoAgAgBGoiBCAGTQ0CIAUgCUEBcSAGckECcjYCBCAFIAZqIgMgBCAGayICQQFyNgIEQcCEASACNgIAQcyEASADNgIADAELIAdByIQBKAIARgRAQbyEASgCACAEaiIDIAZJDQICQCADIAZrIgJBEE8EQCAFIAlBAXEgBnJBAnI2AgQgBSAGaiIEIAJBAXI2AgQgAyAFaiIDIAI2AgAgAyADKAIEQX5xNgIEDAELIAUgCUEBcSADckECcjYCBCADIAVqIgIgAigCBEEBcjYCBEEAIQJBACEEC0HIhAEgBDYCAEG8hAEgAjYCAAwBCyAHKAIEIgNBAnENASADQXhxIARqIgogBkkNASAKIAZrIQwCQCADQf8BTQRAIAcoAggiBCADQQN2IgJBA3RB3IQBakYaIAQgBygCDCIDRgRAQbSEAUG0hAEoAgBBfiACd3E2AgAMAgsgBCADNgIMIAMgBDYCCAwBCyAHKAIYIQsCQCAHIAcoAgwiCEcEQCAHKAIIIgJBxIQBKAIASRogAiAINgIMIAggAjYCCAwBCwJAIAdBFGoiBCgCACICDQAgB0EQaiIEKAIAIgINAEEAIQgMAQsDQCAEIQMgAiIIQRRqIgQoAgAiAg0AIAhBEGohBCAIKAIQIgINAAsgA0EANgIACyALRQ0AAkAgByAHKAIcIgNBAnRB5IYBaiICKAIARgRAIAIgCDYCACAIDQFBuIQBQbiEASgCAEF+IAN3cTYCAAwCCyALQRBBFCALKAIQIAdGG2ogCDYCACAIRQ0BCyAIIAs2AhggBygCECICBEAgCCACNgIQIAIgCDYCGAsgBygCFCICRQ0AIAggAjYCFCACIAg2AhgLIAxBD00EQCAFIAlBAXEgCnJBAnI2AgQgBSAKaiICIAIoAgRBAXI2AgQMAQsgBSAJQQFxIAZyQQJyNgIEIAUgBmoiAyAMQQNyNgIEIAUgCmoiAiACKAIEQQFyNgIEIAMgDBA7CyAFIQILIAILIgIEQCACQQhqDwsgARAJIgVFBEBBAA8LIAUgAEF8QXggAEEEaygCACICQQNxGyACQXhxaiICIAEgASACSxsQBxogABAGIAUL6QEBA38CQCABRQ0AIAJBgDBxIgIEfwJ/IAJBgCBHBEBBAiACQYAQRg0BGiADBEAgA0EANgIEIANBEjYCAAtBAA8LQQQLIQJBAAVBAQshBkEUEAkiBEUEQCADBEAgA0EANgIEIANBDjYCAAtBAA8LIAQgAUEBahAJIgU2AgAgBUUEQCAEEAZBAA8LIAUgACABEAcgAWpBADoAACAEQQA2AhAgBEIANwMIIAQgATsBBCAGDQAgBCACECNBBUcNACAEKAIAEAYgBCgCDBAGIAQQBkEAIQQgAwRAIANBADYCBCADQRI2AgALCyAEC7UBAQJ/AkACQAJAAkACQAJAAkAgAC0ABQRAIAAtAABBAnFFDQELIAAoAjAQECAAQQA2AjAgAC0ABUUNAQsgAC0AAEEIcUUNAQsgACgCNBAcIABBADYCNCAALQAFRQ0BCyAALQAAQQRxRQ0BCyAAKAI4EBAgAEEANgI4IAAtAAVFDQELIAAtAABBgAFxRQ0BCyAAKAJUIgEEfyABQQAgARAiEBkgACgCVAVBAAsQBiAAQQA2AlQLC9wMAgl/AX4jAEFAaiIGJAACQAJAAkACQAJAIAEoAjBBABAjIgVBAkZBACABKAI4QQAQIyIEQQFGGw0AIAVBAUZBACAEQQJGGw0AIAVBAkciAw0BIARBAkcNAQsgASABLwEMQYAQcjsBDEEAIQMMAQsgASABLwEMQf/vA3E7AQxBACEFIANFBEBB9eABIAEoAjAgAEEIahBpIgVFDQILIAJBgAJxBEAgBSEDDAELIARBAkcEQCAFIQMMAQtB9cYBIAEoAjggAEEIahBpIgNFBEAgBRAcDAILIAMgBTYCAAsgASABLwEMQf7/A3EgAS8BUiIFQQBHcjsBDAJAAkACQAJAAn8CQAJAIAEpAyhC/v///w9WDQAgASkDIEL+////D1YNACACQYAEcUUNASABKQNIQv////8PVA0BCyAFQYECa0H//wNxQQNJIQdBAQwBCyAFQYECa0H//wNxIQQgAkGACnFBgApHDQEgBEEDSSEHQQALIQkgBkIcEBciBEUEQCAAQQhqIgAEQCAAQQA2AgQgAEEONgIACyADEBwMBQsgAkGACHEhBQJAAkAgAkGAAnEEQAJAIAUNACABKQMgQv////8PVg0AIAEpAyhCgICAgBBUDQMLIAQgASkDKBAYIAEpAyAhDAwBCwJAAkACQCAFDQAgASkDIEL/////D1YNACABKQMoIgxC/////w9WDQEgASkDSEKAgICAEFQNBAsgASkDKCIMQv////8PVA0BCyAEIAwQGAsgASkDICIMQv////8PWgRAIAQgDBAYCyABKQNIIgxC/////w9UDQELIAQgDBAYCyAELQAARQRAIABBCGoiAARAIABBADYCBCAAQRQ2AgALIAQQCCADEBwMBQtBASEKQQEgBC0AAAR+IAQpAxAFQgALp0H//wNxIAYQRyEFIAQQCCAFIAM2AgAgBw0BDAILIAMhBSAEQQJLDQELIAZCBxAXIgRFBEAgAEEIaiIABEAgAEEANgIEIABBDjYCAAsgBRAcDAMLIARBAhANIARBhxJBAhAsIAQgAS0AUhBwIAQgAS8BEBANIAQtAABFBEAgAEEIaiIABEAgAEEANgIEIABBFDYCAAsgBBAIDAILQYGyAkEHIAYQRyEDIAQQCCADIAU2AgBBASELIAMhBQsgBkIuEBciA0UEQCAAQQhqIgAEQCAAQQA2AgQgAEEONgIACyAFEBwMAgsgA0GjEkGoEiACQYACcSIHG0EEECwgB0UEQCADIAkEf0EtBSABLwEIC0H//wNxEA0LIAMgCQR/QS0FIAEvAQoLQf//A3EQDSADIAEvAQwQDSADIAsEf0HjAAUgASgCEAtB//8DcRANIAYgASgCFDYCPAJ/IAZBPGoQjQEiCEUEQEEAIQlBIQwBCwJ/IAgoAhQiBEHQAE4EQCAEQQl0DAELIAhB0AA2AhRBgMACCyEEIAgoAgRBBXQgCCgCCEELdGogCCgCAEEBdmohCSAIKAIMIAQgCCgCEEEFdGpqQaDAAWoLIQQgAyAJQf//A3EQDSADIARB//8DcRANIAMCfyALBEBBACABKQMoQhRUDQEaCyABKAIYCxASIAEpAyAhDCADAn8gAwJ/AkAgBwRAIAxC/v///w9YBEAgASkDKEL/////D1QNAgsgA0F/EBJBfwwDC0F/IAxC/v///w9WDQEaCyAMpwsQEiABKQMoIgxC/////w8gDEL/////D1QbpwsQEiADIAEoAjAiBAR/IAQvAQQFQQALQf//A3EQDSADIAEoAjQgAhBsIAVBgAYQbGpB//8DcRANIAdFBEAgAyABKAI4IgQEfyAELwEEBUEAC0H//wNxEA0gAyABLwE8EA0gAyABLwFAEA0gAyABKAJEEBIgAyABKQNIIgxC/////w8gDEL/////D1QbpxASCyADLQAARQRAIABBCGoiAARAIABBADYCBCAAQRQ2AgALIAMQCCAFEBwMAgsgACAGIAMtAAAEfiADKQMQBUIACxAbIQQgAxAIIARBf0wNACABKAIwIgMEQCAAIAMQYUF/TA0BCyAFBEAgACAFQYAGEGtBf0wNAQsgBRAcIAEoAjQiBQRAIAAgBSACEGtBAEgNAgsgBw0CIAEoAjgiAUUNAiAAIAEQYUEATg0CDAELIAUQHAtBfyEKCyAGQUBrJAAgCgtNAQJ/IAEtAAAhAgJAIAAtAAAiA0UNACACIANHDQADQCABLQABIQIgAC0AASIDRQ0BIAFBAWohASAAQQFqIQAgAiADRg0ACwsgAyACawvcAwICfgF/IAOtIQQgACkDmC4hBQJAIAACfyAAAn4gACgCoC4iBkEDaiIDQT9NBEAgBCAGrYYgBYQMAQsgBkHAAEYEQCAAKAIEIAAoAhBqIAU3AAAgACgCEEEIagwCCyAAKAIEIAAoAhBqIAQgBq2GIAWENwAAIAAgACgCEEEIajYCECAGQT1rIQMgBEHAACAGa62ICyIENwOYLiAAIAM2AqAuIANBOU4EQCAAKAIEIAAoAhBqIAQ3AAAgACAAKAIQQQhqNgIQDAILIANBGU4EQCAAKAIEIAAoAhBqIAQ+AAAgACAAKAIQQQRqNgIQIAAgACkDmC5CIIgiBDcDmC4gACAAKAKgLkEgayIDNgKgLgsgA0EJTgR/IAAoAgQgACgCEGogBD0AACAAIAAoAhBBAmo2AhAgACkDmC5CEIghBCAAKAKgLkEQawUgAwtBAUgNASAAKAIQCyIDQQFqNgIQIAAoAgQgA2ogBDwAAAsgAEEANgKgLiAAQgA3A5guIAAoAgQgACgCEGogAjsAACAAIAAoAhBBAmoiAzYCECAAKAIEIANqIAJBf3M7AAAgACAAKAIQQQJqIgM2AhAgAgRAIAAoAgQgA2ogASACEAcaIAAgACgCECACajYCEAsLrAQCAX8BfgJAIAANACABUA0AIAMEQCADQQA2AgQgA0ESNgIAC0EADwsCQAJAIAAgASACIAMQiQEiBEUNAEEYEAkiAkUEQCADBEAgA0EANgIEIANBDjYCAAsCQCAEKAIoIgBFBEAgBCkDGCEBDAELIABBADYCKCAEKAIoQgA3AyAgBCAEKQMYIgUgBCkDICIBIAEgBVQbIgE3AxgLIAQpAwggAVYEQANAIAQoAgAgAadBBHRqKAIAEAYgAUIBfCIBIAQpAwhUDQALCyAEKAIAEAYgBCgCBBAGIAQQBgwBCyACQQA2AhQgAiAENgIQIAJBABABNgIMIAJBADYCCCACQgA3AgACf0E4EAkiAEUEQCADBEAgA0EANgIEIANBDjYCAAtBAAwBCyAAQQA2AgggAEIANwMAIABCADcDICAAQoCAgIAQNwIsIABBADoAKCAAQQA2AhQgAEIANwIMIABBADsBNCAAIAI2AgggAEEkNgIEIABCPyACQQBCAEEOQSQRDAAiASABQgBTGzcDGCAACyIADQEgAigCECIDBEACQCADKAIoIgBFBEAgAykDGCEBDAELIABBADYCKCADKAIoQgA3AyAgAyADKQMYIgUgAykDICIBIAEgBVQbIgE3AxgLIAMpAwggAVYEQANAIAMoAgAgAadBBHRqKAIAEAYgAUIBfCIBIAMpAwhUDQALCyADKAIAEAYgAygCBBAGIAMQBgsgAhAGC0EAIQALIAALiwwBBn8gACABaiEFAkACQCAAKAIEIgJBAXENACACQQNxRQ0BIAAoAgAiAiABaiEBAkAgACACayIAQciEASgCAEcEQCACQf8BTQRAIAAoAggiBCACQQN2IgJBA3RB3IQBakYaIAAoAgwiAyAERw0CQbSEAUG0hAEoAgBBfiACd3E2AgAMAwsgACgCGCEGAkAgACAAKAIMIgNHBEAgACgCCCICQcSEASgCAEkaIAIgAzYCDCADIAI2AggMAQsCQCAAQRRqIgIoAgAiBA0AIABBEGoiAigCACIEDQBBACEDDAELA0AgAiEHIAQiA0EUaiICKAIAIgQNACADQRBqIQIgAygCECIEDQALIAdBADYCAAsgBkUNAgJAIAAgACgCHCIEQQJ0QeSGAWoiAigCAEYEQCACIAM2AgAgAw0BQbiEAUG4hAEoAgBBfiAEd3E2AgAMBAsgBkEQQRQgBigCECAARhtqIAM2AgAgA0UNAwsgAyAGNgIYIAAoAhAiAgRAIAMgAjYCECACIAM2AhgLIAAoAhQiAkUNAiADIAI2AhQgAiADNgIYDAILIAUoAgQiAkEDcUEDRw0BQbyEASABNgIAIAUgAkF+cTYCBCAAIAFBAXI2AgQgBSABNgIADwsgBCADNgIMIAMgBDYCCAsCQCAFKAIEIgJBAnFFBEAgBUHMhAEoAgBGBEBBzIQBIAA2AgBBwIQBQcCEASgCACABaiIBNgIAIAAgAUEBcjYCBCAAQciEASgCAEcNA0G8hAFBADYCAEHIhAFBADYCAA8LIAVByIQBKAIARgRAQciEASAANgIAQbyEAUG8hAEoAgAgAWoiATYCACAAIAFBAXI2AgQgACABaiABNgIADwsgAkF4cSABaiEBAkAgAkH/AU0EQCAFKAIIIgQgAkEDdiICQQN0QdyEAWpGGiAEIAUoAgwiA0YEQEG0hAFBtIQBKAIAQX4gAndxNgIADAILIAQgAzYCDCADIAQ2AggMAQsgBSgCGCEGAkAgBSAFKAIMIgNHBEAgBSgCCCICQcSEASgCAEkaIAIgAzYCDCADIAI2AggMAQsCQCAFQRRqIgQoAgAiAg0AIAVBEGoiBCgCACICDQBBACEDDAELA0AgBCEHIAIiA0EUaiIEKAIAIgINACADQRBqIQQgAygCECICDQALIAdBADYCAAsgBkUNAAJAIAUgBSgCHCIEQQJ0QeSGAWoiAigCAEYEQCACIAM2AgAgAw0BQbiEAUG4hAEoAgBBfiAEd3E2AgAMAgsgBkEQQRQgBigCECAFRhtqIAM2AgAgA0UNAQsgAyAGNgIYIAUoAhAiAgRAIAMgAjYCECACIAM2AhgLIAUoAhQiAkUNACADIAI2AhQgAiADNgIYCyAAIAFBAXI2AgQgACABaiABNgIAIABByIQBKAIARw0BQbyEASABNgIADwsgBSACQX5xNgIEIAAgAUEBcjYCBCAAIAFqIAE2AgALIAFB/wFNBEAgAUEDdiICQQN0QdyEAWohAQJ/QbSEASgCACIDQQEgAnQiAnFFBEBBtIQBIAIgA3I2AgAgAQwBCyABKAIICyECIAEgADYCCCACIAA2AgwgACABNgIMIAAgAjYCCA8LQR8hAiAAQgA3AhAgAUH///8HTQRAIAFBCHYiAiACQYD+P2pBEHZBCHEiBHQiAiACQYDgH2pBEHZBBHEiA3QiAiACQYCAD2pBEHZBAnEiAnRBD3YgAyAEciACcmsiAkEBdCABIAJBFWp2QQFxckEcaiECCyAAIAI2AhwgAkECdEHkhgFqIQcCQAJAQbiEASgCACIEQQEgAnQiA3FFBEBBuIQBIAMgBHI2AgAgByAANgIAIAAgBzYCGAwBCyABQQBBGSACQQF2ayACQR9GG3QhAiAHKAIAIQMDQCADIgQoAgRBeHEgAUYNAiACQR12IQMgAkEBdCECIAQgA0EEcWoiB0EQaigCACIDDQALIAcgADYCECAAIAQ2AhgLIAAgADYCDCAAIAA2AggPCyAEKAIIIgEgADYCDCAEIAA2AgggAEEANgIYIAAgBDYCDCAAIAE2AggLC1gCAX8BfgJAAn9BACAARQ0AGiAArUIChiICpyIBIABBBHJBgIAESQ0AGkF/IAEgAkIgiKcbCyIBEAkiAEUNACAAQQRrLQAAQQNxRQ0AIABBACABEBkLIAALQwEDfwJAIAJFDQADQCAALQAAIgQgAS0AACIFRgRAIAFBAWohASAAQQFqIQAgAkEBayICDQEMAgsLIAQgBWshAwsgAwsUACAAEEAgACgCABAgIAAoAgQQIAutBAIBfgV/IwBBEGsiBCQAIAAgAWshBgJAAkAgAUEBRgRAIAAgBi0AACACEBkMAQsgAUEJTwRAIAAgBikAADcAACAAIAJBAWtBB3FBAWoiBWohACACIAVrIgFFDQIgBSAGaiECA0AgACACKQAANwAAIAJBCGohAiAAQQhqIQAgAUEIayIBDQALDAILAkACQAJAAkAgAUEEaw4FAAICAgECCyAEIAYoAAAiATYCBCAEIAE2AgAMAgsgBCAGKQAANwMADAELQQghByAEQQhqIQgDQCAIIAYgByABIAEgB0sbIgUQByAFaiEIIAcgBWsiBw0ACyAEIAQpAwg3AwALAkAgBQ0AIAJBEEkNACAEKQMAIQMgAkEQayIGQQR2QQFqQQdxIgEEQANAIAAgAzcACCAAIAM3AAAgAkEQayECIABBEGohACABQQFrIgENAAsLIAZB8ABJDQADQCAAIAM3AHggACADNwBwIAAgAzcAaCAAIAM3AGAgACADNwBYIAAgAzcAUCAAIAM3AEggACADNwBAIAAgAzcAOCAAIAM3ADAgACADNwAoIAAgAzcAICAAIAM3ABggACADNwAQIAAgAzcACCAAIAM3AAAgAEGAAWohACACQYABayICQQ9LDQALCyACQQhPBEBBCCAFayEBA0AgACAEKQMANwAAIAAgAWohACACIAFrIgJBB0sNAAsLIAJFDQEgACAEIAIQBxoLIAAgAmohAAsgBEEQaiQAIAALXwECfyAAKAIIIgEEQCABEAsgAEEANgIICwJAIAAoAgQiAUUNACABKAIAIgJBAXFFDQAgASgCEEF+Rw0AIAEgAkF+cSICNgIAIAINACABECAgAEEANgIECyAAQQA6AAwL1wICBH8BfgJAAkAgACgCQCABp0EEdGooAgAiA0UEQCACBEAgAkEANgIEIAJBFDYCAAsMAQsgACgCACADKQNIIgdBABAUIQMgACgCACEAIANBf0wEQCACBEAgAiAAKAIMNgIAIAIgACgCEDYCBAsMAQtCACEBIwBBEGsiBiQAQX8hAwJAIABCGkEBEBRBf0wEQCACBEAgAiAAKAIMNgIAIAIgACgCEDYCBAsMAQsgAEIEIAZBCmogAhAtIgRFDQBBHiEAQQEhBQNAIAQQDCAAaiEAIAVBAkcEQCAFQQFqIQUMAQsLIAQtAAAEfyAEKQMQIAQpAwhRBUEAC0UEQCACBEAgAkEANgIEIAJBFDYCAAsgBBAIDAELIAQQCCAAIQMLIAZBEGokACADIgBBAEgNASAHIACtfCIBQn9VDQEgAgRAIAJBFjYCBCACQQQ2AgALC0IAIQELIAELYAIBfgF/AkAgAEUNACAAQQhqEF8iAEUNACABIAEoAjBBAWo2AjAgACADNgIIIAAgAjYCBCAAIAE2AgAgAEI/IAEgA0EAQgBBDiACEQoAIgQgBEIAUxs3AxggACEFCyAFCyIAIAAoAiRBAWtBAU0EQCAAQQBCAEEKEA4aIABBADYCJAsLbgACQAJAAkAgA0IQVA0AIAJFDQECfgJAAkACQCACKAIIDgMCAAEECyACKQMAIAB8DAILIAIpAwAgAXwMAQsgAikDAAsiA0IAUw0AIAEgA1oNAgsgBARAIARBADYCBCAEQRI2AgALC0J/IQMLIAMLggICAX8CfgJAQQEgAiADGwRAIAIgA2oQCSIFRQRAIAQEQCAEQQA2AgQgBEEONgIAC0EADwsgAq0hBgJAAkAgAARAIAAgBhATIgBFBEAgBARAIARBADYCBCAEQQ42AgALDAULIAUgACACEAcaIAMNAQwCCyABIAUgBhARIgdCf1cEQCAEBEAgBCABKAIMNgIAIAQgASgCEDYCBAsMBAsgBiAHVQRAIAQEQCAEQQA2AgQgBEERNgIACwwECyADRQ0BCyACIAVqIgBBADoAACACQQFIDQAgBSECA0AgAi0AAEUEQCACQSA6AAALIAJBAWoiAiAASQ0ACwsLIAUPCyAFEAZBAAuBAQEBfwJAIAAEQCADQYAGcSEFQQAhAwNAAkAgAC8BCCACRw0AIAUgACgCBHFFDQAgA0EATg0DIANBAWohAwsgACgCACIADQALCyAEBEAgBEEANgIEIARBCTYCAAtBAA8LIAEEQCABIAAvAQo7AQALIAAvAQpFBEBBwBQPCyAAKAIMC1cBAX9BEBAJIgNFBEBBAA8LIAMgATsBCiADIAA7AQggA0GABjYCBCADQQA2AgACQCABBEAgAyACIAEQYyIANgIMIAANASADEAZBAA8LIANBADYCDAsgAwvuBQIEfwV+IwBB4ABrIgQkACAEQQhqIgNCADcDICADQQA2AhggA0L/////DzcDECADQQA7AQwgA0G/hig2AgggA0EBOgAGIANBADsBBCADQQA2AgAgA0IANwNIIANBgIDYjXg2AkQgA0IANwMoIANCADcDMCADQgA3AzggA0FAa0EAOwEAIANCADcDUCABKQMIUCIDRQRAIAEoAgAoAgApA0ghBwsCfgJAIAMEQCAHIQkMAQsgByEJA0AgCqdBBHQiBSABKAIAaigCACIDKQNIIgggCSAIIAlUGyIJIAEpAyBWBEAgAgRAIAJBADYCBCACQRM2AgALQn8MAwsgAygCMCIGBH8gBi8BBAVBAAtB//8Dca0gCCADKQMgfHxCHnwiCCAHIAcgCFQbIgcgASkDIFYEQCACBEAgAkEANgIEIAJBEzYCAAtCfwwDCyAAKAIAIAEoAgAgBWooAgApA0hBABAUIQYgACgCACEDIAZBf0wEQCACBEAgAiADKAIMNgIAIAIgAygCEDYCBAtCfwwDCyAEQQhqIANBAEEBIAIQaEJ/UQRAIARBCGoQNkJ/DAMLAkACQCABKAIAIAVqKAIAIgMvAQogBC8BEkkNACADKAIQIAQoAhhHDQAgAygCFCAEKAIcRw0AIAMoAjAgBCgCOBBiRQ0AAkAgBCgCICIGIAMoAhhHBEAgBCkDKCEIDAELIAMpAyAiCyAEKQMoIghSDQAgCyEIIAMpAyggBCkDMFENAgsgBC0AFEEIcUUNACAGDQAgCEIAUg0AIAQpAzBQDQELIAIEQCACQQA2AgQgAkEVNgIACyAEQQhqEDZCfwwDCyABKAIAIAVqKAIAKAI0IAQoAjwQbyEDIAEoAgAgBWooAgAiBUEBOgAEIAUgAzYCNCAEQQA2AjwgBEEIahA2IApCAXwiCiABKQMIVA0ACwsgByAJfSIHQv///////////wAgB0L///////////8AVBsLIQcgBEHgAGokACAHC8YBAQJ/QdgAEAkiAUUEQCAABEAgAEEANgIEIABBDjYCAAtBAA8LIAECf0EYEAkiAkUEQCAABEAgAEEANgIEIABBDjYCAAtBAAwBCyACQQA2AhAgAkIANwMIIAJBADYCACACCyIANgJQIABFBEAgARAGQQAPCyABQgA3AwAgAUEANgIQIAFCADcCCCABQgA3AhQgAUEANgJUIAFCADcCHCABQgA3ACEgAUIANwMwIAFCADcDOCABQUBrQgA3AwAgAUIANwNIIAELgBMCD38CfiMAQdAAayIFJAAgBSABNgJMIAVBN2ohEyAFQThqIRBBACEBA0ACQCAOQQBIDQBB/////wcgDmsgAUgEQEGEhAFBPTYCAEF/IQ4MAQsgASAOaiEOCyAFKAJMIgchAQJAAkACQAJAAkACQAJAAkAgBQJ/AkAgBy0AACIGBEADQAJAAkAgBkH/AXEiBkUEQCABIQYMAQsgBkElRw0BIAEhBgNAIAEtAAFBJUcNASAFIAFBAmoiCDYCTCAGQQFqIQYgAS0AAiEMIAghASAMQSVGDQALCyAGIAdrIQEgAARAIAAgByABEC4LIAENDSAFKAJMIQEgBSgCTCwAAUEwa0EKTw0DIAEtAAJBJEcNAyABLAABQTBrIQ9BASERIAFBA2oMBAsgBSABQQFqIgg2AkwgAS0AASEGIAghAQwACwALIA4hDSAADQggEUUNAkEBIQEDQCAEIAFBAnRqKAIAIgAEQCADIAFBA3RqIAAgAhB4QQEhDSABQQFqIgFBCkcNAQwKCwtBASENIAFBCk8NCANAIAQgAUECdGooAgANCCABQQFqIgFBCkcNAAsMCAtBfyEPIAFBAWoLIgE2AkxBACEIAkAgASwAACIKQSBrIgZBH0sNAEEBIAZ0IgZBidEEcUUNAANAAkAgBSABQQFqIgg2AkwgASwAASIKQSBrIgFBIE8NAEEBIAF0IgFBidEEcUUNACABIAZyIQYgCCEBDAELCyAIIQEgBiEICwJAIApBKkYEQCAFAn8CQCABLAABQTBrQQpPDQAgBSgCTCIBLQACQSRHDQAgASwAAUECdCAEakHAAWtBCjYCACABLAABQQN0IANqQYADaygCACELQQEhESABQQNqDAELIBENCEEAIRFBACELIAAEQCACIAIoAgAiAUEEajYCACABKAIAIQsLIAUoAkxBAWoLIgE2AkwgC0F/Sg0BQQAgC2shCyAIQYDAAHIhCAwBCyAFQcwAahB3IgtBAEgNBiAFKAJMIQELQX8hCQJAIAEtAABBLkcNACABLQABQSpGBEACQCABLAACQTBrQQpPDQAgBSgCTCIBLQADQSRHDQAgASwAAkECdCAEakHAAWtBCjYCACABLAACQQN0IANqQYADaygCACEJIAUgAUEEaiIBNgJMDAILIBENByAABH8gAiACKAIAIgFBBGo2AgAgASgCAAVBAAshCSAFIAUoAkxBAmoiATYCTAwBCyAFIAFBAWo2AkwgBUHMAGoQdyEJIAUoAkwhAQtBACEGA0AgBiESQX8hDSABLAAAQcEAa0E5Sw0HIAUgAUEBaiIKNgJMIAEsAAAhBiAKIQEgBiASQTpsakGf7ABqLQAAIgZBAWtBCEkNAAsgBkETRg0CIAZFDQYgD0EATgRAIAQgD0ECdGogBjYCACAFIAMgD0EDdGopAwA3A0AMBAsgAA0BC0EAIQ0MBQsgBUFAayAGIAIQeCAFKAJMIQoMAgsgD0F/Sg0DC0EAIQEgAEUNBAsgCEH//3txIgwgCCAIQYDAAHEbIQZBACENQaQIIQ8gECEIAkACQAJAAn8CQAJAAkACQAJ/AkACQAJAAkACQAJAAkAgCkEBaywAACIBQV9xIAEgAUEPcUEDRhsgASASGyIBQdgAaw4hBBISEhISEhISDhIPBg4ODhIGEhISEgIFAxISCRIBEhIEAAsCQCABQcEAaw4HDhILEg4ODgALIAFB0wBGDQkMEQsgBSkDQCEUQaQIDAULQQAhAQJAAkACQAJAAkACQAJAIBJB/wFxDggAAQIDBBcFBhcLIAUoAkAgDjYCAAwWCyAFKAJAIA42AgAMFQsgBSgCQCAOrDcDAAwUCyAFKAJAIA47AQAMEwsgBSgCQCAOOgAADBILIAUoAkAgDjYCAAwRCyAFKAJAIA6sNwMADBALIAlBCCAJQQhLGyEJIAZBCHIhBkH4ACEBCyAQIQcgAUEgcSEMIAUpA0AiFFBFBEADQCAHQQFrIgcgFKdBD3FBsPAAai0AACAMcjoAACAUQg9WIQogFEIEiCEUIAoNAAsLIAUpA0BQDQMgBkEIcUUNAyABQQR2QaQIaiEPQQIhDQwDCyAQIQEgBSkDQCIUUEUEQANAIAFBAWsiASAUp0EHcUEwcjoAACAUQgdWIQcgFEIDiCEUIAcNAAsLIAEhByAGQQhxRQ0CIAkgECAHayIBQQFqIAEgCUgbIQkMAgsgBSkDQCIUQn9XBEAgBUIAIBR9IhQ3A0BBASENQaQIDAELIAZBgBBxBEBBASENQaUIDAELQaYIQaQIIAZBAXEiDRsLIQ8gECEBAkAgFEKAgICAEFQEQCAUIRUMAQsDQCABQQFrIgEgFCAUQgqAIhVCCn59p0EwcjoAACAUQv////+fAVYhByAVIRQgBw0ACwsgFaciBwRAA0AgAUEBayIBIAcgB0EKbiIMQQpsa0EwcjoAACAHQQlLIQogDCEHIAoNAAsLIAEhBwsgBkH//3txIAYgCUF/ShshBgJAIAUpA0AiFEIAUg0AIAkNAEEAIQkgECEHDAoLIAkgFFAgECAHa2oiASABIAlIGyEJDAkLIAUoAkAiAUGKEiABGyIHQQAgCRB6IgEgByAJaiABGyEIIAwhBiABIAdrIAkgARshCQwICyAJBEAgBSgCQAwCC0EAIQEgAEEgIAtBACAGECcMAgsgBUEANgIMIAUgBSkDQD4CCCAFIAVBCGo2AkBBfyEJIAVBCGoLIQhBACEBAkADQCAIKAIAIgdFDQECQCAFQQRqIAcQeSIHQQBIIgwNACAHIAkgAWtLDQAgCEEEaiEIIAkgASAHaiIBSw0BDAILC0F/IQ0gDA0FCyAAQSAgCyABIAYQJyABRQRAQQAhAQwBC0EAIQggBSgCQCEKA0AgCigCACIHRQ0BIAVBBGogBxB5IgcgCGoiCCABSg0BIAAgBUEEaiAHEC4gCkEEaiEKIAEgCEsNAAsLIABBICALIAEgBkGAwABzECcgCyABIAEgC0gbIQEMBQsgACAFKwNAIAsgCSAGIAFBABEdACEBDAQLIAUgBSkDQDwAN0EBIQkgEyEHIAwhBgwCC0F/IQ0LIAVB0ABqJAAgDQ8LIABBICANIAggB2siDCAJIAkgDEgbIgpqIgggCyAIIAtKGyIBIAggBhAnIAAgDyANEC4gAEEwIAEgCCAGQYCABHMQJyAAQTAgCiAMQQAQJyAAIAcgDBAuIABBICABIAggBkGAwABzECcMAAsAC54DAgR/AX4gAARAIAAoAgAiAQRAIAEQGhogACgCABALCyAAKAIcEAYgACgCIBAQIAAoAiQQECAAKAJQIgMEQCADKAIQIgIEQCADKAIAIgEEfwNAIAIgBEECdGooAgAiAgRAA0AgAigCGCEBIAIQBiABIgINAAsgAygCACEBCyABIARBAWoiBEsEQCADKAIQIQIMAQsLIAMoAhAFIAILEAYLIAMQBgsgACgCQCIBBEAgACkDMFAEfyABBSABED5CAiEFAkAgACkDMEICVA0AQQEhAgNAIAAoAkAgAkEEdGoQPiAFIAApAzBaDQEgBachAiAFQgF8IQUMAAsACyAAKAJACxAGCwJAIAAoAkRFDQBBACECQgEhBQNAIAAoAkwgAkECdGooAgAiAUEBOgAoIAFBDGoiASgCAEUEQCABBEAgAUEANgIEIAFBCDYCAAsLIAUgADUCRFoNASAFpyECIAVCAXwhBQwACwALIAAoAkwQBiAAKAJUIgIEQCACKAIIIgEEQCACKAIMIAERAwALIAIQBgsgAEEIahAxIAAQBgsL6gMCAX4EfwJAIAAEfiABRQRAIAMEQCADQQA2AgQgA0ESNgIAC0J/DwsgAkGDIHEEQAJAIAApAzBQDQBBPEE9IAJBAXEbIQcgAkECcUUEQANAIAAgBCACIAMQUyIFBEAgASAFIAcRAgBFDQYLIARCAXwiBCAAKQMwVA0ADAILAAsDQCAAIAQgAiADEFMiBQRAIAECfyAFECJBAWohBgNAQQAgBkUNARogBSAGQQFrIgZqIggtAABBL0cNAAsgCAsiBkEBaiAFIAYbIAcRAgBFDQULIARCAXwiBCAAKQMwVA0ACwsgAwRAIANBADYCBCADQQk2AgALQn8PC0ESIQYCQAJAIAAoAlAiBUUNACABRQ0AQQkhBiAFKQMIUA0AIAUoAhAgAS0AACIHBH9CpesKIQQgASEAA0AgBCAHrUL/AYN8IQQgAC0AASIHBEAgAEEBaiEAIARC/////w+DQiF+IQQMAQsLIASnBUGFKgsgBSgCAHBBAnRqKAIAIgBFDQADQCABIAAoAgAQOEUEQCACQQhxBEAgACkDCCIEQn9RDQMMBAsgACkDECIEQn9RDQIMAwsgACgCGCIADQALCyADBEAgA0EANgIEIAMgBjYCAAtCfyEECyAEBUJ/Cw8LIAMEQCADQgA3AgALIAQL3AQCB38BfgJAAkAgAEUNACABRQ0AIAJCf1UNAQsgBARAIARBADYCBCAEQRI2AgALQQAPCwJAIAAoAgAiB0UEQEGAAiEHQYACEDwiBkUNASAAKAIQEAYgAEGAAjYCACAAIAY2AhALAkACQCAAKAIQIAEtAAAiBQR/QqXrCiEMIAEhBgNAIAwgBa1C/wGDfCEMIAYtAAEiBQRAIAZBAWohBiAMQv////8Pg0IhfiEMDAELCyAMpwVBhSoLIgYgB3BBAnRqIggoAgAiBQRAA0ACQCAFKAIcIAZHDQAgASAFKAIAEDgNAAJAIANBCHEEQCAFKQMIQn9SDQELIAUpAxBCf1ENBAsgBARAIARBADYCBCAEQQo2AgALQQAPCyAFKAIYIgUNAAsLQSAQCSIFRQ0CIAUgATYCACAFIAgoAgA2AhggCCAFNgIAIAVCfzcDCCAFIAY2AhwgACAAKQMIQgF8Igw3AwggDLogB7hEAAAAAAAA6D+iZEUNACAHQQBIDQAgByAHQQF0IghGDQAgCBA8IgpFDQECQCAMQgAgBxtQBEAgACgCECEJDAELIAAoAhAhCUEAIQQDQCAJIARBAnRqKAIAIgYEQANAIAYoAhghASAGIAogBigCHCAIcEECdGoiCygCADYCGCALIAY2AgAgASIGDQALCyAEQQFqIgQgB0cNAAsLIAkQBiAAIAg2AgAgACAKNgIQCyADQQhxBEAgBSACNwMICyAFIAI3AxBBAQ8LIAQEQCAEQQA2AgQgBEEONgIAC0EADwsgBARAIARBADYCBCAEQQ42AgALQQAL3Q8BF38jAEFAaiIHQgA3AzAgB0IANwM4IAdCADcDICAHQgA3AygCQAJAAkACQAJAIAIEQCACQQNxIQggAkEBa0EDTwRAIAJBfHEhBgNAIAdBIGogASAJQQF0IgxqLwEAQQF0aiIKIAovAQBBAWo7AQAgB0EgaiABIAxBAnJqLwEAQQF0aiIKIAovAQBBAWo7AQAgB0EgaiABIAxBBHJqLwEAQQF0aiIKIAovAQBBAWo7AQAgB0EgaiABIAxBBnJqLwEAQQF0aiIKIAovAQBBAWo7AQAgCUEEaiEJIAZBBGsiBg0ACwsgCARAA0AgB0EgaiABIAlBAXRqLwEAQQF0aiIGIAYvAQBBAWo7AQAgCUEBaiEJIAhBAWsiCA0ACwsgBCgCACEJQQ8hCyAHLwE+IhENAgwBCyAEKAIAIQkLQQ4hC0EAIREgBy8BPA0AQQ0hCyAHLwE6DQBBDCELIAcvATgNAEELIQsgBy8BNg0AQQohCyAHLwE0DQBBCSELIAcvATINAEEIIQsgBy8BMA0AQQchCyAHLwEuDQBBBiELIAcvASwNAEEFIQsgBy8BKg0AQQQhCyAHLwEoDQBBAyELIAcvASYNAEECIQsgBy8BJA0AIAcvASJFBEAgAyADKAIAIgBBBGo2AgAgAEHAAjYBACADIAMoAgAiAEEEajYCACAAQcACNgEAQQEhDQwDCyAJQQBHIRtBASELQQEhCQwBCyALIAkgCSALSxshG0EBIQ5BASEJA0AgB0EgaiAJQQF0ai8BAA0BIAlBAWoiCSALRw0ACyALIQkLQX8hCCAHLwEiIg9BAksNAUEEIAcvASQiECAPQQF0amsiBkEASA0BIAZBAXQgBy8BJiISayIGQQBIDQEgBkEBdCAHLwEoIhNrIgZBAEgNASAGQQF0IAcvASoiFGsiBkEASA0BIAZBAXQgBy8BLCIVayIGQQBIDQEgBkEBdCAHLwEuIhZrIgZBAEgNASAGQQF0IAcvATAiF2siBkEASA0BIAZBAXQgBy8BMiIZayIGQQBIDQEgBkEBdCAHLwE0IhxrIgZBAEgNASAGQQF0IAcvATYiDWsiBkEASA0BIAZBAXQgBy8BOCIYayIGQQBIDQEgBkEBdCAHLwE6IgxrIgZBAEgNASAGQQF0IAcvATwiCmsiBkEASA0BIAZBAXQgEWsiBkEASA0BIAZBACAARSAOchsNASAJIBtLIRpBACEIIAdBADsBAiAHIA87AQQgByAPIBBqIgY7AQYgByAGIBJqIgY7AQggByAGIBNqIgY7AQogByAGIBRqIgY7AQwgByAGIBVqIgY7AQ4gByAGIBZqIgY7ARAgByAGIBdqIgY7ARIgByAGIBlqIgY7ARQgByAGIBxqIgY7ARYgByAGIA1qIgY7ARggByAGIBhqIgY7ARogByAGIAxqIgY7ARwgByAGIApqOwEeAkAgAkUNACACQQFHBEAgAkF+cSEGA0AgASAIQQF0ai8BACIKBEAgByAKQQF0aiIKIAovAQAiCkEBajsBACAFIApBAXRqIAg7AQALIAEgCEEBciIMQQF0ai8BACIKBEAgByAKQQF0aiIKIAovAQAiCkEBajsBACAFIApBAXRqIAw7AQALIAhBAmohCCAGQQJrIgYNAAsLIAJBAXFFDQAgASAIQQF0ai8BACICRQ0AIAcgAkEBdGoiAiACLwEAIgJBAWo7AQAgBSACQQF0aiAIOwEACyAJIBsgGhshDUEUIRBBACEWIAUiCiEYQQAhEgJAAkACQCAADgICAAELQQEhCCANQQpLDQNBgQIhEEHw2QAhGEGw2QAhCkEBIRIMAQsgAEECRiEWQQAhEEHw2gAhGEGw2gAhCiAAQQJHBEAMAQtBASEIIA1BCUsNAgtBASANdCITQQFrIRwgAygCACEUQQAhFSANIQZBACEPQQAhDkF/IQIDQEEBIAZ0IRoCQANAIAkgD2shFwJAIAUgFUEBdGovAQAiCCAQTwRAIAogCCAQa0EBdCIAai8BACERIAAgGGotAAAhAAwBC0EAQeAAIAhBAWogEEkiBhshACAIQQAgBhshEQsgDiAPdiEMQX8gF3QhBiAaIQgDQCAUIAYgCGoiCCAMakECdGoiGSAROwECIBkgFzoAASAZIAA6AAAgCA0AC0EBIAlBAWt0IQYDQCAGIgBBAXYhBiAAIA5xDQALIAdBIGogCUEBdGoiBiAGLwEAQQFrIgY7AQAgAEEBayAOcSAAakEAIAAbIQ4gFUEBaiEVIAZB//8DcUUEQCAJIAtGDQIgASAFIBVBAXRqLwEAQQF0ai8BACEJCyAJIA1NDQAgDiAccSIAIAJGDQALQQEgCSAPIA0gDxsiD2siBnQhAiAJIAtJBEAgCyAPayEMIAkhCAJAA0AgAiAHQSBqIAhBAXRqLwEAayICQQFIDQEgAkEBdCECIAZBAWoiBiAPaiIIIAtJDQALIAwhBgtBASAGdCECC0EBIQggEiACIBNqIhNBtApLcQ0DIBYgE0HQBEtxDQMgAygCACICIABBAnRqIgggDToAASAIIAY6AAAgCCAUIBpBAnRqIhQgAmtBAnY7AQIgACECDAELCyAOBEAgFCAOQQJ0aiIAQQA7AQIgACAXOgABIABBwAA6AAALIAMgAygCACATQQJ0ajYCAAsgBCANNgIAQQAhCAsgCAusAQICfgF/IAFBAmqtIQIgACkDmC4hAwJAIAAoAqAuIgFBA2oiBEE/TQRAIAIgAa2GIAOEIQIMAQsgAUHAAEYEQCAAKAIEIAAoAhBqIAM3AAAgACAAKAIQQQhqNgIQQQMhBAwBCyAAKAIEIAAoAhBqIAIgAa2GIAOENwAAIAAgACgCEEEIajYCECABQT1rIQQgAkHAACABa62IIQILIAAgAjcDmC4gACAENgKgLguXAwICfgN/QYDJADMBACECIAApA5guIQMCQCAAKAKgLiIFQYLJAC8BACIGaiIEQT9NBEAgAiAFrYYgA4QhAgwBCyAFQcAARgRAIAAoAgQgACgCEGogAzcAACAAIAAoAhBBCGo2AhAgBiEEDAELIAAoAgQgACgCEGogAiAFrYYgA4Q3AAAgACAAKAIQQQhqNgIQIARBQGohBCACQcAAIAVrrYghAgsgACACNwOYLiAAIAQ2AqAuIAEEQAJAIARBOU4EQCAAKAIEIAAoAhBqIAI3AAAgACAAKAIQQQhqNgIQDAELIARBGU4EQCAAKAIEIAAoAhBqIAI+AAAgACAAKAIQQQRqNgIQIAAgACkDmC5CIIgiAjcDmC4gACAAKAKgLkEgayIENgKgLgsgBEEJTgR/IAAoAgQgACgCEGogAj0AACAAIAAoAhBBAmo2AhAgACkDmC5CEIghAiAAKAKgLkEQawUgBAtBAUgNACAAIAAoAhAiAUEBajYCECABIAAoAgRqIAI8AAALIABBADYCoC4gAEIANwOYLgsL8hQBEn8gASgCCCICKAIAIQUgAigCDCEHIAEoAgAhCCAAQoCAgIDQxwA3A6ApQQAhAgJAAkAgB0EASgRAQX8hDANAAkAgCCACQQJ0aiIDLwEABEAgACAAKAKgKUEBaiIDNgKgKSAAIANBAnRqQawXaiACNgIAIAAgAmpBqClqQQA6AAAgAiEMDAELIANBADsBAgsgAkEBaiICIAdHDQALIABB/C1qIQ8gAEH4LWohESAAKAKgKSIEQQFKDQIMAQsgAEH8LWohDyAAQfgtaiERQX8hDAsDQCAAIARBAWoiAjYCoCkgACACQQJ0akGsF2ogDEEBaiIDQQAgDEECSCIGGyICNgIAIAggAkECdCIEakEBOwEAIAAgAmpBqClqQQA6AAAgACAAKAL4LUEBazYC+C0gBQRAIA8gDygCACAEIAVqLwECazYCAAsgAyAMIAYbIQwgACgCoCkiBEECSA0ACwsgASAMNgIEIARBAXYhBgNAIAAgBkECdGpBrBdqKAIAIQkCQCAGIgJBAXQiAyAESg0AIAggCUECdGohCiAAIAlqQagpaiENIAYhBQNAAkAgAyAETgRAIAMhAgwBCyAIIABBrBdqIgIgA0EBciIEQQJ0aigCACILQQJ0ai8BACIOIAggAiADQQJ0aigCACIQQQJ0ai8BACICTwRAIAIgDkcEQCADIQIMAgsgAyECIABBqClqIgMgC2otAAAgAyAQai0AAEsNAQsgBCECCyAKLwEAIgQgCCAAIAJBAnRqQawXaigCACIDQQJ0ai8BACILSQRAIAUhAgwCCwJAIAQgC0cNACANLQAAIAAgA2pBqClqLQAASw0AIAUhAgwCCyAAIAVBAnRqQawXaiADNgIAIAIhBSACQQF0IgMgACgCoCkiBEwNAAsLIAAgAkECdGpBrBdqIAk2AgAgBkECTgRAIAZBAWshBiAAKAKgKSEEDAELCyAAKAKgKSEDA0AgByEGIAAgA0EBayIENgKgKSAAKAKwFyEKIAAgACADQQJ0akGsF2ooAgAiCTYCsBdBASECAkAgA0EDSA0AIAggCUECdGohDSAAIAlqQagpaiELQQIhA0EBIQUDQAJAIAMgBE4EQCADIQIMAQsgCCAAQawXaiICIANBAXIiB0ECdGooAgAiBEECdGovAQAiDiAIIAIgA0ECdGooAgAiEEECdGovAQAiAk8EQCACIA5HBEAgAyECDAILIAMhAiAAQagpaiIDIARqLQAAIAMgEGotAABLDQELIAchAgsgDS8BACIHIAggACACQQJ0akGsF2ooAgAiA0ECdGovAQAiBEkEQCAFIQIMAgsCQCAEIAdHDQAgCy0AACAAIANqQagpai0AAEsNACAFIQIMAgsgACAFQQJ0akGsF2ogAzYCACACIQUgAkEBdCIDIAAoAqApIgRMDQALC0ECIQMgAEGsF2oiByACQQJ0aiAJNgIAIAAgACgCpClBAWsiBTYCpCkgACgCsBchAiAHIAVBAnRqIAo2AgAgACAAKAKkKUEBayIFNgKkKSAHIAVBAnRqIAI2AgAgCCAGQQJ0aiINIAggAkECdGoiBS8BACAIIApBAnRqIgQvAQBqOwEAIABBqClqIgkgBmoiCyACIAlqLQAAIgIgCSAKai0AACIKIAIgCksbQQFqOgAAIAUgBjsBAiAEIAY7AQIgACAGNgKwF0EBIQVBASECAkAgACgCoCkiBEECSA0AA0AgDS8BACIKIAggAAJ/IAMgAyAETg0AGiAIIAcgA0EBciICQQJ0aigCACIEQQJ0ai8BACIOIAggByADQQJ0aigCACIQQQJ0ai8BACISTwRAIAMgDiASRw0BGiADIAQgCWotAAAgCSAQai0AAEsNARoLIAILIgJBAnRqQawXaigCACIDQQJ0ai8BACIESQRAIAUhAgwCCwJAIAQgCkcNACALLQAAIAAgA2pBqClqLQAASw0AIAUhAgwCCyAAIAVBAnRqQawXaiADNgIAIAIhBSACQQF0IgMgACgCoCkiBEwNAAsLIAZBAWohByAAIAJBAnRqQawXaiAGNgIAIAAoAqApIgNBAUoNAAsgACAAKAKkKUEBayICNgKkKSAAQawXaiIDIAJBAnRqIAAoArAXNgIAIAEoAgQhCSABKAIIIgIoAhAhBiACKAIIIQogAigCBCEQIAIoAgAhDSABKAIAIQcgAEGkF2pCADcBACAAQZwXakIANwEAIABBlBdqQgA3AQAgAEGMF2oiAUIANwEAQQAhBSAHIAMgACgCpClBAnRqKAIAQQJ0akEAOwECAkAgACgCpCkiAkG7BEoNACACQQFqIQIDQCAHIAAgAkECdGpBrBdqKAIAIgRBAnQiEmoiCyAHIAsvAQJBAnRqLwECIgNBAWogBiADIAZJGyIOOwECIAMgBk8hEwJAIAQgCUoNACAAIA5BAXRqQYwXaiIDIAMvAQBBAWo7AQBBACEDIAQgCk4EQCAQIAQgCmtBAnRqKAIAIQMLIBEgESgCACALLwEAIgQgAyAOamxqNgIAIA1FDQAgDyAPKAIAIAMgDSASai8BAmogBGxqNgIACyAFIBNqIQUgAkEBaiICQb0ERw0ACyAFRQ0AIAAgBkEBdGpBjBdqIQQDQCAGIQIDQCAAIAIiA0EBayICQQF0akGMF2oiDy8BACIKRQ0ACyAPIApBAWs7AQAgACADQQF0akGMF2oiAiACLwEAQQJqOwEAIAQgBC8BAEEBayIDOwEAIAVBAkohAiAFQQJrIQUgAg0ACyAGRQ0AQb0EIQIDQCADQf//A3EiBQRAA0AgACACQQFrIgJBAnRqQawXaigCACIDIAlKDQAgByADQQJ0aiIDLwECIAZHBEAgESARKAIAIAYgAy8BAGxqIgQ2AgAgESAEIAMvAQAgAy8BAmxrNgIAIAMgBjsBAgsgBUEBayIFDQALCyAGQQFrIgZFDQEgACAGQQF0akGMF2ovAQAhAwwACwALIwBBIGsiAiABIgAvAQBBAXQiATsBAiACIAEgAC8BAmpBAXQiATsBBCACIAEgAC8BBGpBAXQiATsBBiACIAEgAC8BBmpBAXQiATsBCCACIAEgAC8BCGpBAXQiATsBCiACIAEgAC8BCmpBAXQiATsBDCACIAEgAC8BDGpBAXQiATsBDiACIAEgAC8BDmpBAXQiATsBECACIAEgAC8BEGpBAXQiATsBEiACIAEgAC8BEmpBAXQiATsBFCACIAEgAC8BFGpBAXQiATsBFiACIAEgAC8BFmpBAXQiATsBGCACIAEgAC8BGGpBAXQiATsBGiACIAEgAC8BGmpBAXQiATsBHCACIAAvARwgAWpBAXQ7AR5BACEAIAxBAE4EQANAIAggAEECdGoiAy8BAiIBBEAgAiABQQF0aiIFIAUvAQAiBUEBajsBACADIAWtQoD+A4NCCIhCgpCAgQh+QpDCiKKIAYNCgYKEiBB+QiCIp0H/AXEgBUH/AXGtQoKQgIEIfkKQwoiiiAGDQoGChIgQfkIYiKdBgP4DcXJBECABa3Y7AQALIAAgDEchASAAQQFqIQAgAQ0ACwsLcgEBfyMAQRBrIgQkAAJ/QQAgAEUNABogAEEIaiEAIAFFBEAgAlBFBEAgAARAIABBADYCBCAAQRI2AgALQQAMAgtBAEIAIAMgABA6DAELIAQgAjcDCCAEIAE2AgAgBEIBIAMgABA6CyEAIARBEGokACAACyIAIAAgASACIAMQJiIARQRAQQAPCyAAKAIwQQAgAiADECULAwABC8gFAQR/IABB//8DcSEDIABBEHYhBEEBIQAgAkEBRgRAIAMgAS0AAGpB8f8DcCIAIARqQfH/A3BBEHQgAHIPCwJAIAEEfyACQRBJDQECQCACQa8rSwRAA0AgAkGwK2shAkG1BSEFIAEhAANAIAMgAC0AAGoiAyAEaiADIAAtAAFqIgNqIAMgAC0AAmoiA2ogAyAALQADaiIDaiADIAAtAARqIgNqIAMgAC0ABWoiA2ogAyAALQAGaiIDaiADIAAtAAdqIgNqIQQgBQRAIABBCGohACAFQQFrIQUMAQsLIARB8f8DcCEEIANB8f8DcCEDIAFBsCtqIQEgAkGvK0sNAAsgAkEISQ0BCwNAIAMgAS0AAGoiACAEaiAAIAEtAAFqIgBqIAAgAS0AAmoiAGogACABLQADaiIAaiAAIAEtAARqIgBqIAAgAS0ABWoiAGogACABLQAGaiIAaiAAIAEtAAdqIgNqIQQgAUEIaiEBIAJBCGsiAkEHSw0ACwsCQCACRQ0AIAJBAWshBiACQQNxIgUEQCABIQADQCACQQFrIQIgAyAALQAAaiIDIARqIQQgAEEBaiIBIQAgBUEBayIFDQALCyAGQQNJDQADQCADIAEtAABqIgAgAS0AAWoiBSABLQACaiIGIAEtAANqIgMgBiAFIAAgBGpqamohBCABQQRqIQEgAkEEayICDQALCyADQfH/A3AgBEHx/wNwQRB0cgVBAQsPCwJAIAJFDQAgAkEBayEGIAJBA3EiBQRAIAEhAANAIAJBAWshAiADIAAtAABqIgMgBGohBCAAQQFqIgEhACAFQQFrIgUNAAsLIAZBA0kNAANAIAMgAS0AAGoiACABLQABaiIFIAEtAAJqIgYgAS0AA2oiAyAGIAUgACAEampqaiEEIAFBBGohASACQQRrIgINAAsLIANB8f8DcCAEQfH/A3BBEHRyCx8AIAAgAiADQcCAASgCABEAACEAIAEgAiADEAcaIAALIwAgACAAKAJAIAIgA0HUgAEoAgARAAA2AkAgASACIAMQBxoLzSoCGH8HfiAAKAIMIgIgACgCECIDaiEQIAMgAWshASAAKAIAIgUgACgCBGohA0F/IAAoAhwiBygCpAF0IQRBfyAHKAKgAXQhCyAHKAI4IQwCf0EAIAcoAiwiEUUNABpBACACIAxJDQAaIAJBhAJqIAwgEWpNCyEWIBBBgwJrIRMgASACaiEXIANBDmshFCAEQX9zIRggC0F/cyESIAcoApwBIRUgBygCmAEhDSAHKAKIASEIIAc1AoQBIR0gBygCNCEOIAcoAjAhGSAQQQFqIQ8DQCAIQThyIQYgBSAIQQN2QQdxayELAn8gAiANIAUpAAAgCK2GIB2EIh2nIBJxQQJ0IgFqIgMtAAAiBA0AGiACIAEgDWoiAS0AAjoAACAGIAEtAAEiAWshBiACQQFqIA0gHSABrYgiHacgEnFBAnQiAWoiAy0AACIEDQAaIAIgASANaiIDLQACOgABIAYgAy0AASIDayEGIA0gHSADrYgiHacgEnFBAnRqIgMtAAAhBCACQQJqCyEBIAtBB2ohBSAGIAMtAAEiAmshCCAdIAKtiCEdAkACQAJAIARB/wFxRQ0AAkACQAJAAkACQANAIARBEHEEQCAVIB0gBK1CD4OIIhqnIBhxQQJ0aiECAn8gCCAEQQ9xIgZrIgRBG0sEQCAEIQggBQwBCyAEQThyIQggBSkAACAErYYgGoQhGiAFIARBA3ZrQQdqCyELIAMzAQIhGyAIIAItAAEiA2shCCAaIAOtiCEaIAItAAAiBEEQcQ0CA0AgBEHAAHFFBEAgCCAVIAIvAQJBAnRqIBqnQX8gBHRBf3NxQQJ0aiICLQABIgNrIQggGiADrYghGiACLQAAIgRBEHFFDQEMBAsLIAdB0f4ANgIEIABB7A42AhggGiEdDAMLIARB/wFxIgJBwABxRQRAIAggDSADLwECQQJ0aiAdp0F/IAJ0QX9zcUECdGoiAy0AASICayEIIB0gAq2IIR0gAy0AACIERQ0HDAELCyAEQSBxBEAgB0G//gA2AgQgASECDAgLIAdB0f4ANgIEIABB0A42AhggASECDAcLIB1BfyAGdEF/c62DIBt8IhunIQUgCCAEQQ9xIgNrIQggGiAErUIPg4ghHSABIBdrIgYgAjMBAiAaQX8gA3RBf3Otg3ynIgRPDQIgBCAGayIGIBlNDQEgBygCjEdFDQEgB0HR/gA2AgQgAEG5DDYCGAsgASECIAshBQwFCwJAIA5FBEAgDCARIAZraiEDDAELIAYgDk0EQCAMIA4gBmtqIQMMAQsgDCARIAYgDmsiBmtqIQMgBSAGTQ0AIAUgBmshBQJAAkAgASADTSABIA8gAWusIhogBq0iGyAaIBtUGyIapyIGaiICIANLcQ0AIAMgBmogAUsgASADT3ENACABIAMgBhAHGiACIQEMAQsgASADIAMgAWsiASABQR91IgFqIAFzIgIQByACaiEBIBogAq0iHn0iHFANACACIANqIQIDQAJAIBwgHiAcIB5UGyIbQiBUBEAgGyEaDAELIBsiGkIgfSIgQgWIQgF8QgODIh9QRQRAA0AgASACKQAANwAAIAEgAikAGDcAGCABIAIpABA3ABAgASACKQAINwAIIBpCIH0hGiACQSBqIQIgAUEgaiEBIB9CAX0iH0IAUg0ACwsgIELgAFQNAANAIAEgAikAADcAACABIAIpABg3ABggASACKQAQNwAQIAEgAikACDcACCABIAIpADg3ADggASACKQAwNwAwIAEgAikAKDcAKCABIAIpACA3ACAgASACKQBYNwBYIAEgAikAUDcAUCABIAIpAEg3AEggASACKQBANwBAIAEgAikAYDcAYCABIAIpAGg3AGggASACKQBwNwBwIAEgAikAeDcAeCACQYABaiECIAFBgAFqIQEgGkKAAX0iGkIfVg0ACwsgGkIQWgRAIAEgAikAADcAACABIAIpAAg3AAggGkIQfSEaIAJBEGohAiABQRBqIQELIBpCCFoEQCABIAIpAAA3AAAgGkIIfSEaIAJBCGohAiABQQhqIQELIBpCBFoEQCABIAIoAAA2AAAgGkIEfSEaIAJBBGohAiABQQRqIQELIBpCAloEQCABIAIvAAA7AAAgGkICfSEaIAJBAmohAiABQQJqIQELIBwgG30hHCAaUEUEQCABIAItAAA6AAAgAkEBaiECIAFBAWohAQsgHEIAUg0ACwsgDiEGIAwhAwsgBSAGSwRAAkACQCABIANNIAEgDyABa6wiGiAGrSIbIBogG1QbIhqnIglqIgIgA0txDQAgAyAJaiABSyABIANPcQ0AIAEgAyAJEAcaDAELIAEgAyADIAFrIgEgAUEfdSIBaiABcyIBEAcgAWohAiAaIAGtIh59IhxQDQAgASADaiEBA0ACQCAcIB4gHCAeVBsiG0IgVARAIBshGgwBCyAbIhpCIH0iIEIFiEIBfEIDgyIfUEUEQANAIAIgASkAADcAACACIAEpABg3ABggAiABKQAQNwAQIAIgASkACDcACCAaQiB9IRogAUEgaiEBIAJBIGohAiAfQgF9Ih9CAFINAAsLICBC4ABUDQADQCACIAEpAAA3AAAgAiABKQAYNwAYIAIgASkAEDcAECACIAEpAAg3AAggAiABKQA4NwA4IAIgASkAMDcAMCACIAEpACg3ACggAiABKQAgNwAgIAIgASkAWDcAWCACIAEpAFA3AFAgAiABKQBINwBIIAIgASkAQDcAQCACIAEpAGA3AGAgAiABKQBoNwBoIAIgASkAcDcAcCACIAEpAHg3AHggAUGAAWohASACQYABaiECIBpCgAF9IhpCH1YNAAsLIBpCEFoEQCACIAEpAAA3AAAgAiABKQAINwAIIBpCEH0hGiACQRBqIQIgAUEQaiEBCyAaQghaBEAgAiABKQAANwAAIBpCCH0hGiACQQhqIQIgAUEIaiEBCyAaQgRaBEAgAiABKAAANgAAIBpCBH0hGiACQQRqIQIgAUEEaiEBCyAaQgJaBEAgAiABLwAAOwAAIBpCAn0hGiACQQJqIQIgAUECaiEBCyAcIBt9IRwgGlBFBEAgAiABLQAAOgAAIAJBAWohAiABQQFqIQELIBxCAFINAAsLIAUgBmshAUEAIARrIQUCQCAEQQdLBEAgBCEDDAELIAEgBE0EQCAEIQMMAQsgAiAEayEFA0ACQCACIAUpAAA3AAAgBEEBdCEDIAEgBGshASACIARqIQIgBEEDSw0AIAMhBCABIANLDQELC0EAIANrIQULIAIgBWohBAJAIAUgDyACa6wiGiABrSIbIBogG1QbIhqnIgFIIAVBf0pxDQAgBUEBSCABIARqIAJLcQ0AIAIgBCABEAcgAWohAgwDCyACIAQgAyADQR91IgFqIAFzIgEQByABaiECIBogAa0iHn0iHFANAiABIARqIQEDQAJAIBwgHiAcIB5UGyIbQiBUBEAgGyEaDAELIBsiGkIgfSIgQgWIQgF8QgODIh9QRQRAA0AgAiABKQAANwAAIAIgASkAGDcAGCACIAEpABA3ABAgAiABKQAINwAIIBpCIH0hGiABQSBqIQEgAkEgaiECIB9CAX0iH0IAUg0ACwsgIELgAFQNAANAIAIgASkAADcAACACIAEpABg3ABggAiABKQAQNwAQIAIgASkACDcACCACIAEpADg3ADggAiABKQAwNwAwIAIgASkAKDcAKCACIAEpACA3ACAgAiABKQBYNwBYIAIgASkAUDcAUCACIAEpAEg3AEggAiABKQBANwBAIAIgASkAYDcAYCACIAEpAGg3AGggAiABKQBwNwBwIAIgASkAeDcAeCABQYABaiEBIAJBgAFqIQIgGkKAAX0iGkIfVg0ACwsgGkIQWgRAIAIgASkAADcAACACIAEpAAg3AAggGkIQfSEaIAJBEGohAiABQRBqIQELIBpCCFoEQCACIAEpAAA3AAAgGkIIfSEaIAJBCGohAiABQQhqIQELIBpCBFoEQCACIAEoAAA2AAAgGkIEfSEaIAJBBGohAiABQQRqIQELIBpCAloEQCACIAEvAAA7AAAgGkICfSEaIAJBAmohAiABQQJqIQELIBwgG30hHCAaUEUEQCACIAEtAAA6AAAgAkEBaiECIAFBAWohAQsgHFBFDQALDAILAkAgASADTSABIA8gAWusIhogBa0iGyAaIBtUGyIapyIEaiICIANLcQ0AIAMgBGogAUsgASADT3ENACABIAMgBBAHGgwCCyABIAMgAyABayIBIAFBH3UiAWogAXMiARAHIAFqIQIgGiABrSIefSIcUA0BIAEgA2ohAQNAAkAgHCAeIBwgHlQbIhtCIFQEQCAbIRoMAQsgGyIaQiB9IiBCBYhCAXxCA4MiH1BFBEADQCACIAEpAAA3AAAgAiABKQAYNwAYIAIgASkAEDcAECACIAEpAAg3AAggGkIgfSEaIAFBIGohASACQSBqIQIgH0IBfSIfQgBSDQALCyAgQuAAVA0AA0AgAiABKQAANwAAIAIgASkAGDcAGCACIAEpABA3ABAgAiABKQAINwAIIAIgASkAODcAOCACIAEpADA3ADAgAiABKQAoNwAoIAIgASkAIDcAICACIAEpAFg3AFggAiABKQBQNwBQIAIgASkASDcASCACIAEpAEA3AEAgAiABKQBgNwBgIAIgASkAaDcAaCACIAEpAHA3AHAgAiABKQB4NwB4IAFBgAFqIQEgAkGAAWohAiAaQoABfSIaQh9WDQALCyAaQhBaBEAgAiABKQAANwAAIAIgASkACDcACCAaQhB9IRogAkEQaiECIAFBEGohAQsgGkIIWgRAIAIgASkAADcAACAaQgh9IRogAkEIaiECIAFBCGohAQsgGkIEWgRAIAIgASgAADYAACAaQgR9IRogAkEEaiECIAFBBGohAQsgGkICWgRAIAIgAS8AADsAACAaQgJ9IRogAkECaiECIAFBAmohAQsgHCAbfSEcIBpQRQRAIAIgAS0AADoAACACQQFqIQIgAUEBaiEBCyAcUEUNAAsMAQsCQAJAIBYEQAJAIAQgBUkEQCAHKAKYRyAESw0BCyABIARrIQMCQEEAIARrIgVBf0ogDyABa6wiGiAbIBogG1QbIhqnIgIgBUpxDQAgBUEBSCACIANqIAFLcQ0AIAEgAyACEAcgAmohAgwFCyABIAMgBCAEQR91IgFqIAFzIgEQByABaiECIBogAa0iHn0iHFANBCABIANqIQEDQAJAIBwgHiAcIB5UGyIbQiBUBEAgGyEaDAELIBsiGkIgfSIgQgWIQgF8QgODIh9QRQRAA0AgAiABKQAANwAAIAIgASkAGDcAGCACIAEpABA3ABAgAiABKQAINwAIIBpCIH0hGiABQSBqIQEgAkEgaiECIB9CAX0iH0IAUg0ACwsgIELgAFQNAANAIAIgASkAADcAACACIAEpABg3ABggAiABKQAQNwAQIAIgASkACDcACCACIAEpADg3ADggAiABKQAwNwAwIAIgASkAKDcAKCACIAEpACA3ACAgAiABKQBYNwBYIAIgASkAUDcAUCACIAEpAEg3AEggAiABKQBANwBAIAIgASkAYDcAYCACIAEpAGg3AGggAiABKQBwNwBwIAIgASkAeDcAeCABQYABaiEBIAJBgAFqIQIgGkKAAX0iGkIfVg0ACwsgGkIQWgRAIAIgASkAADcAACACIAEpAAg3AAggGkIQfSEaIAJBEGohAiABQRBqIQELIBpCCFoEQCACIAEpAAA3AAAgGkIIfSEaIAJBCGohAiABQQhqIQELIBpCBFoEQCACIAEoAAA2AAAgGkIEfSEaIAJBBGohAiABQQRqIQELIBpCAloEQCACIAEvAAA7AAAgGkICfSEaIAJBAmohAiABQQJqIQELIBwgG30hHCAaUEUEQCACIAEtAAA6AAAgAkEBaiECIAFBAWohAQsgHFBFDQALDAQLIBAgAWsiCUEBaiIGIAUgBSAGSxshAyABIARrIQIgAUEHcUUNAiADRQ0CIAEgAi0AADoAACACQQFqIQIgAUEBaiIGQQdxQQAgA0EBayIFGw0BIAYhASAFIQMgCSEGDAILAkAgBCAFSQRAIAcoAphHIARLDQELIAEgASAEayIGKQAANwAAIAEgBUEBa0EHcUEBaiIDaiECIAUgA2siBEUNAyADIAZqIQEDQCACIAEpAAA3AAAgAUEIaiEBIAJBCGohAiAEQQhrIgQNAAsMAwsgASAEIAUQPyECDAILIAEgAi0AADoAASAJQQFrIQYgA0ECayEFIAJBAWohAgJAIAFBAmoiCkEHcUUNACAFRQ0AIAEgAi0AADoAAiAJQQJrIQYgA0EDayEFIAJBAWohAgJAIAFBA2oiCkEHcUUNACAFRQ0AIAEgAi0AADoAAyAJQQNrIQYgA0EEayEFIAJBAWohAgJAIAFBBGoiCkEHcUUNACAFRQ0AIAEgAi0AADoABCAJQQRrIQYgA0EFayEFIAJBAWohAgJAIAFBBWoiCkEHcUUNACAFRQ0AIAEgAi0AADoABSAJQQVrIQYgA0EGayEFIAJBAWohAgJAIAFBBmoiCkEHcUUNACAFRQ0AIAEgAi0AADoABiAJQQZrIQYgA0EHayEFIAJBAWohAgJAIAFBB2oiCkEHcUUNACAFRQ0AIAEgAi0AADoAByAJQQdrIQYgA0EIayEDIAFBCGohASACQQFqIQIMBgsgCiEBIAUhAwwFCyAKIQEgBSEDDAQLIAohASAFIQMMAwsgCiEBIAUhAwwCCyAKIQEgBSEDDAELIAohASAFIQMLAkACQCAGQRdNBEAgA0UNASADQQFrIQUgA0EHcSIEBEADQCABIAItAAA6AAAgA0EBayEDIAFBAWohASACQQFqIQIgBEEBayIEDQALCyAFQQdJDQEDQCABIAItAAA6AAAgASACLQABOgABIAEgAi0AAjoAAiABIAItAAM6AAMgASACLQAEOgAEIAEgAi0ABToABSABIAItAAY6AAYgASACLQAHOgAHIAFBCGohASACQQhqIQIgA0EIayIDDQALDAELIAMNAQsgASECDAELIAEgBCADED8hAgsgCyEFDAELIAEgAy0AAjoAACABQQFqIQILIAUgFE8NACACIBNJDQELCyAAIAI2AgwgACAFIAhBA3ZrIgE2AgAgACATIAJrQYMCajYCECAAIBQgAWtBDmo2AgQgByAIQQdxIgA2AogBIAcgHUJ/IACthkJ/hYM+AoQBC+cFAQR/IAMgAiACIANLGyEEIAAgAWshAgJAIABBB3FFDQAgBEUNACAAIAItAAA6AAAgA0EBayEGIAJBAWohAiAAQQFqIgdBB3FBACAEQQFrIgUbRQRAIAchACAFIQQgBiEDDAELIAAgAi0AADoAASADQQJrIQYgBEECayEFIAJBAWohAgJAIABBAmoiB0EHcUUNACAFRQ0AIAAgAi0AADoAAiADQQNrIQYgBEEDayEFIAJBAWohAgJAIABBA2oiB0EHcUUNACAFRQ0AIAAgAi0AADoAAyADQQRrIQYgBEEEayEFIAJBAWohAgJAIABBBGoiB0EHcUUNACAFRQ0AIAAgAi0AADoABCADQQVrIQYgBEEFayEFIAJBAWohAgJAIABBBWoiB0EHcUUNACAFRQ0AIAAgAi0AADoABSADQQZrIQYgBEEGayEFIAJBAWohAgJAIABBBmoiB0EHcUUNACAFRQ0AIAAgAi0AADoABiADQQdrIQYgBEEHayEFIAJBAWohAgJAIABBB2oiB0EHcUUNACAFRQ0AIAAgAi0AADoAByADQQhrIQMgBEEIayEEIABBCGohACACQQFqIQIMBgsgByEAIAUhBCAGIQMMBQsgByEAIAUhBCAGIQMMBAsgByEAIAUhBCAGIQMMAwsgByEAIAUhBCAGIQMMAgsgByEAIAUhBCAGIQMMAQsgByEAIAUhBCAGIQMLAkAgA0EXTQRAIARFDQEgBEEBayEBIARBB3EiAwRAA0AgACACLQAAOgAAIARBAWshBCAAQQFqIQAgAkEBaiECIANBAWsiAw0ACwsgAUEHSQ0BA0AgACACLQAAOgAAIAAgAi0AAToAASAAIAItAAI6AAIgACACLQADOgADIAAgAi0ABDoABCAAIAItAAU6AAUgACACLQAGOgAGIAAgAi0ABzoAByAAQQhqIQAgAkEIaiECIARBCGsiBA0ACwwBCyAERQ0AIAAgASAEED8hAAsgAAvyCAEXfyAAKAJoIgwgACgCMEGGAmsiBWtBACAFIAxJGyENIAAoAnQhAiAAKAKQASEPIAAoAkgiDiAMaiIJIAAoAnAiBUECIAUbIgVBAWsiBmoiAy0AASESIAMtAAAhEyAGIA5qIQZBAyEDIAAoApQBIRYgACgCPCEUIAAoAkwhECAAKAI4IRECQAJ/IAVBA0kEQCANIQggDgwBCyAAIABBACAJLQABIAAoAnwRAAAgCS0AAiAAKAJ8EQAAIQoDQCAAIAogAyAJai0AACAAKAJ8EQAAIQogACgCUCAKQQF0ai8BACIIIAEgCCABQf//A3FJIggbIQEgA0ECayAHIAgbIQcgA0EBaiIDIAVNDQALIAFB//8DcSAHIA1qIghB//8DcU0NASAGIAdB//8DcSIDayEGIA4gA2sLIQMCQAJAIAwgAUH//wNxTQ0AIAIgAkECdiAFIA9JGyEKIA1B//8DcSEVIAlBAmohDyAJQQRrIRcDQAJAAkAgBiABQf//A3EiC2otAAAgE0cNACAGIAtBAWoiAWotAAAgEkcNACADIAtqIgItAAAgCS0AAEcNACABIANqLQAAIAktAAFGDQELIApBAWsiCkUNAiAQIAsgEXFBAXRqLwEAIgEgCEH//wNxSw0BDAILIAJBAmohAUEAIQQgDyECAkADQCACLQAAIAEtAABHDQEgAi0AASABLQABRwRAIARBAXIhBAwCCyACLQACIAEtAAJHBEAgBEECciEEDAILIAItAAMgAS0AA0cEQCAEQQNyIQQMAgsgAi0ABCABLQAERwRAIARBBHIhBAwCCyACLQAFIAEtAAVHBEAgBEEFciEEDAILIAItAAYgAS0ABkcEQCAEQQZyIQQMAgsgAi0AByABLQAHRwRAIARBB3IhBAwCCyABQQhqIQEgAkEIaiECIARB+AFJIRggBEEIaiEEIBgNAAtBgAIhBAsCQAJAIAUgBEECaiICSQRAIAAgCyAHQf//A3FrIgY2AmwgAiAUSwRAIBQPCyACIBZPBEAgAg8LIAkgBEEBaiIFaiIBLQABIRIgAS0AACETAkAgAkEESQ0AIAIgBmogDE8NACAGQf//A3EhCCAEQQFrIQtBACEDQQAhBwNAIBAgAyAIaiARcUEBdGovAQAiASAGQf//A3FJBEAgAyAVaiABTw0IIAMhByABIQYLIANBAWoiAyALTQ0ACyAAIAAgAEEAIAIgF2oiAS0AACAAKAJ8EQAAIAEtAAEgACgCfBEAACABLQACIAAoAnwRAAAhASAAKAJQIAFBAXRqLwEAIgEgBkH//wNxTwRAIAdB//8DcSEDIAYhAQwDCyAEQQJrIgdB//8DcSIDIBVqIAFPDQYMAgsgAyAFaiEGIAIhBQsgCkEBayIKRQ0DIBAgCyARcUEBdGovAQAiASAIQf//A3FNDQMMAQsgByANaiEIIA4gA2siAyAFaiEGIAIhBQsgDCABQf//A3FLDQALCyAFDwsgAiEFCyAFIAAoAjwiACAAIAVLGwuGBQETfyAAKAJ0IgMgA0ECdiAAKAJwIgNBAiADGyIDIAAoApABSRshByAAKAJoIgogACgCMEGGAmsiBWtB//8DcUEAIAUgCkkbIQwgACgCSCIIIApqIgkgA0EBayICaiIFLQABIQ0gBS0AACEOIAlBAmohBSACIAhqIQsgACgClAEhEiAAKAI8IQ8gACgCTCEQIAAoAjghESAAKAKIAUEFSCETA0ACQCAKIAFB//8DcU0NAANAAkACQCALIAFB//8DcSIGai0AACAORw0AIAsgBkEBaiIBai0AACANRw0AIAYgCGoiAi0AACAJLQAARw0AIAEgCGotAAAgCS0AAUYNAQsgB0EBayIHRQ0CIAwgECAGIBFxQQF0ai8BACIBSQ0BDAILCyACQQJqIQRBACECIAUhAQJAA0AgAS0AACAELQAARw0BIAEtAAEgBC0AAUcEQCACQQFyIQIMAgsgAS0AAiAELQACRwRAIAJBAnIhAgwCCyABLQADIAQtAANHBEAgAkEDciECDAILIAEtAAQgBC0ABEcEQCACQQRyIQIMAgsgAS0ABSAELQAFRwRAIAJBBXIhAgwCCyABLQAGIAQtAAZHBEAgAkEGciECDAILIAEtAAcgBC0AB0cEQCACQQdyIQIMAgsgBEEIaiEEIAFBCGohASACQfgBSSEUIAJBCGohAiAUDQALQYACIQILAkAgAyACQQJqIgFJBEAgACAGNgJsIAEgD0sEQCAPDwsgASASTwRAIAEPCyAIIAJBAWoiA2ohCyADIAlqIgMtAAEhDSADLQAAIQ4gASEDDAELIBMNAQsgB0EBayIHRQ0AIAwgECAGIBFxQQF0ai8BACIBSQ0BCwsgAwvLAQECfwJAA0AgAC0AACABLQAARw0BIAAtAAEgAS0AAUcEQCACQQFyDwsgAC0AAiABLQACRwRAIAJBAnIPCyAALQADIAEtAANHBEAgAkEDcg8LIAAtAAQgAS0ABEcEQCACQQRyDwsgAC0ABSABLQAFRwRAIAJBBXIPCyAALQAGIAEtAAZHBEAgAkEGcg8LIAAtAAcgAS0AB0cEQCACQQdyDwsgAUEIaiEBIABBCGohACACQfgBSSEDIAJBCGohAiADDQALQYACIQILIAIL5wwBB38gAEF/cyEAIAJBF08EQAJAIAFBA3FFDQAgAS0AACAAQf8BcXNBAnRB0BhqKAIAIABBCHZzIQAgAkEBayIEQQAgAUEBaiIDQQNxG0UEQCAEIQIgAyEBDAELIAEtAAEgAEH/AXFzQQJ0QdAYaigCACAAQQh2cyEAIAFBAmohAwJAIAJBAmsiBEUNACADQQNxRQ0AIAEtAAIgAEH/AXFzQQJ0QdAYaigCACAAQQh2cyEAIAFBA2ohAwJAIAJBA2siBEUNACADQQNxRQ0AIAEtAAMgAEH/AXFzQQJ0QdAYaigCACAAQQh2cyEAIAFBBGohASACQQRrIQIMAgsgBCECIAMhAQwBCyAEIQIgAyEBCyACQRRuIgNBbGwhCQJAIANBAWsiCEUEQEEAIQQMAQsgA0EUbCABakEUayEDQQAhBANAIAEoAhAgB3MiB0EWdkH8B3FB0DhqKAIAIAdBDnZB/AdxQdAwaigCACAHQQZ2QfwHcUHQKGooAgAgB0H/AXFBAnRB0CBqKAIAc3NzIQcgASgCDCAGcyIGQRZ2QfwHcUHQOGooAgAgBkEOdkH8B3FB0DBqKAIAIAZBBnZB/AdxQdAoaigCACAGQf8BcUECdEHQIGooAgBzc3MhBiABKAIIIAVzIgVBFnZB/AdxQdA4aigCACAFQQ52QfwHcUHQMGooAgAgBUEGdkH8B3FB0ChqKAIAIAVB/wFxQQJ0QdAgaigCAHNzcyEFIAEoAgQgBHMiBEEWdkH8B3FB0DhqKAIAIARBDnZB/AdxQdAwaigCACAEQQZ2QfwHcUHQKGooAgAgBEH/AXFBAnRB0CBqKAIAc3NzIQQgASgCACAAcyIAQRZ2QfwHcUHQOGooAgAgAEEOdkH8B3FB0DBqKAIAIABBBnZB/AdxQdAoaigCACAAQf8BcUECdEHQIGooAgBzc3MhACABQRRqIQEgCEEBayIIDQALIAMhAQsgAiAJaiECIAEoAhAgASgCDCABKAIIIAEoAgQgASgCACAAcyIAQQh2IABB/wFxQQJ0QdAYaigCAHMiAEEIdiAAQf8BcUECdEHQGGooAgBzIgBBCHYgAEH/AXFBAnRB0BhqKAIAcyIAQf8BcUECdEHQGGooAgAgBHNzIABBCHZzIgBBCHYgAEH/AXFBAnRB0BhqKAIAcyIAQQh2IABB/wFxQQJ0QdAYaigCAHMiAEEIdiAAQf8BcUECdEHQGGooAgBzIgBB/wFxQQJ0QdAYaigCACAFc3MgAEEIdnMiAEEIdiAAQf8BcUECdEHQGGooAgBzIgBBCHYgAEH/AXFBAnRB0BhqKAIAcyIAQQh2IABB/wFxQQJ0QdAYaigCAHMiAEH/AXFBAnRB0BhqKAIAIAZzcyAAQQh2cyIAQQh2IABB/wFxQQJ0QdAYaigCAHMiAEEIdiAAQf8BcUECdEHQGGooAgBzIgBBCHYgAEH/AXFBAnRB0BhqKAIAcyIAQf8BcUECdEHQGGooAgAgB3NzIABBCHZzIgBBCHYgAEH/AXFBAnRB0BhqKAIAcyIAQQh2IABB/wFxQQJ0QdAYaigCAHMiAEEIdiAAQf8BcUECdEHQGGooAgBzIgBBCHYgAEH/AXFBAnRB0BhqKAIAcyEAIAFBFGohAQsgAkEHSwRAA0AgAS0AByABLQAGIAEtAAUgAS0ABCABLQADIAEtAAIgAS0AASABLQAAIABB/wFxc0ECdEHQGGooAgAgAEEIdnMiAEH/AXFzQQJ0QdAYaigCACAAQQh2cyIAQf8BcXNBAnRB0BhqKAIAIABBCHZzIgBB/wFxc0ECdEHQGGooAgAgAEEIdnMiAEH/AXFzQQJ0QdAYaigCACAAQQh2cyIAQf8BcXNBAnRB0BhqKAIAIABBCHZzIgBB/wFxc0ECdEHQGGooAgAgAEEIdnMiAEH/AXFzQQJ0QdAYaigCACAAQQh2cyEAIAFBCGohASACQQhrIgJBB0sNAAsLAkAgAkUNACACQQFxBH8gAS0AACAAQf8BcXNBAnRB0BhqKAIAIABBCHZzIQAgAUEBaiEBIAJBAWsFIAILIQMgAkEBRg0AA0AgAS0AASABLQAAIABB/wFxc0ECdEHQGGooAgAgAEEIdnMiAEH/AXFzQQJ0QdAYaigCACAAQQh2cyEAIAFBAmohASADQQJrIgMNAAsLIABBf3MLwgIBA38jAEEQayIIJAACfwJAIAAEQCAEDQEgBVANAQsgBgRAIAZBADYCBCAGQRI2AgALQQAMAQtBgAEQCSIHRQRAIAYEQCAGQQA2AgQgBkEONgIAC0EADAELIAcgATcDCCAHQgA3AwAgB0EoaiIJECogByAFNwMYIAcgBDYCECAHIAM6AGAgB0EANgJsIAdCADcCZCAAKQMYIQEgCEF/NgIIIAhCjoCAgPAANwMAIAdBECAIECQgAUL/gQGDhCIBNwNwIAcgAadBBnZBAXE6AHgCQCACRQ0AIAkgAhBgQX9KDQAgBxAGQQAMAQsgBhBfIgIEQCAAIAAoAjBBAWo2AjAgAiAHNgIIIAJBATYCBCACIAA2AgAgAkI/IAAgB0EAQgBBDkEBEQoAIgEgAUIAUxs3AxgLIAILIQAgCEEQaiQAIAALYgEBf0E4EAkiAUUEQCAABEAgAEEANgIEIABBDjYCAAtBAA8LIAFBADYCCCABQgA3AwAgAUIANwMgIAFCgICAgBA3AiwgAUEAOgAoIAFBADYCFCABQgA3AgwgAUEAOwE0IAELuwEBAX4gASkDACICQgKDUEUEQCAAIAEpAxA3AxALIAJCBINQRQRAIAAgASkDGDcDGAsgAkIIg1BFBEAgACABKQMgNwMgCyACQhCDUEUEQCAAIAEoAig2AigLIAJCIINQRQRAIAAgASgCLDYCLAsgAkLAAINQRQRAIAAgAS8BMDsBMAsgAkKAAYNQRQRAIAAgAS8BMjsBMgsgAkKAAoNQRQRAIAAgASgCNDYCNAsgACAAKQMAIAKENwMAQQALGQAgAUUEQEEADwsgACABKAIAIAEzAQQQGws3AQJ/IABBACABG0UEQCAAIAFGDwsgAC8BBCIDIAEvAQRGBH8gACgCACABKAIAIAMQPQVBAQtFCyIBAX8gAUUEQEEADwsgARAJIgJFBEBBAA8LIAIgACABEAcLKQAgACABIAIgAyAEEEUiAEUEQEEADwsgACACQQAgBBA1IQEgABAGIAELcQEBfgJ/AkAgAkJ/VwRAIAMEQCADQQA2AgQgA0EUNgIACwwBCyAAIAEgAhARIgRCf1cEQCADBEAgAyAAKAIMNgIAIAMgACgCEDYCBAsMAQtBACACIARXDQEaIAMEQCADQQA2AgQgA0ERNgIACwtBfwsLNQAgACABIAJBABAmIgBFBEBBfw8LIAMEQCADIAAtAAk6AAALIAQEQCAEIAAoAkQ2AgALQQAL/AECAn8BfiMAQRBrIgMkAAJAIAAgA0EOaiABQYAGQQAQRiIARQRAIAIhAAwBCyADLwEOIgFBBUkEQCACIQAMAQsgAC0AAEEBRwRAIAIhAAwBCyAAIAGtQv//A4MQFyIBRQRAIAIhAAwBCyABEH0aAkAgARAVIAIEfwJ/IAIvAQQhAEEAIAIoAgAiBEUNABpBACAEIABB1IABKAIAEQAACwVBAAtHBEAgAiEADAELIAEgAS0AAAR+IAEpAwggASkDEH0FQgALIgVC//8DgxATIAWnQf//A3FBgBBBABA1IgBFBEAgAiEADAELIAIQEAsgARAICyADQRBqJAAgAAvmDwIIfwJ+IwBB4ABrIgckAEEeQS4gAxshCwJAAkAgAgRAIAIiBSIGLQAABH4gBikDCCAGKQMQfQVCAAsgC61aDQEgBARAIARBADYCBCAEQRM2AgALQn8hDQwCCyABIAutIAcgBBAtIgUNAEJ/IQ0MAQsgBUIEEBMoAABBoxJBqBIgAxsoAABHBEAgBARAIARBADYCBCAEQRM2AgALQn8hDSACDQEgBRAIDAELIABCADcDICAAQQA2AhggAEL/////DzcDECAAQQA7AQwgAEG/hig2AgggAEEBOgAGIABBADsBBCAAQQA2AgAgAEIANwNIIABBgIDYjXg2AkQgAEIANwMoIABCADcDMCAAQgA3AzggAEFAa0EAOwEAIABCADcDUCAAIAMEf0EABSAFEAwLOwEIIAAgBRAMOwEKIAAgBRAMOwEMIAAgBRAMNgIQIAUQDCEGIAUQDCEJIAdBADYCWCAHQgA3A1AgB0IANwNIIAcgCUEfcTYCPCAHIAZBC3Y2AjggByAGQQV2QT9xNgI0IAcgBkEBdEE+cTYCMCAHIAlBCXZB0ABqNgJEIAcgCUEFdkEPcUEBazYCQCAAIAdBMGoQBTYCFCAAIAUQFTYCGCAAIAUQFa03AyAgACAFEBWtNwMoIAUQDCEIIAUQDCEGIAACfiADBEBBACEJIABBADYCRCAAQQA7AUAgAEEANgI8QgAMAQsgBRAMIQkgACAFEAw2AjwgACAFEAw7AUAgACAFEBU2AkQgBRAVrQs3A0ggBS0AAEUEQCAEBEAgBEEANgIEIARBFDYCAAtCfyENIAINASAFEAgMAQsCQCAALwEMIgpBAXEEQCAKQcAAcQRAIABB//8DOwFSDAILIABBATsBUgwBCyAAQQA7AVILIABBADYCOCAAQgA3AzAgBiAIaiAJaiEKAkAgAgRAIAUtAAAEfiAFKQMIIAUpAxB9BUIACyAKrVoNASAEBEAgBEEANgIEIARBFTYCAAtCfyENDAILIAUQCCABIAqtQQAgBBAtIgUNAEJ/IQ0MAQsCQCAIRQ0AIAAgBSABIAhBASAEEGQiCDYCMCAIRQRAIAQoAgBBEUYEQCAEBEAgBEEANgIEIARBFTYCAAsLQn8hDSACDQIgBRAIDAILIAAtAA1BCHFFDQAgCEECECNBBUcNACAEBEAgBEEANgIEIARBFTYCAAtCfyENIAINASAFEAgMAQsgAEE0aiEIAkAgBkUNACAFIAEgBkEAIAQQRSIMRQRAQn8hDSACDQIgBRAIDAILIAwgBkGAAkGABCADGyAIIAQQbiEGIAwQBiAGRQRAQn8hDSACDQIgBRAIDAILIANFDQAgAEEBOgAECwJAIAlFDQAgACAFIAEgCUEAIAQQZCIBNgI4IAFFBEBCfyENIAINAiAFEAgMAgsgAC0ADUEIcUUNACABQQIQI0EFRw0AIAQEQCAEQQA2AgQgBEEVNgIAC0J/IQ0gAg0BIAUQCAwBCyAAIAAoAjRB9eABIAAoAjAQZzYCMCAAIAAoAjRB9cYBIAAoAjgQZzYCOAJAAkAgACkDKEL/////D1ENACAAKQMgQv////8PUQ0AIAApA0hC/////w9SDQELAkACQAJAIAgoAgAgB0EwakEBQYACQYAEIAMbIAQQRiIBRQRAIAJFDQEMAgsgASAHMwEwEBciAUUEQCAEBEAgBEEANgIEIARBDjYCAAsgAkUNAQwCCwJAIAApAyhC/////w9RBEAgACABEB03AygMAQsgA0UNAEEAIQYCQCABKQMQIg5CCHwiDSAOVA0AIAEpAwggDVQNACABIA03AxBBASEGCyABIAY6AAALIAApAyBC/////w9RBEAgACABEB03AyALAkAgAw0AIAApA0hC/////w9RBEAgACABEB03A0gLIAAoAjxB//8DRw0AIAAgARAVNgI8CyABLQAABH8gASkDECABKQMIUQVBAAsNAiAEBEAgBEEANgIEIARBFTYCAAsgARAIIAINAQsgBRAIC0J/IQ0MAgsgARAICyAFLQAARQRAIAQEQCAEQQA2AgQgBEEUNgIAC0J/IQ0gAg0BIAUQCAwBCyACRQRAIAUQCAtCfyENIAApA0hCf1cEQCAEBEAgBEEWNgIEIARBBDYCAAsMAQsjAEEQayIDJABBASEBAkAgACgCEEHjAEcNAEEAIQECQCAAKAI0IANBDmpBgbICQYAGQQAQRiICBEAgAy8BDiIFQQZLDQELIAQEQCAEQQA2AgQgBEEVNgIACwwBCyACIAWtQv//A4MQFyICRQRAIAQEQCAEQQA2AgQgBEEUNgIACwwBC0EBIQECQAJAAkAgAhAMQQFrDgICAQALQQAhASAEBEAgBEEANgIEIARBGDYCAAsgAhAIDAILIAApAyhCE1YhAQsgAkICEBMvAABBwYoBRwRAQQAhASAEBEAgBEEANgIEIARBGDYCAAsgAhAIDAELIAIQfUEBayIFQf8BcUEDTwRAQQAhASAEBEAgBEEANgIEIARBGDYCAAsgAhAIDAELIAMvAQ5BB0cEQEEAIQEgBARAIARBADYCBCAEQRU2AgALIAIQCAwBCyAAIAE6AAYgACAFQf8BcUGBAmo7AVIgACACEAw2AhAgAhAIQQEhAQsgA0EQaiQAIAFFDQAgCCAIKAIAEG02AgAgCiALaq0hDQsgB0HgAGokACANC4ECAQR/IwBBEGsiBCQAAkAgASAEQQxqQcAAQQAQJSIGRQ0AIAQoAgxBBWoiA0GAgARPBEAgAgRAIAJBADYCBCACQRI2AgALDAELQQAgA60QFyIDRQRAIAIEQCACQQA2AgQgAkEONgIACwwBCyADQQEQcCADIAEEfwJ/IAEvAQQhBUEAIAEoAgAiAUUNABpBACABIAVB1IABKAIAEQAACwVBAAsQEiADIAYgBCgCDBAsAn8gAy0AAEUEQCACBEAgAkEANgIEIAJBFDYCAAtBAAwBCyAAIAMtAAAEfiADKQMQBUIAC6dB//8DcSADKAIEEEcLIQUgAxAICyAEQRBqJAAgBQvgAQICfwF+QTAQCSICRQRAIAEEQCABQQA2AgQgAUEONgIAC0EADwsgAkIANwMIIAJBADYCACACQgA3AxAgAkIANwMYIAJCADcDICACQgA3ACUgAFAEQCACDwsCQCAAQv////8AVg0AIACnQQR0EAkiA0UNACACIAM2AgBBACEBQgEhBANAIAMgAUEEdGoiAUIANwIAIAFCADcABSAAIARSBEAgBKchASAEQgF8IQQMAQsLIAIgADcDCCACIAA3AxAgAg8LIAEEQCABQQA2AgQgAUEONgIAC0EAEBAgAhAGQQAL7gECA38BfiMAQRBrIgQkAAJAIARBDGpCBBAXIgNFBEBBfyECDAELAkAgAQRAIAJBgAZxIQUDQAJAIAUgASgCBHFFDQACQCADKQMIQgBUBEAgA0EAOgAADAELIANCADcDECADQQE6AAALIAMgAS8BCBANIAMgAS8BChANIAMtAABFBEAgAEEIaiIABEAgAEEANgIEIABBFDYCAAtBfyECDAQLQX8hAiAAIARBDGpCBBAbQQBIDQMgATMBCiIGUA0AIAAgASgCDCAGEBtBAEgNAwsgASgCACIBDQALC0EAIQILIAMQCAsgBEEQaiQAIAILPAEBfyAABEAgAUGABnEhAQNAIAEgACgCBHEEQCACIAAvAQpqQQRqIQILIAAoAgAiAA0ACwsgAkH//wNxC5wBAQN/IABFBEBBAA8LIAAhAwNAAn8CQAJAIAAvAQgiAUH04AFNBEAgAUEBRg0BIAFB9cYBRg0BDAILIAFBgbICRg0AIAFB9eABRw0BCyAAKAIAIQEgAEEANgIAIAAoAgwQBiAAEAYgASADIAAgA0YbIQMCQCACRQRAQQAhAgwBCyACIAE2AgALIAEMAQsgACICKAIACyIADQALIAMLsgQCBX8BfgJAAkACQCAAIAGtEBciAQRAIAEtAAANAUEAIQAMAgsgBARAIARBADYCBCAEQQ42AgALQQAPC0EAIQADQCABLQAABH4gASkDCCABKQMQfQVCAAtCBFQNASABEAwhByABIAEQDCIGrRATIghFBEBBACECIAQEQCAEQQA2AgQgBEEVNgIACyABEAggAEUNAwNAIAAoAgAhASAAKAIMEAYgABAGIAEiAA0ACwwDCwJAAkBBEBAJIgUEQCAFIAY7AQogBSAHOwEIIAUgAjYCBCAFQQA2AgAgBkUNASAFIAggBhBjIgY2AgwgBg0CIAUQBgtBACECIAQEQCAEQQA2AgQgBEEONgIACyABEAggAEUNBANAIAAoAgAhASAAKAIMEAYgABAGIAEiAA0ACwwECyAFQQA2AgwLAkAgAEUEQCAFIQAMAQsgCSAFNgIACyAFIQkgAS0AAA0ACwsCQCABLQAABH8gASkDECABKQMIUQVBAAsNACABIAEtAAAEfiABKQMIIAEpAxB9BUIACyIKQv////8PgxATIQICQCAKpyIFQQNLDQAgAkUNACACQcEUIAUQPUUNAQtBACECIAQEQCAEQQA2AgQgBEEVNgIACyABEAggAEUNAQNAIAAoAgAhASAAKAIMEAYgABAGIAEiAA0ACwwBCyABEAggAwRAIAMgADYCAEEBDwtBASECIABFDQADQCAAKAIAIQEgACgCDBAGIAAQBiABIgANAAsLIAILvgEBBX8gAAR/IAAhAgNAIAIiBCgCACICDQALIAEEQANAIAEiAy8BCCEGIAMoAgAhASAAIQICQAJAA0ACQCACLwEIIAZHDQAgAi8BCiIFIAMvAQpHDQAgBUUNAiACKAIMIAMoAgwgBRA9RQ0CCyACKAIAIgINAAsgA0EANgIAIAQgAzYCACADIQQMAQsgAiACKAIEIAMoAgRBgAZxcjYCBCADQQA2AgAgAygCDBAGIAMQBgsgAQ0ACwsgAAUgAQsLVQICfgF/AkACQCAALQAARQ0AIAApAxAiAkIBfCIDIAJUDQAgAyAAKQMIWA0BCyAAQQA6AAAPCyAAKAIEIgRFBEAPCyAAIAM3AxAgBCACp2ogAToAAAt9AQN/IwBBEGsiAiQAIAIgATYCDEF/IQMCQCAALQAoDQACQCAAKAIAIgRFDQAgBCABEHFBf0oNACAAKAIAIQEgAEEMaiIABEAgACABKAIMNgIAIAAgASgCEDYCBAsMAQsgACACQQxqQgRBExAOQj+HpyEDCyACQRBqJAAgAwvdAQEDfyABIAApAzBaBEAgAEEIagRAIABBADYCDCAAQRI2AggLQX8PCyAAQQhqIQIgAC0AGEECcQRAIAIEQCACQQA2AgQgAkEZNgIAC0F/DwtBfyEDAkAgACABQQAgAhBTIgRFDQAgACgCUCAEIAIQfkUNAAJ/IAEgACkDMFoEQCAAQQhqBEAgAEEANgIMIABBEjYCCAtBfwwBCyABp0EEdCICIAAoAkBqKAIEECAgACgCQCACaiICQQA2AgQgAhBAQQALDQAgACgCQCABp0EEdGpBAToADEEAIQMLIAMLpgIBBX9BfyEFAkAgACABQQBBABAmRQ0AIAAtABhBAnEEQCAAQQhqIgAEQCAAQQA2AgQgAEEZNgIAC0F/DwsCfyAAKAJAIgQgAaciBkEEdGooAgAiBUUEQCADQYCA2I14RyEHQQMMAQsgBSgCRCADRyEHIAUtAAkLIQggBCAGQQR0aiIEIQYgBCgCBCEEQQAgAiAIRiAHG0UEQAJAIAQNACAGIAUQKyIENgIEIAQNACAAQQhqIgAEQCAAQQA2AgQgAEEONgIAC0F/DwsgBCADNgJEIAQgAjoACSAEIAQoAgBBEHI2AgBBAA8LQQAhBSAERQ0AIAQgBCgCAEFvcSIANgIAIABFBEAgBBAgIAZBADYCBEEADwsgBCADNgJEIAQgCDoACQsgBQvjCAIFfwR+IAAtABhBAnEEQCAAQQhqBEAgAEEANgIMIABBGTYCCAtCfw8LIAApAzAhCwJAIANBgMAAcQRAIAAgASADQQAQTCIJQn9SDQELAn4CQAJAIAApAzAiCUIBfCIMIAApAzgiClQEQCAAKAJAIQQMAQsgCkIBhiIJQoAIIAlCgAhUGyIJQhAgCUIQVhsgCnwiCadBBHQiBK0gCkIEhkLw////D4NUDQEgACgCQCAEEDQiBEUNASAAIAk3AzggACAENgJAIAApAzAiCUIBfCEMCyAAIAw3AzAgBCAJp0EEdGoiBEIANwIAIARCADcABSAJDAELIABBCGoEQCAAQQA2AgwgAEEONgIIC0J/CyIJQgBZDQBCfw8LAkAgAUUNAAJ/QQAhBCAJIAApAzBaBEAgAEEIagRAIABBADYCDCAAQRI2AggLQX8MAQsgAC0AGEECcQRAIABBCGoEQCAAQQA2AgwgAEEZNgIIC0F/DAELAkAgAUUNACABLQAARQ0AQX8gASABECJB//8DcSADIABBCGoQNSIERQ0BGiADQYAwcQ0AIARBABAjQQNHDQAgBEECNgIICwJAIAAgAUEAQQAQTCIKQgBTIgENACAJIApRDQAgBBAQIABBCGoEQCAAQQA2AgwgAEEKNgIIC0F/DAELAkAgAUEBIAkgClEbRQ0AAkACfwJAIAAoAkAiASAJpyIFQQR0aiIGKAIAIgMEQCADKAIwIAQQYg0BCyAEIAYoAgQNARogBiAGKAIAECsiAzYCBCAEIAMNARogAEEIagRAIABBADYCDCAAQQ42AggLDAILQQEhByAGKAIAKAIwC0EAQQAgAEEIaiIDECUiCEUNAAJAAkAgASAFQQR0aiIFKAIEIgENACAGKAIAIgENAEEAIQEMAQsgASgCMCIBRQRAQQAhAQwBCyABQQBBACADECUiAUUNAQsgACgCUCAIIAlBACADEE1FDQAgAQRAIAAoAlAgAUEAEH4aCyAFKAIEIQMgBwRAIANFDQIgAy0AAEECcUUNAiADKAIwEBAgBSgCBCIBIAEoAgBBfXEiAzYCACADRQRAIAEQICAFQQA2AgQgBBAQQQAMBAsgASAGKAIAKAIwNgIwIAQQEEEADAMLIAMoAgAiAUECcQRAIAMoAjAQECAFKAIEIgMoAgAhAQsgAyAENgIwIAMgAUECcjYCAEEADAILIAQQEEF/DAELIAQQEEEAC0UNACALIAApAzBRBEBCfw8LIAAoAkAgCadBBHRqED4gACALNwMwQn8PCyAJpyIGQQR0IgEgACgCQGoQQAJAAkAgACgCQCIEIAFqIgMoAgAiBUUNAAJAIAMoAgQiAwRAIAMoAgAiAEEBcUUNAQwCCyAFECshAyAAKAJAIgQgBkEEdGogAzYCBCADRQ0CIAMoAgAhAAsgA0F+NgIQIAMgAEEBcjYCAAsgASAEaiACNgIIIAkPCyAAQQhqBEAgAEEANgIMIABBDjYCCAtCfwteAQF/IwBBEGsiAiQAAn8gACgCJEEBRwRAIABBDGoiAARAIABBADYCBCAAQRI2AgALQX8MAQsgAkEANgIIIAIgATcDACAAIAJCEEEMEA5CP4enCyEAIAJBEGokACAAC9oDAQZ/IwBBEGsiBSQAIAUgAjYCDCMAQaABayIEJAAgBEEIakHA8ABBkAEQBxogBCAANgI0IAQgADYCHCAEQX4gAGsiA0H/////ByADQf////8HSRsiBjYCOCAEIAAgBmoiADYCJCAEIAA2AhggBEEIaiEAIwBB0AFrIgMkACADIAI2AswBIANBoAFqQQBBKBAZIAMgAygCzAE2AsgBAkBBACABIANByAFqIANB0ABqIANBoAFqEEpBAEgNACAAKAJMQQBOIQcgACgCACECIAAsAEpBAEwEQCAAIAJBX3E2AgALIAJBIHEhCAJ/IAAoAjAEQCAAIAEgA0HIAWogA0HQAGogA0GgAWoQSgwBCyAAQdAANgIwIAAgA0HQAGo2AhAgACADNgIcIAAgAzYCFCAAKAIsIQIgACADNgIsIAAgASADQcgBaiADQdAAaiADQaABahBKIAJFDQAaIABBAEEAIAAoAiQRAAAaIABBADYCMCAAIAI2AiwgAEEANgIcIABBADYCECAAKAIUGiAAQQA2AhRBAAsaIAAgACgCACAIcjYCACAHRQ0ACyADQdABaiQAIAYEQCAEKAIcIgAgACAEKAIYRmtBADoAAAsgBEGgAWokACAFQRBqJAALUwEDfwJAIAAoAgAsAABBMGtBCk8NAANAIAAoAgAiAiwAACEDIAAgAkEBajYCACABIANqQTBrIQEgAiwAAUEwa0EKTw0BIAFBCmwhAQwACwALIAELuwIAAkAgAUEUSw0AAkACQAJAAkACQAJAAkACQAJAAkAgAUEJaw4KAAECAwQFBgcICQoLIAIgAigCACIBQQRqNgIAIAAgASgCADYCAA8LIAIgAigCACIBQQRqNgIAIAAgATQCADcDAA8LIAIgAigCACIBQQRqNgIAIAAgATUCADcDAA8LIAIgAigCAEEHakF4cSIBQQhqNgIAIAAgASkDADcDAA8LIAIgAigCACIBQQRqNgIAIAAgATIBADcDAA8LIAIgAigCACIBQQRqNgIAIAAgATMBADcDAA8LIAIgAigCACIBQQRqNgIAIAAgATAAADcDAA8LIAIgAigCACIBQQRqNgIAIAAgATEAADcDAA8LIAIgAigCAEEHakF4cSIBQQhqNgIAIAAgASsDADkDAA8LIAAgAkEAEQcACwubAgAgAEUEQEEADwsCfwJAIAAEfyABQf8ATQ0BAkBB9IIBKAIAKAIARQRAIAFBgH9xQYC/A0YNAwwBCyABQf8PTQRAIAAgAUE/cUGAAXI6AAEgACABQQZ2QcABcjoAAEECDAQLIAFBgLADT0EAIAFBgEBxQYDAA0cbRQRAIAAgAUE/cUGAAXI6AAIgACABQQx2QeABcjoAACAAIAFBBnZBP3FBgAFyOgABQQMMBAsgAUGAgARrQf//P00EQCAAIAFBP3FBgAFyOgADIAAgAUESdkHwAXI6AAAgACABQQZ2QT9xQYABcjoAAiAAIAFBDHZBP3FBgAFyOgABQQQMBAsLQYSEAUEZNgIAQX8FQQELDAELIAAgAToAAEEBCwvjAQECfyACQQBHIQMCQAJAAkAgAEEDcUUNACACRQ0AIAFB/wFxIQQDQCAALQAAIARGDQIgAkEBayICQQBHIQMgAEEBaiIAQQNxRQ0BIAINAAsLIANFDQELAkAgAC0AACABQf8BcUYNACACQQRJDQAgAUH/AXFBgYKECGwhAwNAIAAoAgAgA3MiBEF/cyAEQYGChAhrcUGAgYKEeHENASAAQQRqIQAgAkEEayICQQNLDQALCyACRQ0AIAFB/wFxIQEDQCABIAAtAABGBEAgAA8LIABBAWohACACQQFrIgINAAsLQQALeQEBfAJAIABFDQAgACsDECAAKwMgIgIgAUQAAAAAAAAAACABRAAAAAAAAAAAZBsiAUQAAAAAAADwPyABRAAAAAAAAPA/YxsgACsDKCACoaKgIgEgACsDGKFjRQ0AIAAoAgAgASAAKAIMIAAoAgQRDgAgACABOQMYCwtIAQF8AkAgAEUNACAAKwMQIAArAyAiASAAKwMoIAGhoCIBIAArAxihY0UNACAAKAIAIAEgACgCDCAAKAIEEQ4AIAAgATkDGAsLWgICfgF/An8CQAJAIAAtAABFDQAgACkDECIBQgF8IgIgAVQNACACIAApAwhYDQELIABBADoAAEEADAELQQAgACgCBCIDRQ0AGiAAIAI3AxAgAyABp2otAAALC4IEAgZ/AX4gAEEAIAEbRQRAIAIEQCACQQA2AgQgAkESNgIAC0EADwsCQAJAIAApAwhQDQAgACgCECABLQAAIgQEf0Kl6wohCSABIQMDQCAJIAStQv8Bg3whCSADLQABIgQEQCADQQFqIQMgCUL/////D4NCIX4hCQwBCwsgCacFQYUqCyIEIAAoAgBwQQJ0aiIGKAIAIgNFDQADQAJAIAMoAhwgBEcNACABIAMoAgAQOA0AAkAgAykDCEJ/UQRAIAMoAhghAQJAIAUEQCAFIAE2AhgMAQsgBiABNgIACyADEAYgACAAKQMIQgF9Igk3AwggCbogACgCACIBuER7FK5H4XqEP6JjRQ0BIAFBgQJJDQECf0EAIQMgACgCACIGIAFBAXYiBUcEQCAFEDwiB0UEQCACBEAgAkEANgIEIAJBDjYCAAtBAAwCCwJAIAApAwhCACAGG1AEQCAAKAIQIQQMAQsgACgCECEEA0AgBCADQQJ0aigCACIBBEADQCABKAIYIQIgASAHIAEoAhwgBXBBAnRqIggoAgA2AhggCCABNgIAIAIiAQ0ACwsgA0EBaiIDIAZHDQALCyAEEAYgACAFNgIAIAAgBzYCEAtBAQsNAQwFCyADQn83AxALQQEPCyADIgUoAhgiAw0ACwsgAgRAIAJBADYCBCACQQk2AgALC0EAC6UGAgl/AX4jAEHwAGsiBSQAAkACQCAARQ0AAkAgAQRAIAEpAzAgAlYNAQtBACEDIABBCGoEQCAAQQA2AgwgAEESNgIICwwCCwJAIANBCHENACABKAJAIAKnQQR0aiIGKAIIRQRAIAYtAAxFDQELQQAhAyAAQQhqBEAgAEEANgIMIABBDzYCCAsMAgsgASACIANBCHIgBUE4ahCKAUF/TARAQQAhAyAAQQhqBEAgAEEANgIMIABBFDYCCAsMAgsgA0EDdkEEcSADciIGQQRxIQcgBSkDUCEOIAUvAWghCQJAIANBIHFFIAUvAWpBAEdxIgtFDQAgBA0AIAAoAhwiBA0AQQAhAyAAQQhqBEAgAEEANgIMIABBGjYCCAsMAgsgBSkDWFAEQCAAQQBCAEEAEFIhAwwCCwJAIAdFIgwgCUEAR3EiDUEBckUEQEEAIQMgBUEAOwEwIAUgDjcDICAFIA43AxggBSAFKAJgNgIoIAVC3AA3AwAgASgCACAOIAVBACABIAIgAEEIahBeIgYNAQwDC0EAIQMgASACIAYgAEEIaiIGECYiB0UNAiABKAIAIAUpA1ggBUE4aiAHLwEMQQF2QQNxIAEgAiAGEF4iBkUNAgsCfyAGIAE2AiwCQCABKAJEIghBAWoiCiABKAJIIgdJBEAgASgCTCEHDAELIAEoAkwgB0EKaiIIQQJ0EDQiB0UEQCABQQhqBEAgAUEANgIMIAFBDjYCCAtBfwwCCyABIAc2AkwgASAINgJIIAEoAkQiCEEBaiEKCyABIAo2AkQgByAIQQJ0aiAGNgIAQQALQX9MBEAgBhALDAELAkAgC0UEQCAGIQEMAQtBJkEAIAUvAWpBAUYbIgFFBEAgAEEIagRAIABBADYCDCAAQRg2AggLDAMLIAAgBiAFLwFqQQAgBCABEQYAIQEgBhALIAFFDQILAkAgDUUEQCABIQMMAQsgACABIAUvAWgQgQEhAyABEAsgA0UNAQsCQCAJRSAMckUEQCADIQEMAQsgACADQQEQgAEhASADEAsgAUUNAQsgASEDDAELQQAhAwsgBUHwAGokACADC4UBAQF/IAFFBEAgAEEIaiIABEAgAEEANgIEIABBEjYCAAtBAA8LQTgQCSIDRQRAIABBCGoiAARAIABBADYCBCAAQQ42AgALQQAPCyADQQA2AhAgA0IANwIIIANCADcDKCADQQA2AgQgAyACNgIAIANCADcDGCADQQA2AjAgACABQTsgAxBCCw8AIAAgASACQQBBABCCAQusAgECfyABRQRAIABBCGoiAARAIABBADYCBCAAQRI2AgALQQAPCwJAIAJBfUsNACACQf//A3FBCEYNACAAQQhqIgAEQCAAQQA2AgQgAEEQNgIAC0EADwsCQEGwwAAQCSIFBEAgBUEANgIIIAVCADcCACAFQYiBAUGogQEgAxs2AqhAIAUgAjYCFCAFIAM6ABAgBUEAOgAPIAVBADsBDCAFIAMgAkF9SyIGcToADiAFQQggAiAGG0H//wNxIAQgBUGIgQFBqIEBIAMbKAIAEQAAIgI2AqxAIAINASAFEDEgBRAGCyAAQQhqIgAEQCAAQQA2AgQgAEEONgIAC0EADwsgACABQTogBRBCIgAEfyAABSAFKAKsQCAFKAKoQCgCBBEDACAFEDEgBRAGQQALC6ABAQF/IAIgACgCBCIDIAIgA0kbIgIEQCAAIAMgAms2AgQCQAJAAkACQCAAKAIcIgMoAhRBAWsOAgEAAgsgA0GgAWogASAAKAIAIAJB3IABKAIAEQgADAILIAAgACgCMCABIAAoAgAgAkHEgAEoAgARBAA2AjAMAQsgASAAKAIAIAIQBxoLIAAgACgCACACajYCACAAIAAoAgggAmo2AggLC7cCAQR/QX4hAgJAIABFDQAgACgCIEUNACAAKAIkIgRFDQAgACgCHCIBRQ0AIAEoAgAgAEcNAAJAAkAgASgCICIDQTlrDjkBAgICAgICAgICAgIBAgICAQICAgICAgICAgICAgICAgICAQICAgICAgICAgICAQICAgICAgICAgEACyADQZoFRg0AIANBKkcNAQsCfwJ/An8gASgCBCICBEAgBCAAKAIoIAIQHiAAKAIcIQELIAEoAlAiAgsEQCAAKAIkIAAoAiggAhAeIAAoAhwhAQsgASgCTCICCwRAIAAoAiQgACgCKCACEB4gACgCHCEBCyABKAJIIgILBEAgACgCJCAAKAIoIAIQHiAAKAIcIQELIAAoAiQgACgCKCABEB4gAEEANgIcQX1BACADQfEARhshAgsgAgvrCQEIfyAAKAIwIgMgACgCDEEFayICIAIgA0sbIQggACgCACIEKAIEIQkgAUEERiEHAkADQCAEKAIQIgMgACgCoC5BKmpBA3UiAkkEQEEBIQYMAgsgCCADIAJrIgMgACgCaCAAKAJYayICIAQoAgRqIgVB//8DIAVB//8DSRsiBiADIAZJGyIDSwRAQQEhBiADQQBHIAdyRQ0CIAFFDQIgAyAFRw0CCyAAQQBBACAHIAMgBUZxIgUQOSAAIAAoAhBBBGsiBDYCECAAKAIEIARqIAM7AAAgACAAKAIQQQJqIgQ2AhAgACgCBCAEaiADQX9zOwAAIAAgACgCEEECajYCECAAKAIAEAoCfyACBEAgACgCACgCDCAAKAJIIAAoAlhqIAMgAiACIANLGyICEAcaIAAoAgAiBCAEKAIMIAJqNgIMIAQgBCgCECACazYCECAEIAQoAhQgAmo2AhQgACAAKAJYIAJqNgJYIAMgAmshAwsgAwsEQCAAKAIAIgIgAigCDCADEIMBIAAoAgAiAiACKAIMIANqNgIMIAIgAigCECADazYCECACIAIoAhQgA2o2AhQLIAAoAgAhBCAFRQ0AC0EAIQYLAkAgCSAEKAIEayICRQRAIAAoAmghAwwBCwJAIAAoAjAiAyACTQRAIABBAjYCgC4gACgCSCAEKAIAIANrIAMQBxogACAAKAIwIgM2AoQuIAAgAzYCaAwBCyACIAAoAkQgACgCaCIFa08EQCAAIAUgA2siBDYCaCAAKAJIIgUgAyAFaiAEEAcaIAAoAoAuIgNBAU0EQCAAIANBAWo2AoAuCyAAIAAoAmgiBSAAKAKELiIDIAMgBUsbNgKELiAAKAIAIQQLIAAoAkggBWogBCgCACACayACEAcaIAAgACgCaCACaiIDNgJoIAAgACgCMCAAKAKELiIEayIFIAIgAiAFSxsgBGo2AoQuCyAAIAM2AlgLIAAgAyAAKAJAIgIgAiADSRs2AkBBAyECAkAgBkUNACAAKAIAIgUoAgQhAgJAAkAgAUF7cUUNACACDQBBASECIAMgACgCWEYNAiAAKAJEIANrIQRBACECDAELIAIgACgCRCADayIETQ0AIAAoAlgiByAAKAIwIgZIDQAgACADIAZrIgM2AmggACAHIAZrNgJYIAAoAkgiAiACIAZqIAMQBxogACgCgC4iA0EBTQRAIAAgA0EBajYCgC4LIAAgACgCaCIDIAAoAoQuIgIgAiADSxs2AoQuIAAoAjAgBGohBCAAKAIAIgUoAgQhAgsCQCACIAQgAiAESRsiAkUEQCAAKAIwIQUMAQsgBSAAKAJIIANqIAIQgwEgACAAKAJoIAJqIgM2AmggACAAKAIwIgUgACgChC4iBGsiBiACIAIgBksbIARqNgKELgsgACADIAAoAkAiAiACIANJGzYCQCADIAAoAlgiBmsiAyAFIAAoAgwgACgCoC5BKmpBA3VrIgJB//8DIAJB//8DSRsiBCAEIAVLG0kEQEEAIQIgAUEERiADQQBHckUNASABRQ0BIAAoAgAoAgQNASADIARLDQELQQAhAiABQQRGBEAgACgCACgCBEUgAyAETXEhAgsgACAAKAJIIAZqIAQgAyADIARLGyIBIAIQOSAAIAAoAlggAWo2AlggACgCABAKQQJBACACGw8LIAIL/woCCn8DfiAAKQOYLiENIAAoAqAuIQQgAkEATgRAQQRBAyABLwECIggbIQlBB0GKASAIGyEFQX8hCgNAIAghByABIAsiDEEBaiILQQJ0ai8BAiEIAkACQCAGQQFqIgMgBU4NACAHIAhHDQAgAyEGDAELAkAgAyAJSARAIAAgB0ECdGoiBkHOFWohCSAGQcwVaiEKA0AgCjMBACEPAn8gBCAJLwEAIgZqIgVBP00EQCAPIASthiANhCENIAUMAQsgBEHAAEYEQCAAKAIEIAAoAhBqIA03AAAgACAAKAIQQQhqNgIQIA8hDSAGDAELIAAoAgQgACgCEGogDyAErYYgDYQ3AAAgACAAKAIQQQhqNgIQIA9BwAAgBGutiCENIAVBQGoLIQQgA0EBayIDDQALDAELIAcEQAJAIAcgCkYEQCANIQ8gBCEFIAMhBgwBCyAAIAdBAnRqIgNBzBVqMwEAIQ8gBCADQc4Vai8BACIDaiIFQT9NBEAgDyAErYYgDYQhDwwBCyAEQcAARgRAIAAoAgQgACgCEGogDTcAACAAIAAoAhBBCGo2AhAgAyEFDAELIAAoAgQgACgCEGogDyAErYYgDYQ3AAAgACAAKAIQQQhqNgIQIAVBQGohBSAPQcAAIARrrYghDwsgADMBjBYhDgJAIAUgAC8BjhYiBGoiA0E/TQRAIA4gBa2GIA+EIQ4MAQsgBUHAAEYEQCAAKAIEIAAoAhBqIA83AAAgACAAKAIQQQhqNgIQIAQhAwwBCyAAKAIEIAAoAhBqIA4gBa2GIA+ENwAAIAAgACgCEEEIajYCECADQUBqIQMgDkHAACAFa62IIQ4LIAasQgN9IQ0gA0E9TQRAIANBAmohBCANIAOthiAOhCENDAILIANBwABGBEAgACgCBCAAKAIQaiAONwAAIAAgACgCEEEIajYCEEECIQQMAgsgACgCBCAAKAIQaiANIAOthiAOhDcAACAAIAAoAhBBCGo2AhAgA0E+ayEEIA1BwAAgA2utiCENDAELIAZBCUwEQCAAMwGQFiEOAkAgBCAALwGSFiIFaiIDQT9NBEAgDiAErYYgDYQhDgwBCyAEQcAARgRAIAAoAgQgACgCEGogDTcAACAAIAAoAhBBCGo2AhAgBSEDDAELIAAoAgQgACgCEGogDiAErYYgDYQ3AAAgACAAKAIQQQhqNgIQIANBQGohAyAOQcAAIARrrYghDgsgBqxCAn0hDSADQTxNBEAgA0EDaiEEIA0gA62GIA6EIQ0MAgsgA0HAAEYEQCAAKAIEIAAoAhBqIA43AAAgACAAKAIQQQhqNgIQQQMhBAwCCyAAKAIEIAAoAhBqIA0gA62GIA6ENwAAIAAgACgCEEEIajYCECADQT1rIQQgDUHAACADa62IIQ0MAQsgADMBlBYhDgJAIAQgAC8BlhYiBWoiA0E/TQRAIA4gBK2GIA2EIQ4MAQsgBEHAAEYEQCAAKAIEIAAoAhBqIA03AAAgACAAKAIQQQhqNgIQIAUhAwwBCyAAKAIEIAAoAhBqIA4gBK2GIA2ENwAAIAAgACgCEEEIajYCECADQUBqIQMgDkHAACAEa62IIQ4LIAatQgp9IQ0gA0E4TQRAIANBB2ohBCANIAOthiAOhCENDAELIANBwABGBEAgACgCBCAAKAIQaiAONwAAIAAgACgCEEEIajYCEEEHIQQMAQsgACgCBCAAKAIQaiANIAOthiAOhDcAACAAIAAoAhBBCGo2AhAgA0E5ayEEIA1BwAAgA2utiCENC0EAIQYCfyAIRQRAQYoBIQVBAwwBC0EGQQcgByAIRiIDGyEFQQNBBCADGwshCSAHIQoLIAIgDEcNAAsLIAAgBDYCoC4gACANNwOYLgv5BQIIfwJ+AkAgACgC8C1FBEAgACkDmC4hCyAAKAKgLiEDDAELA0AgCSIDQQNqIQkgAyAAKALsLWoiAy0AAiEFIAApA5guIQwgACgCoC4hBAJAIAMvAAAiB0UEQCABIAVBAnRqIgMzAQAhCyAEIAMvAQIiBWoiA0E/TQRAIAsgBK2GIAyEIQsMAgsgBEHAAEYEQCAAKAIEIAAoAhBqIAw3AAAgACAAKAIQQQhqNgIQIAUhAwwCCyAAKAIEIAAoAhBqIAsgBK2GIAyENwAAIAAgACgCEEEIajYCECADQUBqIQMgC0HAACAEa62IIQsMAQsgBUGAzwBqLQAAIghBAnQiBiABaiIDQYQIajMBACELIANBhghqLwEAIQMgCEEIa0ETTQRAIAUgBkGA0QBqKAIAa60gA62GIAuEIQsgBkHA0wBqKAIAIANqIQMLIAMgAiAHQQFrIgcgB0EHdkGAAmogB0GAAkkbQYDLAGotAAAiBUECdCIIaiIKLwECaiEGIAozAQAgA62GIAuEIQsgBCAFQQRJBH8gBgUgByAIQYDSAGooAgBrrSAGrYYgC4QhCyAIQcDUAGooAgAgBmoLIgVqIgNBP00EQCALIASthiAMhCELDAELIARBwABGBEAgACgCBCAAKAIQaiAMNwAAIAAgACgCEEEIajYCECAFIQMMAQsgACgCBCAAKAIQaiALIASthiAMhDcAACAAIAAoAhBBCGo2AhAgA0FAaiEDIAtBwAAgBGutiCELCyAAIAs3A5guIAAgAzYCoC4gCSAAKALwLUkNAAsLIAFBgAhqMwEAIQwCQCADIAFBgghqLwEAIgJqIgFBP00EQCAMIAOthiALhCEMDAELIANBwABGBEAgACgCBCAAKAIQaiALNwAAIAAgACgCEEEIajYCECACIQEMAQsgACgCBCAAKAIQaiAMIAOthiALhDcAACAAIAAoAhBBCGo2AhAgAUFAaiEBIAxBwAAgA2utiCEMCyAAIAw3A5guIAAgATYCoC4L8AQBA38gAEHkAWohAgNAIAIgAUECdCIDakEAOwEAIAIgA0EEcmpBADsBACABQQJqIgFBngJHDQALIABBADsBzBUgAEEAOwHYEyAAQZQWakEAOwEAIABBkBZqQQA7AQAgAEGMFmpBADsBACAAQYgWakEAOwEAIABBhBZqQQA7AQAgAEGAFmpBADsBACAAQfwVakEAOwEAIABB+BVqQQA7AQAgAEH0FWpBADsBACAAQfAVakEAOwEAIABB7BVqQQA7AQAgAEHoFWpBADsBACAAQeQVakEAOwEAIABB4BVqQQA7AQAgAEHcFWpBADsBACAAQdgVakEAOwEAIABB1BVqQQA7AQAgAEHQFWpBADsBACAAQcwUakEAOwEAIABByBRqQQA7AQAgAEHEFGpBADsBACAAQcAUakEAOwEAIABBvBRqQQA7AQAgAEG4FGpBADsBACAAQbQUakEAOwEAIABBsBRqQQA7AQAgAEGsFGpBADsBACAAQagUakEAOwEAIABBpBRqQQA7AQAgAEGgFGpBADsBACAAQZwUakEAOwEAIABBmBRqQQA7AQAgAEGUFGpBADsBACAAQZAUakEAOwEAIABBjBRqQQA7AQAgAEGIFGpBADsBACAAQYQUakEAOwEAIABBgBRqQQA7AQAgAEH8E2pBADsBACAAQfgTakEAOwEAIABB9BNqQQA7AQAgAEHwE2pBADsBACAAQewTakEAOwEAIABB6BNqQQA7AQAgAEHkE2pBADsBACAAQeATakEAOwEAIABB3BNqQQA7AQAgAEIANwL8LSAAQeQJakEBOwEAIABBADYC+C0gAEEANgLwLQuKAwIGfwR+QcgAEAkiBEUEQEEADwsgBEIANwMAIARCADcDMCAEQQA2AiggBEIANwMgIARCADcDGCAEQgA3AxAgBEIANwMIIARCADcDOCABUARAIARBCBAJIgA2AgQgAEUEQCAEEAYgAwRAIANBADYCBCADQQ42AgALQQAPCyAAQgA3AwAgBA8LAkAgAaciBUEEdBAJIgZFDQAgBCAGNgIAIAVBA3RBCGoQCSIFRQ0AIAQgATcDECAEIAU2AgQDQCAAIAynIghBBHRqIgcpAwgiDVBFBEAgBygCACIHRQRAIAMEQCADQQA2AgQgA0ESNgIACyAGEAYgBRAGIAQQBkEADwsgBiAKp0EEdGoiCSANNwMIIAkgBzYCACAFIAhBA3RqIAs3AwAgCyANfCELIApCAXwhCgsgDEIBfCIMIAFSDQALIAQgCjcDCCAEQgAgCiACGzcDGCAFIAqnQQN0aiALNwMAIAQgCzcDMCAEDwsgAwRAIANBADYCBCADQQ42AgALIAYQBiAEEAZBAAvlAQIDfwF+QX8hBQJAIAAgASACQQAQJiIERQ0AIAAgASACEIsBIgZFDQACfgJAIAJBCHENACAAKAJAIAGnQQR0aigCCCICRQ0AIAIgAxAhQQBOBEAgAykDAAwCCyAAQQhqIgAEQCAAQQA2AgQgAEEPNgIAC0F/DwsgAxAqIAMgBCgCGDYCLCADIAQpAyg3AxggAyAEKAIUNgIoIAMgBCkDIDcDICADIAQoAhA7ATAgAyAELwFSOwEyQvwBQtwBIAQtAAYbCyEHIAMgBjYCCCADIAE3AxAgAyAHQgOENwMAQQAhBQsgBQspAQF/IAAgASACIABBCGoiABAmIgNFBEBBAA8LIAMoAjBBACACIAAQJQuAAwEGfwJ/An9BMCABQYB/Sw0BGgJ/IAFBgH9PBEBBhIQBQTA2AgBBAAwBC0EAQRAgAUELakF4cSABQQtJGyIFQcwAahAJIgFFDQAaIAFBCGshAgJAIAFBP3FFBEAgAiEBDAELIAFBBGsiBigCACIHQXhxIAFBP2pBQHFBCGsiASABQUBrIAEgAmtBD0sbIgEgAmsiA2shBCAHQQNxRQRAIAIoAgAhAiABIAQ2AgQgASACIANqNgIADAELIAEgBCABKAIEQQFxckECcjYCBCABIARqIgQgBCgCBEEBcjYCBCAGIAMgBigCAEEBcXJBAnI2AgAgAiADaiIEIAQoAgRBAXI2AgQgAiADEDsLAkAgASgCBCICQQNxRQ0AIAJBeHEiAyAFQRBqTQ0AIAEgBSACQQFxckECcjYCBCABIAVqIgIgAyAFayIFQQNyNgIEIAEgA2oiAyADKAIEQQFyNgIEIAIgBRA7CyABQQhqCyIBRQsEQEEwDwsgACABNgIAQQALCwoAIABBiIQBEAQL6AIBBX8gACgCUCEBIAAvATAhBEEEIQUDQCABQQAgAS8BACICIARrIgMgAiADSRs7AQAgAUEAIAEvAQIiAiAEayIDIAIgA0kbOwECIAFBACABLwEEIgIgBGsiAyACIANJGzsBBCABQQAgAS8BBiICIARrIgMgAiADSRs7AQYgBUGAgARGRQRAIAFBCGohASAFQQRqIQUMAQsLAkAgBEUNACAEQQNxIQUgACgCTCEBIARBAWtBA08EQCAEIAVrIQADQCABQQAgAS8BACICIARrIgMgAiADSRs7AQAgAUEAIAEvAQIiAiAEayIDIAIgA0kbOwECIAFBACABLwEEIgIgBGsiAyACIANJGzsBBCABQQAgAS8BBiICIARrIgMgAiADSRs7AQYgAUEIaiEBIABBBGsiAA0ACwsgBUUNAANAIAFBACABLwEAIgAgBGsiAiAAIAJJGzsBACABQQJqIQEgBUEBayIFDQALCwuDAQEEfyACQQFOBEAgAiAAKAJIIAFqIgJqIQMgACgCUCEEA0AgBCACKAAAQbHz3fF5bEEPdkH+/wdxaiIFLwEAIgYgAUH//wNxRwRAIAAoAkwgASAAKAI4cUH//wNxQQF0aiAGOwEAIAUgATsBAAsgAUEBaiEBIAJBAWoiAiADSQ0ACwsLUAECfyABIAAoAlAgACgCSCABaigAAEGx893xeWxBD3ZB/v8HcWoiAy8BACICRwRAIAAoAkwgACgCOCABcUEBdGogAjsBACADIAE7AQALIAILugEBAX8jAEEQayICJAAgAkEAOgAIQYCBAUECNgIAQfyAAUEDNgIAQfiAAUEENgIAQfSAAUEFNgIAQfCAAUEGNgIAQeyAAUEHNgIAQeiAAUEINgIAQeSAAUEJNgIAQeCAAUEKNgIAQdyAAUELNgIAQdiAAUEMNgIAQdSAAUENNgIAQdCAAUEONgIAQcyAAUEPNgIAQciAAUEQNgIAQcSAAUERNgIAQcCAAUESNgIAIAAgARBYIAJBEGokAAu9AQEBfyMAQRBrIgEkACABQQA6AAhBgIEBQQI2AgBB/IABQQM2AgBB+IABQQQ2AgBB9IABQQU2AgBB8IABQQY2AgBB7IABQQc2AgBB6IABQQg2AgBB5IABQQk2AgBB4IABQQo2AgBB3IABQQs2AgBB2IABQQw2AgBB1IABQQ02AgBB0IABQQ42AgBBzIABQQ82AgBByIABQRA2AgBBxIABQRE2AgBBwIABQRI2AgAgAEEANgJAIAFBEGokAEEAC70BAQF/IwBBEGsiASQAIAFBADoACEGAgQFBAjYCAEH8gAFBAzYCAEH4gAFBBDYCAEH0gAFBBTYCAEHwgAFBBjYCAEHsgAFBBzYCAEHogAFBCDYCAEHkgAFBCTYCAEHggAFBCjYCAEHcgAFBCzYCAEHYgAFBDDYCAEHUgAFBDTYCAEHQgAFBDjYCAEHMgAFBDzYCAEHIgAFBEDYCAEHEgAFBETYCAEHAgAFBEjYCACAAKAJAIQAgAUEQaiQAIAALvgEBAX8jAEEQayIEJAAgBEEAOgAIQYCBAUECNgIAQfyAAUEDNgIAQfiAAUEENgIAQfSAAUEFNgIAQfCAAUEGNgIAQeyAAUEHNgIAQeiAAUEINgIAQeSAAUEJNgIAQeCAAUEKNgIAQdyAAUELNgIAQdiAAUEMNgIAQdSAAUENNgIAQdCAAUEONgIAQcyAAUEPNgIAQciAAUEQNgIAQcSAAUERNgIAQcCAAUESNgIAIAAgASACIAMQVyAEQRBqJAALygEAIwBBEGsiAyQAIANBADoACEGAgQFBAjYCAEH8gAFBAzYCAEH4gAFBBDYCAEH0gAFBBTYCAEHwgAFBBjYCAEHsgAFBBzYCAEHogAFBCDYCAEHkgAFBCTYCAEHggAFBCjYCAEHcgAFBCzYCAEHYgAFBDDYCAEHUgAFBDTYCAEHQgAFBDjYCAEHMgAFBDzYCAEHIgAFBEDYCAEHEgAFBETYCAEHAgAFBEjYCACAAIAAoAkAgASACQdSAASgCABEAADYCQCADQRBqJAALwAEBAX8jAEEQayIDJAAgA0EAOgAIQYCBAUECNgIAQfyAAUEDNgIAQfiAAUEENgIAQfSAAUEFNgIAQfCAAUEGNgIAQeyAAUEHNgIAQeiAAUEINgIAQeSAAUEJNgIAQeCAAUEKNgIAQdyAAUELNgIAQdiAAUEMNgIAQdSAAUENNgIAQdCAAUEONgIAQcyAAUEPNgIAQciAAUEQNgIAQcSAAUERNgIAQcCAAUESNgIAIAAgASACEF0hACADQRBqJAAgAAu+AQEBfyMAQRBrIgIkACACQQA6AAhBgIEBQQI2AgBB/IABQQM2AgBB+IABQQQ2AgBB9IABQQU2AgBB8IABQQY2AgBB7IABQQc2AgBB6IABQQg2AgBB5IABQQk2AgBB4IABQQo2AgBB3IABQQs2AgBB2IABQQw2AgBB1IABQQ02AgBB0IABQQ42AgBBzIABQQ82AgBByIABQRA2AgBBxIABQRE2AgBBwIABQRI2AgAgACABEFwhACACQRBqJAAgAAu2AQEBfyMAQRBrIgAkACAAQQA6AAhBgIEBQQI2AgBB/IABQQM2AgBB+IABQQQ2AgBB9IABQQU2AgBB8IABQQY2AgBB7IABQQc2AgBB6IABQQg2AgBB5IABQQk2AgBB4IABQQo2AgBB3IABQQs2AgBB2IABQQw2AgBB1IABQQ02AgBB0IABQQ42AgBBzIABQQ82AgBByIABQRA2AgBBxIABQRE2AgBBwIABQRI2AgAgAEEQaiQAQQgLwgEBAX8jAEEQayIEJAAgBEEAOgAIQYCBAUECNgIAQfyAAUEDNgIAQfiAAUEENgIAQfSAAUEFNgIAQfCAAUEGNgIAQeyAAUEHNgIAQeiAAUEINgIAQeSAAUEJNgIAQeCAAUEKNgIAQdyAAUELNgIAQdiAAUEMNgIAQdSAAUENNgIAQdCAAUEONgIAQcyAAUEPNgIAQciAAUEQNgIAQcSAAUERNgIAQcCAAUESNgIAIAAgASACIAMQWSEAIARBEGokACAAC8IBAQF/IwBBEGsiBCQAIARBADoACEGAgQFBAjYCAEH8gAFBAzYCAEH4gAFBBDYCAEH0gAFBBTYCAEHwgAFBBjYCAEHsgAFBBzYCAEHogAFBCDYCAEHkgAFBCTYCAEHggAFBCjYCAEHcgAFBCzYCAEHYgAFBDDYCAEHUgAFBDTYCAEHQgAFBDjYCAEHMgAFBDzYCAEHIgAFBEDYCAEHEgAFBETYCAEHAgAFBEjYCACAAIAEgAiADEFYhACAEQRBqJAAgAAsHACAALwEwC8ABAQF/IwBBEGsiAyQAIANBADoACEGAgQFBAjYCAEH8gAFBAzYCAEH4gAFBBDYCAEH0gAFBBTYCAEHwgAFBBjYCAEHsgAFBBzYCAEHogAFBCDYCAEHkgAFBCTYCAEHggAFBCjYCAEHcgAFBCzYCAEHYgAFBDDYCAEHUgAFBDTYCAEHQgAFBDjYCAEHMgAFBDzYCAEHIgAFBEDYCAEHEgAFBETYCAEHAgAFBEjYCACAAIAEgAhBVIQAgA0EQaiQAIAALBwAgACgCQAsaACAAIAAoAkAgASACQdSAASgCABEAADYCQAsLACAAQQA2AkBBAAsHACAAKAIgCwQAQQgLzgUCA34BfyMAQYBAaiIIJAACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQCAEDhECAwwFAAEECAkJCQkJCQcJBgkLIANCCFoEfiACIAEoAmQ2AgAgAiABKAJoNgIEQggFQn8LIQYMCwsgARAGDAoLIAEoAhAiAgRAIAIgASkDGCABQeQAaiICEEEiA1ANCCABKQMIIgVCf4UgA1QEQCACBEAgAkEANgIEIAJBFTYCAAsMCQsgAUEANgIQIAEgAyAFfDcDCCABIAEpAwAgA3w3AwALIAEtAHgEQCABKQMAIQUMCQtCACEDIAEpAwAiBVAEQCABQgA3AyAMCgsDQCAAIAggBSADfSIFQoDAACAFQoDAAFQbEBEiB0J/VwRAIAFB5ABqIgEEQCABIAAoAgw2AgAgASAAKAIQNgIECwwJCyAHUEUEQCABKQMAIgUgAyAHfCIDWA0KDAELCyABQeQAagRAIAFBADYCaCABQRE2AmQLDAcLIAEpAwggASkDICIFfSIHIAMgAyAHVhsiA1ANCAJAIAEtAHhFDQAgACAFQQAQFEF/Sg0AIAFB5ABqIgEEQCABIAAoAgw2AgAgASAAKAIQNgIECwwHCyAAIAIgAxARIgZCf1cEQCABQeQAagRAIAFBADYCaCABQRE2AmQLDAcLIAEgASkDICAGfCIDNwMgIAZCAFINCEIAIQYgAyABKQMIWg0IIAFB5ABqBEAgAUEANgJoIAFBETYCZAsMBgsgASkDICABKQMAIgV9IAEpAwggBX0gAiADIAFB5ABqEEQiA0IAUw0FIAEgASkDACADfDcDIAwHCyACIAFBKGoQYEEfdawhBgwGCyABMABgIQYMBQsgASkDcCEGDAQLIAEpAyAgASkDAH0hBgwDCyABQeQAagRAIAFBADYCaCABQRw2AmQLC0J/IQYMAQsgASAFNwMgCyAIQYBAayQAIAYLBwAgACgCAAsPACAAIAAoAjBBAWo2AjALGABB+IMBQgA3AgBBgIQBQQA2AgBB+IMBCwcAIABBDGoLBwAgACgCLAsHACAAKAIoCwcAIAAoAhgLFQAgACABrSACrUIghoQgAyAEEIoBCxMBAX4gABAzIgFCIIinEAAgAacLbwEBfiABrSACrUIghoQhBSMAQRBrIgEkAAJ/IABFBEAgBVBFBEAgBARAIARBADYCBCAEQRI2AgALQQAMAgtBAEIAIAMgBBA6DAELIAEgBTcDCCABIAA2AgAgAUIBIAMgBBA6CyEAIAFBEGokACAACxQAIAAgASACrSADrUIghoQgBBBSC9oCAgJ/AX4CfyABrSACrUIghoQiByAAKQMwVEEAIARBCkkbRQRAIABBCGoEQCAAQQA2AgwgAEESNgIIC0F/DAELIAAtABhBAnEEQCAAQQhqBEAgAEEANgIMIABBGTYCCAtBfwwBCyADBH8gA0H//wNxQQhGIANBfUtyBUEBC0UEQCAAQQhqBEAgAEEANgIMIABBEDYCCAtBfwwBCyAAKAJAIgEgB6ciBUEEdGooAgAiAgR/IAIoAhAgA0YFIANBf0YLIQYgASAFQQR0aiIBIQUgASgCBCEBAkAgBgRAIAFFDQEgAUEAOwFQIAEgASgCAEF+cSIANgIAIAANASABECAgBUEANgIEQQAMAgsCQCABDQAgBSACECsiATYCBCABDQAgAEEIagRAIABBADYCDCAAQQ42AggLQX8MAgsgASAEOwFQIAEgAzYCECABIAEoAgBBAXI2AgALQQALCxwBAX4gACABIAIgAEEIahBMIgNCIIinEAAgA6cLHwEBfiAAIAEgAq0gA61CIIaEEBEiBEIgiKcQACAEpwteAQF+An5CfyAARQ0AGiAAKQMwIgIgAUEIcUUNABpCACACUA0AGiAAKAJAIQADQCACIAKnQQR0IABqQRBrKAIADQEaIAJCAX0iAkIAUg0AC0IACyICQiCIpxAAIAKnCxMAIAAgAa0gAq1CIIaEIAMQiwELnwEBAn4CfiACrSADrUIghoQhBUJ/IQQCQCAARQ0AIAAoAgQNACAAQQRqIQIgBUJ/VwRAIAIEQCACQQA2AgQgAkESNgIAC0J/DAILQgAhBCAALQAQDQAgBVANACAAKAIUIAEgBRARIgRCf1UNACAAKAIUIQAgAgRAIAIgACgCDDYCACACIAAoAhA2AgQLQn8hBAsgBAsiBEIgiKcQACAEpwueAQEBfwJ/IAAgACABrSACrUIghoQgAyAAKAIcEH8iAQRAIAEQMkF/TARAIABBCGoEQCAAIAEoAgw2AgggACABKAIQNgIMCyABEAtBAAwCC0EYEAkiBEUEQCAAQQhqBEAgAEEANgIMIABBDjYCCAsgARALQQAMAgsgBCAANgIAIARBADYCDCAEQgA3AgQgBCABNgIUIARBADoAEAsgBAsLsQICAX8BfgJ/QX8hBAJAIAAgAa0gAq1CIIaEIgZBAEEAECZFDQAgAC0AGEECcQRAIABBCGoEQCAAQQA2AgwgAEEZNgIIC0F/DAILIAAoAkAiASAGpyICQQR0aiIEKAIIIgUEQEEAIQQgBSADEHFBf0oNASAAQQhqBEAgAEEANgIMIABBDzYCCAtBfwwCCwJAIAQoAgAiBQRAIAUoAhQgA0YNAQsCQCABIAJBBHRqIgEoAgQiBA0AIAEgBRArIgQ2AgQgBA0AIABBCGoEQCAAQQA2AgwgAEEONgIIC0F/DAMLIAQgAzYCFCAEIAQoAgBBIHI2AgBBAAwCC0EAIQQgASACQQR0aiIBKAIEIgBFDQAgACAAKAIAQV9xIgI2AgAgAg0AIAAQICABQQA2AgQLIAQLCxQAIAAgAa0gAq1CIIaEIAQgBRBzCxIAIAAgAa0gAq1CIIaEIAMQFAtBAQF+An4gAUEAIAIbRQRAIABBCGoEQCAAQQA2AgwgAEESNgIIC0J/DAELIAAgASACIAMQdAsiBEIgiKcQACAEpwvGAwIFfwF+An4CQAJAIAAiBC0AGEECcQRAIARBCGoEQCAEQQA2AgwgBEEZNgIICwwBCyABRQRAIARBCGoEQCAEQQA2AgwgBEESNgIICwwBCyABECIiByABakEBay0AAEEvRwRAIAdBAmoQCSIARQRAIARBCGoEQCAEQQA2AgwgBEEONgIICwwCCwJAAkAgACIGIAEiBXNBA3ENACAFQQNxBEADQCAGIAUtAAAiAzoAACADRQ0DIAZBAWohBiAFQQFqIgVBA3ENAAsLIAUoAgAiA0F/cyADQYGChAhrcUGAgYKEeHENAANAIAYgAzYCACAFKAIEIQMgBkEEaiEGIAVBBGohBSADQYGChAhrIANBf3NxQYCBgoR4cUUNAAsLIAYgBS0AACIDOgAAIANFDQADQCAGIAUtAAEiAzoAASAGQQFqIQYgBUEBaiEFIAMNAAsLIAcgACIDakEvOwAACyAEQQBCAEEAEFIiAEUEQCADEAYMAQsgBCADIAEgAxsgACACEHQhCCADEAYgCEJ/VwRAIAAQCyAIDAMLIAQgCEEDQYCA/I8EEHNBf0oNASAEIAgQchoLQn8hCAsgCAsiCEIgiKcQACAIpwsQACAAIAGtIAKtQiCGhBByCxYAIAAgAa0gAq1CIIaEIAMgBCAFEGYL3iMDD38IfgF8IwBB8ABrIgkkAAJAIAFBAE5BACAAG0UEQCACBEAgAkEANgIEIAJBEjYCAAsMAQsgACkDGCISAn5BsIMBKQMAIhNCf1EEQCAJQoOAgIBwNwMwIAlChoCAgPAANwMoIAlCgYCAgCA3AyBBsIMBQQAgCUEgahAkNwMAIAlCj4CAgHA3AxAgCUKJgICAoAE3AwAgCUKMgICA0AE3AwhBuIMBQQggCRAkNwMAQbCDASkDACETCyATC4MgE1IEQCACBEAgAkEANgIEIAJBHDYCAAsMAQsgASABQRByQbiDASkDACITIBKDIBNRGyIKQRhxQRhGBEAgAgRAIAJBADYCBCACQRk2AgALDAELIAlBOGoQKgJAIAAgCUE4ahAhBEACQCAAKAIMQQVGBEAgACgCEEEsRg0BCyACBEAgAiAAKAIMNgIAIAIgACgCEDYCBAsMAgsgCkEBcUUEQCACBEAgAkEANgIEIAJBCTYCAAsMAwsgAhBJIgVFDQEgBSAKNgIEIAUgADYCACAKQRBxRQ0CIAUgBSgCFEECcjYCFCAFIAUoAhhBAnI2AhgMAgsgCkECcQRAIAIEQCACQQA2AgQgAkEKNgIACwwCCyAAEDJBf0wEQCACBEAgAiAAKAIMNgIAIAIgACgCEDYCBAsMAQsCfyAKQQhxBEACQCACEEkiAUUNACABIAo2AgQgASAANgIAIApBEHFFDQAgASABKAIUQQJyNgIUIAEgASgCGEECcjYCGAsgAQwBCyMAQUBqIg4kACAOQQhqECoCQCAAIA5BCGoQIUF/TARAIAIEQCACIAAoAgw2AgAgAiAAKAIQNgIECwwBCyAOLQAIQQRxRQRAIAIEQCACQYoBNgIEIAJBBDYCAAsMAQsgDikDICETIAIQSSIFRQRAQQAhBQwBCyAFIAo2AgQgBSAANgIAIApBEHEEQCAFIAUoAhRBAnI2AhQgBSAFKAIYQQJyNgIYCwJAAkACQCATUARAAn8gACEBAkADQCABKQMYQoCAEINCAFINASABKAIAIgENAAtBAQwBCyABQQBCAEESEA6nCw0EIAVBCGoEQCAFQQA2AgwgBUETNgIICwwBCyMAQdAAayIBJAACQCATQhVYBEAgBUEIagRAIAVBADYCDCAFQRM2AggLDAELAkACQCAFKAIAQgAgE0KqgAQgE0KqgARUGyISfUECEBRBf0oNACAFKAIAIgMoAgxBBEYEQCADKAIQQRZGDQELIAVBCGoEQCAFIAMoAgw2AgggBSADKAIQNgIMCwwBCyAFKAIAEDMiE0J/VwRAIAUoAgAhAyAFQQhqIggEQCAIIAMoAgw2AgAgCCADKAIQNgIECwwBCyAFKAIAIBJBACAFQQhqIg8QLSIERQ0BIBJCqoAEWgRAAkAgBCkDCEIUVARAIARBADoAAAwBCyAEQhQ3AxAgBEEBOgAACwsgAQRAIAFBADYCBCABQRM2AgALIARCABATIQwCQCAELQAABH4gBCkDCCAEKQMQfQVCAAunIgdBEmtBA0sEQEJ/IRcDQCAMQQFrIQMgByAMakEVayEGAkADQCADQQFqIgNB0AAgBiADaxB6IgNFDQEgA0EBaiIMQZ8SQQMQPQ0ACwJAIAMgBCgCBGusIhIgBCkDCFYEQCAEQQA6AAAMAQsgBCASNwMQIARBAToAAAsgBC0AAAR+IAQpAxAFQgALIRICQCAELQAABH4gBCkDCCAEKQMQfQVCAAtCFVgEQCABBEAgAUEANgIEIAFBEzYCAAsMAQsgBEIEEBMoAABB0JaVMEcEQCABBEAgAUEANgIEIAFBEzYCAAsMAQsCQAJAAkAgEkIUVA0AIAQoAgQgEqdqQRRrKAAAQdCWmThHDQACQCASQhR9IhQgBCIDKQMIVgRAIANBADoAAAwBCyADIBQ3AxAgA0EBOgAACyAFKAIUIRAgBSgCACEGIAMtAAAEfiAEKQMQBUIACyEWIARCBBATGiAEEAwhCyAEEAwhDSAEEB0iFEJ/VwRAIAEEQCABQRY2AgQgAUEENgIACwwECyAUQjh8IhUgEyAWfCIWVgRAIAEEQCABQQA2AgQgAUEVNgIACwwECwJAAkAgEyAUVg0AIBUgEyAEKQMIfFYNAAJAIBQgE30iFSAEKQMIVgRAIANBADoAAAwBCyADIBU3AxAgA0EBOgAAC0EAIQcMAQsgBiAUQQAQFEF/TARAIAEEQCABIAYoAgw2AgAgASAGKAIQNgIECwwFC0EBIQcgBkI4IAFBEGogARAtIgNFDQQLIANCBBATKAAAQdCWmTBHBEAgAQRAIAFBADYCBCABQRU2AgALIAdFDQQgAxAIDAQLIAMQHSEVAkAgEEEEcSIGRQ0AIBQgFXxCDHwgFlENACABBEAgAUEANgIEIAFBFTYCAAsgB0UNBCADEAgMBAsgA0IEEBMaIAMQFSIQIAsgC0H//wNGGyELIAMQFSIRIA0gDUH//wNGGyENAkAgBkUNACANIBFGQQAgCyAQRhsNACABBEAgAUEANgIEIAFBFTYCAAsgB0UNBCADEAgMBAsgCyANcgRAIAEEQCABQQA2AgQgAUEBNgIACyAHRQ0EIAMQCAwECyADEB0iGCADEB1SBEAgAQRAIAFBADYCBCABQQE2AgALIAdFDQQgAxAIDAQLIAMQHSEVIAMQHSEWIAMtAABFBEAgAQRAIAFBADYCBCABQRQ2AgALIAdFDQQgAxAIDAQLIAcEQCADEAgLAkAgFkIAWQRAIBUgFnwiGSAWWg0BCyABBEAgAUEWNgIEIAFBBDYCAAsMBAsgEyAUfCIUIBlUBEAgAQRAIAFBADYCBCABQRU2AgALDAQLAkAgBkUNACAUIBlRDQAgAQRAIAFBADYCBCABQRU2AgALDAQLIBggFUIugFgNASABBEAgAUEANgIEIAFBFTYCAAsMAwsCQCASIAQpAwhWBEAgBEEAOgAADAELIAQgEjcDECAEQQE6AAALIAUoAhQhAyAELQAABH4gBCkDCCAEKQMQfQVCAAtCFVgEQCABBEAgAUEANgIEIAFBFTYCAAsMAwsgBC0AAAR+IAQpAxAFQgALIRQgBEIEEBMaIAQQFQRAIAEEQCABQQA2AgQgAUEBNgIACwwDCyAEEAwgBBAMIgZHBEAgAQRAIAFBADYCBCABQRM2AgALDAMLIAQQFSEHIAQQFa0iFiAHrSIVfCIYIBMgFHwiFFYEQCABBEAgAUEANgIEIAFBFTYCAAsMAwsCQCADQQRxRQ0AIBQgGFENACABBEAgAUEANgIEIAFBFTYCAAsMAwsgBq0gARBqIgNFDQIgAyAWNwMgIAMgFTcDGCADQQA6ACwMAQsgGCABEGoiA0UNASADIBY3AyAgAyAVNwMYIANBAToALAsCQCASQhR8IhQgBCkDCFYEQCAEQQA6AAAMAQsgBCAUNwMQIARBAToAAAsgBBAMIQYCQCADKQMYIAMpAyB8IBIgE3xWDQACQCAGRQRAIAUtAARBBHFFDQELAkAgEkIWfCISIAQpAwhWBEAgBEEAOgAADAELIAQgEjcDECAEQQE6AAALIAQtAAAEfiAEKQMIIAQpAxB9BUIACyIUIAatIhJUDQEgBS0ABEEEcUEAIBIgFFIbDQEgBkUNACADIAQgEhATIAZBACABEDUiBjYCKCAGDQAgAxAWDAILAkAgEyADKQMgIhJYBEACQCASIBN9IhIgBCkDCFYEQCAEQQA6AAAMAQsgBCASNwMQIARBAToAAAsgBCADKQMYEBMiBkUNAiAGIAMpAxgQFyIHDQEgAQRAIAFBADYCBCABQQ42AgALIAMQFgwDCyAFKAIAIBJBABAUIQcgBSgCACEGIAdBf0wEQCABBEAgASAGKAIMNgIAIAEgBigCEDYCBAsgAxAWDAMLQQAhByAGEDMgAykDIFENACABBEAgAUEANgIEIAFBEzYCAAsgAxAWDAILQgAhFAJAAkAgAykDGCIWUEUEQANAIBQgAykDCFIiC0UEQCADLQAsDQMgFkIuVA0DAn8CQCADKQMQIhVCgIAEfCISIBVaQQAgEkKAgICAAVQbRQ0AIAMoAgAgEqdBBHQQNCIGRQ0AIAMgBjYCAAJAIAMpAwgiFSASWg0AIAYgFadBBHRqIgZCADcCACAGQgA3AAUgFUIBfCIVIBJRDQADQCADKAIAIBWnQQR0aiIGQgA3AgAgBkIANwAFIBVCAXwiFSASUg0ACwsgAyASNwMIIAMgEjcDEEEBDAELIAEEQCABQQA2AgQgAUEONgIAC0EAC0UNBAtB2AAQCSIGBH8gBkIANwMgIAZBADYCGCAGQv////8PNwMQIAZBADsBDCAGQb+GKDYCCCAGQQE6AAYgBkEAOwEEIAZBADYCACAGQgA3A0ggBkGAgNiNeDYCRCAGQgA3AyggBkIANwMwIAZCADcDOCAGQUBrQQA7AQAgBkIANwNQIAYFQQALIQYgAygCACAUp0EEdGogBjYCAAJAIAYEQCAGIAUoAgAgB0EAIAEQaCISQn9VDQELIAsNBCABKAIAQRNHDQQgAQRAIAFBADYCBCABQRU2AgALDAQLIBRCAXwhFCAWIBJ9IhZCAFINAAsLIBQgAykDCFINAAJAIAUtAARBBHFFDQAgBwRAIActAAAEfyAHKQMQIAcpAwhRBUEAC0UNAgwBCyAFKAIAEDMiEkJ/VwRAIAUoAgAhBiABBEAgASAGKAIMNgIAIAEgBigCEDYCBAsgAxAWDAULIBIgAykDGCADKQMgfFINAQsgBxAIAn4gCARAAn8gF0IAVwRAIAUgCCABEEghFwsgBSADIAEQSCISIBdVCwRAIAgQFiASDAILIAMQFgwFC0IAIAUtAARBBHFFDQAaIAUgAyABEEgLIRcgAyEIDAMLIAEEQCABQQA2AgQgAUEVNgIACyAHEAggAxAWDAILIAMQFiAHEAgMAQsgAQRAIAFBADYCBCABQRU2AgALIAMQFgsCQCAMIAQoAgRrrCISIAQpAwhWBEAgBEEAOgAADAELIAQgEjcDECAEQQE6AAALIAQtAAAEfiAEKQMIIAQpAxB9BUIAC6ciB0ESa0EDSw0BCwsgBBAIIBdCf1UNAwwBCyAEEAgLIA8iAwRAIAMgASgCADYCACADIAEoAgQ2AgQLIAgQFgtBACEICyABQdAAaiQAIAgNAQsgAgRAIAIgBSgCCDYCACACIAUoAgw2AgQLDAELIAUgCCgCADYCQCAFIAgpAwg3AzAgBSAIKQMQNwM4IAUgCCgCKDYCICAIEAYgBSgCUCEIIAVBCGoiBCEBQQAhBwJAIAUpAzAiE1ANAEGAgICAeCEGAn8gE7pEAAAAAAAA6D+jRAAA4P///+9BpCIaRAAAAAAAAPBBYyAaRAAAAAAAAAAAZnEEQCAaqwwBC0EACyIDQYCAgIB4TQRAIANBAWsiA0EBdiADciIDQQJ2IANyIgNBBHYgA3IiA0EIdiADciIDQRB2IANyQQFqIQYLIAYgCCgCACIMTQ0AIAYQPCILRQRAIAEEQCABQQA2AgQgAUEONgIACwwBCwJAIAgpAwhCACAMG1AEQCAIKAIQIQ8MAQsgCCgCECEPA0AgDyAHQQJ0aigCACIBBEADQCABKAIYIQMgASALIAEoAhwgBnBBAnRqIg0oAgA2AhggDSABNgIAIAMiAQ0ACwsgB0EBaiIHIAxHDQALCyAPEAYgCCAGNgIAIAggCzYCEAsCQCAFKQMwUA0AQgAhEwJAIApBBHFFBEADQCAFKAJAIBOnQQR0aigCACgCMEEAQQAgAhAlIgFFDQQgBSgCUCABIBNBCCAEEE1FBEAgBCgCAEEKRw0DCyATQgF8IhMgBSkDMFQNAAwDCwALA0AgBSgCQCATp0EEdGooAgAoAjBBAEEAIAIQJSIBRQ0DIAUoAlAgASATQQggBBBNRQ0BIBNCAXwiEyAFKQMwVA0ACwwBCyACBEAgAiAEKAIANgIAIAIgBCgCBDYCBAsMAQsgBSAFKAIUNgIYDAELIAAgACgCMEEBajYCMCAFEEtBACEFCyAOQUBrJAAgBQsiBQ0BIAAQGhoLQQAhBQsgCUHwAGokACAFCxAAIwAgAGtBcHEiACQAIAALBgAgACQACwQAIwAL4CoDEX8IfgN8IwBBwMAAayIHJABBfyECAkAgAEUNAAJ/IAAtAChFBEBBACAAKAIYIAAoAhRGDQEaC0EBCyEBAkACQCAAKQMwIhRQRQRAIAAoAkAhCgNAIAogEqdBBHRqIgMtAAwhCwJAAkAgAygCCA0AIAsNACADKAIEIgNFDQEgAygCAEUNAQtBASEBCyAXIAtBAXOtQv8Bg3whFyASQgF8IhIgFFINAAsgF0IAUg0BCyAAKAIEQQhxIAFyRQ0BAn8gACgCACIDKAIkIgFBA0cEQCADKAIgBH9BfyADEBpBAEgNAhogAygCJAUgAQsEQCADEEMLQX8gA0EAQgBBDxAOQgBTDQEaIANBAzYCJAtBAAtBf0oNASAAKAIAKAIMQRZGBEAgACgCACgCEEEsRg0CCyAAKAIAIQEgAEEIagRAIAAgASgCDDYCCCAAIAEoAhA2AgwLDAILIAFFDQAgFCAXVARAIABBCGoEQCAAQQA2AgwgAEEUNgIICwwCCyAXp0EDdBAJIgtFDQFCfyEWQgAhEgNAAkAgCiASp0EEdGoiBigCACIDRQ0AAkAgBigCCA0AIAYtAAwNACAGKAIEIgFFDQEgASgCAEUNAQsgFiADKQNIIhMgEyAWVhshFgsgBi0ADEUEQCAXIBlYBEAgCxAGIABBCGoEQCAAQQA2AgwgAEEUNgIICwwECyALIBmnQQN0aiASNwMAIBlCAXwhGQsgEkIBfCISIBRSDQALIBcgGVYEQCALEAYgAEEIagRAIABBADYCDCAAQRQ2AggLDAILAkACQCAAKAIAKQMYQoCACINQDQACQAJAIBZCf1INACAAKQMwIhNQDQIgE0IBgyEVIAAoAkAhAwJAIBNCAVEEQEJ/IRRCACESQgAhFgwBCyATQn6DIRlCfyEUQgAhEkIAIRYDQCADIBKnQQR0aigCACIBBEAgFiABKQNIIhMgEyAWVCIBGyEWIBQgEiABGyEUCyADIBJCAYQiGKdBBHRqKAIAIgEEQCAWIAEpA0giEyATIBZUIgEbIRYgFCAYIAEbIRQLIBJCAnwhEiAZQgJ9IhlQRQ0ACwsCQCAVUA0AIAMgEqdBBHRqKAIAIgFFDQAgFiABKQNIIhMgEyAWVCIBGyEWIBQgEiABGyEUCyAUQn9RDQBCACETIwBBEGsiBiQAAkAgACAUIABBCGoiCBBBIhVQDQAgFSAAKAJAIBSnQQR0aigCACIKKQMgIhh8IhQgGFpBACAUQn9VG0UEQCAIBEAgCEEWNgIEIAhBBDYCAAsMAQsgCi0ADEEIcUUEQCAUIRMMAQsgACgCACAUQQAQFCEBIAAoAgAhAyABQX9MBEAgCARAIAggAygCDDYCACAIIAMoAhA2AgQLDAELIAMgBkEMakIEEBFCBFIEQCAAKAIAIQEgCARAIAggASgCDDYCACAIIAEoAhA2AgQLDAELIBRCBHwgFCAGKAAMQdCWncAARhtCFEIMAn9BASEBAkAgCikDKEL+////D1YNACAKKQMgQv7///8PVg0AQQAhAQsgAQsbfCIUQn9XBEAgCARAIAhBFjYCBCAIQQQ2AgALDAELIBQhEwsgBkEQaiQAIBMiFkIAUg0BIAsQBgwFCyAWUA0BCwJ/IAAoAgAiASgCJEEBRgRAIAFBDGoEQCABQQA2AhAgAUESNgIMC0F/DAELQX8gAUEAIBZBERAOQgBTDQAaIAFBATYCJEEAC0F/Sg0BC0IAIRYCfyAAKAIAIgEoAiRBAUYEQCABQQxqBEAgAUEANgIQIAFBEjYCDAtBfwwBC0F/IAFBAEIAQQgQDkIAUw0AGiABQQE2AiRBAAtBf0oNACAAKAIAIQEgAEEIagRAIAAgASgCDDYCCCAAIAEoAhA2AgwLIAsQBgwCCyAAKAJUIgIEQCACQgA3AxggAigCAEQAAAAAAAAAACACKAIMIAIoAgQRDgALIABBCGohBCAXuiEcQgAhFAJAAkACQANAIBcgFCITUgRAIBO6IByjIRsgE0IBfCIUuiAcoyEaAkAgACgCVCICRQ0AIAIgGjkDKCACIBs5AyAgAisDECAaIBuhRAAAAAAAAAAAoiAboCIaIAIrAxihY0UNACACKAIAIBogAigCDCACKAIEEQ4AIAIgGjkDGAsCfwJAIAAoAkAgCyATp0EDdGopAwAiE6dBBHRqIg0oAgAiAQRAIAEpA0ggFlQNAQsgDSgCBCEFAkACfwJAIA0oAggiAkUEQCAFRQ0BQQEgBSgCACICQQFxDQIaIAJBwABxQQZ2DAILQQEgBQ0BGgsgDSABECsiBTYCBCAFRQ0BIAJBAEcLIQZBACEJIwBBEGsiDCQAAkAgEyAAKQMwWgRAIABBCGoEQCAAQQA2AgwgAEESNgIIC0F/IQkMAQsgACgCQCIKIBOnIgNBBHRqIg8oAgAiAkUNACACLQAEDQACQCACKQNIQhp8IhhCf1cEQCAAQQhqBEAgAEEWNgIMIABBBDYCCAsMAQtBfyEJIAAoAgAgGEEAEBRBf0wEQCAAKAIAIQIgAEEIagRAIAAgAigCDDYCCCAAIAIoAhA2AgwLDAILIAAoAgBCBCAMQQxqIABBCGoiDhAtIhBFDQEgEBAMIQEgEBAMIQggEC0AAAR/IBApAxAgECkDCFEFQQALIQIgEBAIIAJFBEAgDgRAIA5BADYCBCAOQRQ2AgALDAILAkAgCEUNACAAKAIAIAGtQQEQFEF/TARAQYSEASgCACECIA4EQCAOIAI2AgQgDkEENgIACwwDC0EAIAAoAgAgCEEAIA4QRSIBRQ0BIAEgCEGAAiAMQQhqIA4QbiECIAEQBiACRQ0BIAwoAggiAkUNACAMIAIQbSICNgIIIA8oAgAoAjQgAhBvIQIgDygCACACNgI0CyAPKAIAIgJBAToABEEAIQkgCiADQQR0aigCBCIBRQ0BIAEtAAQNASACKAI0IQIgAUEBOgAEIAEgAjYCNAwBC0F/IQkLIAxBEGokACAJQQBIDQUgACgCABAfIhhCAFMNBSAFIBg3A0ggBgRAQQAhDCANKAIIIg0hASANRQRAIAAgACATQQhBABB/IgwhASAMRQ0HCwJAAkAgASAHQQhqECFBf0wEQCAEBEAgBCABKAIMNgIAIAQgASgCEDYCBAsMAQsgBykDCCISQsAAg1AEQCAHQQA7ATggByASQsAAhCISNwMICwJAAkAgBSgCECICQX5PBEAgBy8BOCIDRQ0BIAUgAzYCECADIQIMAgsgAg0AIBJCBINQDQAgByAHKQMgNwMoIAcgEkIIhCISNwMIQQAhAgwBCyAHIBJC9////w+DIhI3AwgLIBJCgAGDUARAIAdBADsBOiAHIBJCgAGEIhI3AwgLAn8gEkIEg1AEQEJ/IRVBgAoMAQsgBSAHKQMgIhU3AyggEkIIg1AEQAJAAkACQAJAQQggAiACQX1LG0H//wNxDg0CAwMDAwMDAwEDAwMAAwtBgApBgAIgFUKUwuTzD1YbDAQLQYAKQYACIBVCg4Ow/w9WGwwDC0GACkGAAiAVQv////8PVhsMAgtBgApBgAIgFUIAUhsMAQsgBSAHKQMoNwMgQYACCyEPIAAoAgAQHyITQn9XBEAgACgCACECIAQEQCAEIAIoAgw2AgAgBCACKAIQNgIECwwBCyAFIAUvAQxB9/8DcTsBDCAAIAUgDxA3IgpBAEgNACAHLwE4IghBCCAFKAIQIgMgA0F9SxtB//8DcSICRyEGAkACQAJAAkACQAJAAkAgAiAIRwRAIANBAEchAwwBC0EAIQMgBS0AAEGAAXFFDQELIAUvAVIhCSAHLwE6IQIMAQsgBS8BUiIJIAcvAToiAkYNAQsgASABKAIwQQFqNgIwIAJB//8DcQ0BIAEhAgwCCyABIAEoAjBBAWo2AjBBACEJDAILQSZBACAHLwE6QQFGGyICRQRAIAQEQCAEQQA2AgQgBEEYNgIACyABEAsMAwsgACABIAcvATpBACAAKAIcIAIRBgAhAiABEAsgAkUNAgsgCUEARyEJIAhBAEcgBnFFBEAgAiEBDAELIAAgAiAHLwE4EIEBIQEgAhALIAFFDQELAkAgCEUgBnJFBEAgASECDAELIAAgAUEAEIABIQIgARALIAJFDQELAkAgA0UEQCACIQMMAQsgACACIAUoAhBBASAFLwFQEIIBIQMgAhALIANFDQELAkAgCUUEQCADIQEMAQsgBSgCVCIBRQRAIAAoAhwhAQsCfyAFLwFSGkEBCwRAIAQEQCAEQQA2AgQgBEEYNgIACyADEAsMAgsgACADIAUvAVJBASABQQARBgAhASADEAsgAUUNAQsgACgCABAfIhhCf1cEQCAAKAIAIQIgBARAIAQgAigCDDYCACAEIAIoAhA2AgQLDAELAkAgARAyQQBOBEACfwJAAkAgASAHQUBrQoDAABARIhJCAVMNAEIAIRkgFUIAVQRAIBW5IRoDQCAAIAdBQGsgEhAbQQBIDQMCQCASQoDAAFINACAAKAJUIgJFDQAgAiAZQoBAfSIZuSAaoxB7CyABIAdBQGtCgMAAEBEiEkIAVQ0ACwwBCwNAIAAgB0FAayASEBtBAEgNAiABIAdBQGtCgMAAEBEiEkIAVQ0ACwtBACASQn9VDQEaIAQEQCAEIAEoAgw2AgAgBCABKAIQNgIECwtBfwshAiABEBoaDAELIAQEQCAEIAEoAgw2AgAgBCABKAIQNgIEC0F/IQILIAEgB0EIahAhQX9MBEAgBARAIAQgASgCDDYCACAEIAEoAhA2AgQLQX8hAgsCf0EAIQkCQCABIgNFDQADQCADLQAaQQFxBEBB/wEhCSADQQBCAEEQEA4iFUIAUw0CIBVCBFkEQCADQQxqBEAgA0EANgIQIANBFDYCDAsMAwsgFachCQwCCyADKAIAIgMNAAsLIAlBGHRBGHUiA0F/TAsEQCAEBEAgBCABKAIMNgIAIAQgASgCEDYCBAsgARALDAELIAEQCyACQQBIDQAgACgCABAfIRUgACgCACECIBVCf1cEQCAEBEAgBCACKAIMNgIAIAQgAigCEDYCBAsMAQsgAiATEHVBf0wEQCAAKAIAIQIgBARAIAQgAigCDDYCACAEIAIoAhA2AgQLDAELIAcpAwgiE0LkAINC5ABSBEAgBARAIARBADYCBCAEQRQ2AgALDAELAkAgBS0AAEEgcQ0AIBNCEINQRQRAIAUgBygCMDYCFAwBCyAFQRRqEAEaCyAFIAcvATg2AhAgBSAHKAI0NgIYIAcpAyAhEyAFIBUgGH03AyAgBSATNwMoIAUgBS8BDEH5/wNxIANB/wFxQQF0cjsBDCAPQQp2IQNBPyEBAkACQAJAAkAgBSgCECICQQxrDgMAAQIBCyAFQS47AQoMAgtBLSEBIAMNACAFKQMoQv7///8PVg0AIAUpAyBC/v///w9WDQBBFCEBIAJBCEYNACAFLwFSQQFGDQAgBSgCMCICBH8gAi8BBAVBAAtB//8DcSICBEAgAiAFKAIwKAIAakEBay0AAEEvRg0BC0EKIQELIAUgATsBCgsgACAFIA8QNyICQQBIDQAgAiAKRwRAIAQEQCAEQQA2AgQgBEEUNgIACwwBCyAAKAIAIBUQdUF/Sg0BIAAoAgAhAiAEBEAgBCACKAIMNgIAIAQgAigCEDYCBAsLIA0NByAMEAsMBwsgDQ0CIAwQCwwCCyAFIAUvAQxB9/8DcTsBDCAAIAVBgAIQN0EASA0FIAAgEyAEEEEiE1ANBSAAKAIAIBNBABAUQX9MBEAgACgCACECIAQEQCAEIAIoAgw2AgAgBCACKAIQNgIECwwGCyAFKQMgIRIjAEGAQGoiAyQAAkAgElBFBEAgAEEIaiECIBK6IRoDQEF/IQEgACgCACADIBJCgMAAIBJCgMAAVBsiEyACEGVBAEgNAiAAIAMgExAbQQBIDQIgACgCVCAaIBIgE30iErqhIBqjEHsgEkIAUg0ACwtBACEBCyADQYBAayQAIAFBf0oNAUEBIREgAUEcdkEIcUEIRgwCCyAEBEAgBEEANgIEIARBDjYCAAsMBAtBAAtFDQELCyARDQBBfyECAkAgACgCABAfQgBTDQAgFyEUQQAhCkIAIRcjAEHwAGsiESQAAkAgACgCABAfIhVCAFkEQCAUUEUEQANAIAAgACgCQCALIBenQQN0aigCAEEEdGoiAygCBCIBBH8gAQUgAygCAAtBgAQQNyIBQQBIBEBCfyEXDAQLIAFBAEcgCnIhCiAXQgF8IhcgFFINAAsLQn8hFyAAKAIAEB8iGEJ/VwRAIAAoAgAhASAAQQhqBEAgACABKAIMNgIIIAAgASgCEDYCDAsMAgsgEULiABAXIgZFBEAgAEEIagRAIABBADYCDCAAQQ42AggLDAILIBggFX0hEyAVQv////8PViAUQv//A1ZyIApyQQFxBEAgBkGZEkEEECwgBkIsEBggBkEtEA0gBkEtEA0gBkEAEBIgBkEAEBIgBiAUEBggBiAUEBggBiATEBggBiAVEBggBkGUEkEEECwgBkEAEBIgBiAYEBggBkEBEBILIAZBnhJBBBAsIAZBABASIAYgFEL//wMgFEL//wNUG6dB//8DcSIBEA0gBiABEA0gBkF/IBOnIBNC/v///w9WGxASIAZBfyAVpyAVQv7///8PVhsQEiAGIABBJEEgIAAtACgbaigCACIDBH8gAy8BBAVBAAtB//8DcRANIAYtAABFBEAgAEEIagRAIABBADYCDCAAQRQ2AggLIAYQCAwCCyAAIAYoAgQgBi0AAAR+IAYpAxAFQgALEBshASAGEAggAUEASA0BIAMEQCAAIAMoAgAgAzMBBBAbQQBIDQILIBMhFwwBCyAAKAIAIQEgAEEIagRAIAAgASgCDDYCCCAAIAEoAhA2AgwLQn8hFwsgEUHwAGokACAXQgBTDQAgACgCABAfQj+HpyECCyALEAYgAkEASA0BAn8gACgCACIBKAIkQQFHBEAgAUEMagRAIAFBADYCECABQRI2AgwLQX8MAQsgASgCICICQQJPBEAgAUEMagRAIAFBADYCECABQR02AgwLQX8MAQsCQCACQQFHDQAgARAaQQBODQBBfwwBCyABQQBCAEEJEA5Cf1cEQCABQQI2AiRBfwwBCyABQQA2AiRBAAtFDQIgACgCACECIAQEQCAEIAIoAgw2AgAgBCACKAIQNgIECwwBCyALEAYLIAAoAlQQfCAAKAIAEENBfyECDAILIAAoAlQQfAsgABBLQQAhAgsgB0HAwABqJAAgAgtFAEHwgwFCADcDAEHogwFCADcDAEHggwFCADcDAEHYgwFCADcDAEHQgwFCADcDAEHIgwFCADcDAEHAgwFCADcDAEHAgwELoQMBCH8jAEGgAWsiAiQAIAAQMQJAAn8CQCAAKAIAIgFBAE4EQCABQbATKAIASA0BCyACIAE2AhAgAkEgakH2ESACQRBqEHZBASEGIAJBIGohBCACQSBqECIhA0EADAELIAFBAnQiAUGwEmooAgAhBQJ/AkACQCABQcATaigCAEEBaw4CAAEECyAAKAIEIQNB9IIBKAIAIQdBACEBAkACQANAIAMgAUHQ8QBqLQAARwRAQdcAIQQgAUEBaiIBQdcARw0BDAILCyABIgQNAEGw8gAhAwwBC0Gw8gAhAQNAIAEtAAAhCCABQQFqIgMhASAIDQAgAyEBIARBAWsiBA0ACwsgBygCFBogAwwBC0EAIAAoAgRrQQJ0QdjAAGooAgALIgRFDQEgBBAiIQMgBUUEQEEAIQVBASEGQQAMAQsgBRAiQQJqCyEBIAEgA2pBAWoQCSIBRQRAQegSKAIAIQUMAQsgAiAENgIIIAJBrBJBkRIgBhs2AgQgAkGsEiAFIAYbNgIAIAFBqwogAhB2IAAgATYCCCABIQULIAJBoAFqJAAgBQszAQF/IAAoAhQiAyABIAIgACgCECADayIBIAEgAksbIgEQBxogACAAKAIUIAFqNgIUIAILBgBBsIgBCwYAQayIAQsGAEGkiAELBwAgAEEEagsHACAAQQhqCyYBAX8gACgCFCIBBEAgARALCyAAKAIEIQEgAEEEahAxIAAQBiABC6kBAQN/AkAgAC0AACICRQ0AA0AgAS0AACIERQRAIAIhAwwCCwJAIAIgBEYNACACQSByIAIgAkHBAGtBGkkbIAEtAAAiAkEgciACIAJBwQBrQRpJG0YNACAALQAAIQMMAgsgAUEBaiEBIAAtAAEhAiAAQQFqIQAgAg0ACwsgA0H/AXEiAEEgciAAIABBwQBrQRpJGyABLQAAIgBBIHIgACAAQcEAa0EaSRtrC8sGAgJ+An8jAEHgAGsiByQAAkACQAJAAkACQAJAAkACQAJAAkACQCAEDg8AAQoCAwQGBwgICAgICAUICyABQgA3AyAMCQsgACACIAMQESIFQn9XBEAgAUEIaiIBBEAgASAAKAIMNgIAIAEgACgCEDYCBAsMCAsCQCAFUARAIAEpAygiAyABKQMgUg0BIAEgAzcDGCABQQE2AgQgASgCAEUNASAAIAdBKGoQIUF/TARAIAFBCGoiAQRAIAEgACgCDDYCACABIAAoAhA2AgQLDAoLAkAgBykDKCIDQiCDUA0AIAcoAlQgASgCMEYNACABQQhqBEAgAUEANgIMIAFBBzYCCAsMCgsgA0IEg1ANASAHKQNAIAEpAxhRDQEgAUEIagRAIAFBADYCDCABQRU2AggLDAkLIAEoAgQNACABKQMoIgMgASkDICIGVA0AIAUgAyAGfSIDWA0AIAEoAjAhBANAIAECfyAFIAN9IgZC/////w8gBkL/////D1QbIganIQBBACACIAOnaiIIRQ0AGiAEIAggAEHUgAEoAgARAAALIgQ2AjAgASABKQMoIAZ8NwMoIAUgAyAGfCIDVg0ACwsgASABKQMgIAV8NwMgDAgLIAEoAgRFDQcgAiABKQMYIgM3AxggASgCMCEAIAJBADYCMCACIAM3AyAgAiAANgIsIAIgAikDAELsAYQ3AwAMBwsgA0IIWgR+IAIgASgCCDYCACACIAEoAgw2AgRCCAVCfwshBQwGCyABEAYMBQtCfyEFIAApAxgiA0J/VwRAIAFBCGoiAQRAIAEgACgCDDYCACABIAAoAhA2AgQLDAULIAdBfzYCGCAHQo+AgICAAjcDECAHQoyAgIDQATcDCCAHQomAgICgATcDACADQQggBxAkQn+FgyEFDAQLIANCD1gEQCABQQhqBEAgAUEANgIMIAFBEjYCCAsMAwsgAkUNAgJAIAAgAikDACACKAIIEBRBAE4EQCAAEDMiA0J/VQ0BCyABQQhqIgEEQCABIAAoAgw2AgAgASAAKAIQNgIECwwDCyABIAM3AyAMAwsgASkDICEFDAILIAFBCGoEQCABQQA2AgwgAUEcNgIICwtCfyEFCyAHQeAAaiQAIAULjAcCAn4CfyMAQRBrIgckAAJAAkACQAJAAkACQAJAAkACQAJAIAQOEQABAgMFBggICAgICAgIBwgECAsgAUJ/NwMgIAFBADoADyABQQA7AQwgAUIANwMYIAEoAqxAIAEoAqhAKAIMEQEArUIBfSEFDAgLQn8hBSABKAIADQdCACEFIANQDQcgAS0ADQ0HIAFBKGohBAJAA0ACQCAHIAMgBX03AwggASgCrEAgAiAFp2ogB0EIaiABKAKoQCgCHBEAACEIQgAgBykDCCAIQQJGGyAFfCEFAkACQAJAIAhBAWsOAwADAQILIAFBAToADSABKQMgIgNCf1cEQCABBEAgAUEANgIEIAFBFDYCAAsMBQsgAS0ADkUNBCADIAVWDQQgASADNwMYIAFBAToADyACIAQgA6cQBxogASkDGCEFDAwLIAEtAAwNAyAAIARCgMAAEBEiBkJ/VwRAIAEEQCABIAAoAgw2AgAgASAAKAIQNgIECwwECyAGUARAIAFBAToADCABKAKsQCABKAKoQCgCGBEDACABKQMgQn9VDQEgAUIANwMgDAELAkAgASkDIEIAWQRAIAFBADoADgwBCyABIAY3AyALIAEoAqxAIAQgBiABKAKoQCgCFBEPABoLIAMgBVYNAQwCCwsgASgCAA0AIAEEQCABQQA2AgQgAUEUNgIACwsgBVBFBEAgAUEAOgAOIAEgASkDGCAFfDcDGAwIC0J/QgAgASgCABshBQwHCyABKAKsQCABKAKoQCgCEBEBAK1CAX0hBQwGCyABLQAQBEAgAS0ADQRAIAIgAS0ADwR/QQAFQQggASgCFCIAIABBfUsbCzsBMCACIAEpAxg3AyAgAiACKQMAQsgAhDcDAAwHCyACIAIpAwBCt////w+DNwMADAYLIAJBADsBMCACKQMAIQMgAS0ADQRAIAEpAxghBSACIANCxACENwMAIAIgBTcDGEIAIQUMBgsgAiADQrv///8Pg0LAAIQ3AwAMBQsgAS0ADw0EIAEoAqxAIAEoAqhAKAIIEQEArCEFDAQLIANCCFoEfiACIAEoAgA2AgAgAiABKAIENgIEQggFQn8LIQUMAwsgAUUNAiABKAKsQCABKAKoQCgCBBEDACABEDEgARAGDAILIAdBfzYCAEEQIAcQJEI/hCEFDAELIAEEQCABQQA2AgQgAUEUNgIAC0J/IQULIAdBEGokACAFC2MAQcgAEAkiAEUEQEGEhAEoAgAhASACBEAgAiABNgIEIAJBATYCAAsgAA8LIABBADoADCAAQQA6AAQgACACNgIAIABBADYCOCAAQgA3AzAgACABQQkgAUEBa0EJSRs2AgggAAu3fAIefwZ+IAIpAwAhIiAAIAE2AhwgACAiQv////8PICJC/////w9UGz4CICAAQRBqIQECfyAALQAEBEACfyAALQAMQQJ0IQpBfiEEAkACQAJAIAEiBUUNACAFKAIgRQ0AIAUoAiRFDQAgBSgCHCIDRQ0AIAMoAgAgBUcNAAJAAkAgAygCICIGQTlrDjkBAgICAgICAgICAgIBAgICAQICAgICAgICAgICAgICAgICAQICAgICAgICAgICAQICAgICAgICAgEACyAGQZoFRg0AIAZBKkcNAQsgCkEFSw0AAkACQCAFKAIMRQ0AIAUoAgQiAQRAIAUoAgBFDQELIAZBmgVHDQEgCkEERg0BCyAFQeDAACgCADYCGEF+DAQLIAUoAhBFDQEgAygCJCEEIAMgCjYCJAJAIAMoAhAEQCADEDACQCAFKAIQIgYgAygCECIIIAYgCEkbIgFFDQAgBSgCDCADKAIIIAEQBxogBSAFKAIMIAFqNgIMIAMgAygCCCABajYCCCAFIAUoAhQgAWo2AhQgBSAFKAIQIAFrIgY2AhAgAyADKAIQIAFrIgg2AhAgCA0AIAMgAygCBDYCCEEAIQgLIAYEQCADKAIgIQYMAgsMBAsgAQ0AIApBAXRBd0EAIApBBEsbaiAEQQF0QXdBACAEQQRKG2pKDQAgCkEERg0ADAILAkACQAJAAkACQCAGQSpHBEAgBkGaBUcNASAFKAIERQ0DDAcLIAMoAhRFBEAgA0HxADYCIAwCCyADKAI0QQx0QYDwAWshBAJAIAMoAowBQQJODQAgAygCiAEiAUEBTA0AIAFBBUwEQCAEQcAAciEEDAELQYABQcABIAFBBkYbIARyIQQLIAMoAgQgCGogBEEgciAEIAMoAmgbIgFBH3AgAXJBH3NBCHQgAUGA/gNxQQh2cjsAACADIAMoAhBBAmoiATYCECADKAJoBEAgAygCBCABaiAFKAIwIgFBGHQgAUEIdEGAgPwHcXIgAUEIdkGA/gNxIAFBGHZycjYAACADIAMoAhBBBGo2AhALIAVBATYCMCADQfEANgIgIAUQCiADKAIQDQcgAygCICEGCwJAAkACQAJAIAZBOUYEfyADQaABakHkgAEoAgARAQAaIAMgAygCECIBQQFqNgIQIAEgAygCBGpBHzoAACADIAMoAhAiAUEBajYCECABIAMoAgRqQYsBOgAAIAMgAygCECIBQQFqNgIQIAEgAygCBGpBCDoAAAJAIAMoAhwiAUUEQCADKAIEIAMoAhBqQQA2AAAgAyADKAIQIgFBBWo2AhAgASADKAIEakEAOgAEQQIhBCADKAKIASIBQQlHBEBBBCABQQJIQQJ0IAMoAowBQQFKGyEECyADIAMoAhAiAUEBajYCECABIAMoAgRqIAQ6AAAgAyADKAIQIgFBAWo2AhAgASADKAIEakEDOgAAIANB8QA2AiAgBRAKIAMoAhBFDQEMDQsgASgCJCELIAEoAhwhCSABKAIQIQggASgCLCENIAEoAgAhBiADIAMoAhAiAUEBajYCEEECIQQgASADKAIEaiANQQBHQQF0IAZBAEdyIAhBAEdBAnRyIAlBAEdBA3RyIAtBAEdBBHRyOgAAIAMoAgQgAygCEGogAygCHCgCBDYAACADIAMoAhAiDUEEaiIGNgIQIAMoAogBIgFBCUcEQEEEIAFBAkhBAnQgAygCjAFBAUobIQQLIAMgDUEFajYCECADKAIEIAZqIAQ6AAAgAygCHCgCDCEEIAMgAygCECIBQQFqNgIQIAEgAygCBGogBDoAACADKAIcIgEoAhAEfyADKAIEIAMoAhBqIAEoAhQ7AAAgAyADKAIQQQJqNgIQIAMoAhwFIAELKAIsBEAgBQJ/IAUoAjAhBiADKAIQIQRBACADKAIEIgFFDQAaIAYgASAEQdSAASgCABEAAAs2AjALIANBxQA2AiAgA0EANgIYDAILIAMoAiAFIAYLQcUAaw4jAAQEBAEEBAQEBAQEBAQEBAQEBAQEBAIEBAQEBAQEBAQEBAMECyADKAIcIgEoAhAiBgRAIAMoAgwiCCADKAIQIgQgAS8BFCADKAIYIg1rIglqSQRAA0AgAygCBCAEaiAGIA1qIAggBGsiCBAHGiADIAMoAgwiDTYCEAJAIAMoAhwoAixFDQAgBCANTw0AIAUCfyAFKAIwIQZBACADKAIEIARqIgFFDQAaIAYgASANIARrQdSAASgCABEAAAs2AjALIAMgAygCGCAIajYCGCAFKAIcIgYQMAJAIAUoAhAiBCAGKAIQIgEgASAESxsiAUUNACAFKAIMIAYoAgggARAHGiAFIAUoAgwgAWo2AgwgBiAGKAIIIAFqNgIIIAUgBSgCFCABajYCFCAFIAUoAhAgAWs2AhAgBiAGKAIQIAFrIgE2AhAgAQ0AIAYgBigCBDYCCAsgAygCEA0MIAMoAhghDSADKAIcKAIQIQZBACEEIAkgCGsiCSADKAIMIghLDQALCyADKAIEIARqIAYgDWogCRAHGiADIAMoAhAgCWoiDTYCEAJAIAMoAhwoAixFDQAgBCANTw0AIAUCfyAFKAIwIQZBACADKAIEIARqIgFFDQAaIAYgASANIARrQdSAASgCABEAAAs2AjALIANBADYCGAsgA0HJADYCIAsgAygCHCgCHARAIAMoAhAiBCEJA0ACQCAEIAMoAgxHDQACQCADKAIcKAIsRQ0AIAQgCU0NACAFAn8gBSgCMCEGQQAgAygCBCAJaiIBRQ0AGiAGIAEgBCAJa0HUgAEoAgARAAALNgIwCyAFKAIcIgYQMAJAIAUoAhAiBCAGKAIQIgEgASAESxsiAUUNACAFKAIMIAYoAgggARAHGiAFIAUoAgwgAWo2AgwgBiAGKAIIIAFqNgIIIAUgBSgCFCABajYCFCAFIAUoAhAgAWs2AhAgBiAGKAIQIAFrIgE2AhAgAQ0AIAYgBigCBDYCCAtBACEEQQAhCSADKAIQRQ0ADAsLIAMoAhwoAhwhBiADIAMoAhgiAUEBajYCGCABIAZqLQAAIQEgAyAEQQFqNgIQIAMoAgQgBGogAToAACABBEAgAygCECEEDAELCwJAIAMoAhwoAixFDQAgAygCECIGIAlNDQAgBQJ/IAUoAjAhBEEAIAMoAgQgCWoiAUUNABogBCABIAYgCWtB1IABKAIAEQAACzYCMAsgA0EANgIYCyADQdsANgIgCwJAIAMoAhwoAiRFDQAgAygCECIEIQkDQAJAIAQgAygCDEcNAAJAIAMoAhwoAixFDQAgBCAJTQ0AIAUCfyAFKAIwIQZBACADKAIEIAlqIgFFDQAaIAYgASAEIAlrQdSAASgCABEAAAs2AjALIAUoAhwiBhAwAkAgBSgCECIEIAYoAhAiASABIARLGyIBRQ0AIAUoAgwgBigCCCABEAcaIAUgBSgCDCABajYCDCAGIAYoAgggAWo2AgggBSAFKAIUIAFqNgIUIAUgBSgCECABazYCECAGIAYoAhAgAWsiATYCECABDQAgBiAGKAIENgIIC0EAIQRBACEJIAMoAhBFDQAMCgsgAygCHCgCJCEGIAMgAygCGCIBQQFqNgIYIAEgBmotAAAhASADIARBAWo2AhAgAygCBCAEaiABOgAAIAEEQCADKAIQIQQMAQsLIAMoAhwoAixFDQAgAygCECIGIAlNDQAgBQJ/IAUoAjAhBEEAIAMoAgQgCWoiAUUNABogBCABIAYgCWtB1IABKAIAEQAACzYCMAsgA0HnADYCIAsCQCADKAIcKAIsBEAgAygCDCADKAIQIgFBAmpJBH8gBRAKIAMoAhANAkEABSABCyADKAIEaiAFKAIwOwAAIAMgAygCEEECajYCECADQaABakHkgAEoAgARAQAaCyADQfEANgIgIAUQCiADKAIQRQ0BDAcLDAYLIAUoAgQNAQsgAygCPA0AIApFDQEgAygCIEGaBUYNAQsCfyADKAKIASIBRQRAIAMgChCFAQwBCwJAAkACQCADKAKMAUECaw4CAAECCwJ/AkADQAJAAkAgAygCPA0AIAMQLyADKAI8DQAgCg0BQQAMBAsgAygCSCADKAJoai0AACEEIAMgAygC8C0iAUEBajYC8C0gASADKALsLWpBADoAACADIAMoAvAtIgFBAWo2AvAtIAEgAygC7C1qQQA6AAAgAyADKALwLSIBQQFqNgLwLSABIAMoAuwtaiAEOgAAIAMgBEECdGoiASABLwHkAUEBajsB5AEgAyADKAI8QQFrNgI8IAMgAygCaEEBaiIBNgJoIAMoAvAtIAMoAvQtRw0BQQAhBCADIAMoAlgiBkEATgR/IAMoAkggBmoFQQALIAEgBmtBABAPIAMgAygCaDYCWCADKAIAEAogAygCACgCEA0BDAILCyADQQA2AoQuIApBBEYEQCADIAMoAlgiAUEATgR/IAMoAkggAWoFQQALIAMoAmggAWtBARAPIAMgAygCaDYCWCADKAIAEApBA0ECIAMoAgAoAhAbDAILIAMoAvAtBEBBACEEIAMgAygCWCIBQQBOBH8gAygCSCABagVBAAsgAygCaCABa0EAEA8gAyADKAJoNgJYIAMoAgAQCiADKAIAKAIQRQ0BC0EBIQQLIAQLDAILAn8CQANAAkACQAJAAkACQCADKAI8Ig1BggJLDQAgAxAvAkAgAygCPCINQYICSw0AIAoNAEEADAgLIA1FDQQgDUECSw0AIAMoAmghCAwBCyADKAJoIghFBEBBACEIDAELIAMoAkggCGoiAUEBayIELQAAIgYgAS0AAEcNACAGIAQtAAJHDQAgBEEDaiEEQQAhCQJAA0AgBiAELQAARw0BIAQtAAEgBkcEQCAJQQFyIQkMAgsgBC0AAiAGRwRAIAlBAnIhCQwCCyAELQADIAZHBEAgCUEDciEJDAILIAQtAAQgBkcEQCAJQQRyIQkMAgsgBC0ABSAGRwRAIAlBBXIhCQwCCyAELQAGIAZHBEAgCUEGciEJDAILIAQtAAcgBkcEQCAJQQdyIQkMAgsgBEEIaiEEIAlB+AFJIQEgCUEIaiEJIAENAAtBgAIhCQtBggIhBCANIAlBAmoiASABIA1LGyIBQYECSw0BIAEiBEECSw0BCyADKAJIIAhqLQAAIQQgAyADKALwLSIBQQFqNgLwLSABIAMoAuwtakEAOgAAIAMgAygC8C0iAUEBajYC8C0gASADKALsLWpBADoAACADIAMoAvAtIgFBAWo2AvAtIAEgAygC7C1qIAQ6AAAgAyAEQQJ0aiIBIAEvAeQBQQFqOwHkASADIAMoAjxBAWs2AjwgAyADKAJoQQFqIgQ2AmgMAQsgAyADKALwLSIBQQFqNgLwLSABIAMoAuwtakEBOgAAIAMgAygC8C0iAUEBajYC8C0gASADKALsLWpBADoAACADIAMoAvAtIgFBAWo2AvAtIAEgAygC7C1qIARBA2s6AAAgAyADKAKALkEBajYCgC4gBEH9zgBqLQAAQQJ0IANqQegJaiIBIAEvAQBBAWo7AQAgA0GAywAtAABBAnRqQdgTaiIBIAEvAQBBAWo7AQAgAyADKAI8IARrNgI8IAMgAygCaCAEaiIENgJoCyADKALwLSADKAL0LUcNAUEAIQggAyADKAJYIgFBAE4EfyADKAJIIAFqBUEACyAEIAFrQQAQDyADIAMoAmg2AlggAygCABAKIAMoAgAoAhANAQwCCwsgA0EANgKELiAKQQRGBEAgAyADKAJYIgFBAE4EfyADKAJIIAFqBUEACyADKAJoIAFrQQEQDyADIAMoAmg2AlggAygCABAKQQNBAiADKAIAKAIQGwwCCyADKALwLQRAQQAhCCADIAMoAlgiAUEATgR/IAMoAkggAWoFQQALIAMoAmggAWtBABAPIAMgAygCaDYCWCADKAIAEAogAygCACgCEEUNAQtBASEICyAICwwBCyADIAogAUEMbEG42ABqKAIAEQIACyIBQX5xQQJGBEAgA0GaBTYCIAsgAUF9cUUEQEEAIQQgBSgCEA0CDAQLIAFBAUcNAAJAAkACQCAKQQFrDgUAAQEBAgELIAMpA5guISICfwJ+IAMoAqAuIgFBA2oiCUE/TQRAQgIgAa2GICKEDAELIAFBwABGBEAgAygCBCADKAIQaiAiNwAAIAMgAygCEEEIajYCEEICISJBCgwCCyADKAIEIAMoAhBqQgIgAa2GICKENwAAIAMgAygCEEEIajYCECABQT1rIQlCAkHAACABa62ICyEiIAlBB2ogCUE5SQ0AGiADKAIEIAMoAhBqICI3AAAgAyADKAIQQQhqNgIQQgAhIiAJQTlrCyEBIAMgIjcDmC4gAyABNgKgLiADEDAMAQsgA0EAQQBBABA5IApBA0cNACADKAJQQQBBgIAIEBkgAygCPA0AIANBADYChC4gA0EANgJYIANBADYCaAsgBRAKIAUoAhANAAwDC0EAIQQgCkEERw0AAkACfwJAAkAgAygCFEEBaw4CAQADCyAFIANBoAFqQeCAASgCABEBACIBNgIwIAMoAgQgAygCEGogATYAACADIAMoAhBBBGoiATYCECADKAIEIAFqIQQgBSgCCAwBCyADKAIEIAMoAhBqIQQgBSgCMCIBQRh0IAFBCHRBgID8B3FyIAFBCHZBgP4DcSABQRh2cnILIQEgBCABNgAAIAMgAygCEEEEajYCEAsgBRAKIAMoAhQiAUEBTgRAIANBACABazYCFAsgAygCEEUhBAsgBAwCCyAFQezAACgCADYCGEF7DAELIANBfzYCJEEACwwBCyMAQRBrIhQkAEF+IRcCQCABIgxFDQAgDCgCIEUNACAMKAIkRQ0AIAwoAhwiB0UNACAHKAIAIAxHDQAgBygCBCIIQbT+AGtBH0sNACAMKAIMIhBFDQAgDCgCACIBRQRAIAwoAgQNAQsgCEG//gBGBEAgB0HA/gA2AgRBwP4AIQgLIAdBpAFqIR8gB0G8BmohGSAHQbwBaiEcIAdBoAFqIR0gB0G4AWohGiAHQfwKaiEYIAdBQGshHiAHKAKIASEFIAwoAgQiICEGIAcoAoQBIQogDCgCECIPIRYCfwJAAkACQANAAkBBfSEEQQEhCQJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAIAhBtP4Aaw4fBwYICQolJicoBSwtLQsZGgQMAjIzATUANw0OAzlISUwLIAcoApQBIQMgASEEIAYhCAw1CyAHKAKUASEDIAEhBCAGIQgMMgsgBygCtAEhCAwuCyAHKAIMIQgMQQsgBUEOTw0pIAZFDUEgBUEIaiEIIAFBAWohBCAGQQFrIQkgAS0AACAFdCAKaiEKIAVBBkkNDCAEIQEgCSEGIAghBQwpCyAFQSBPDSUgBkUNQCABQQFqIQQgBkEBayEIIAEtAAAgBXQgCmohCiAFQRhJDQ0gBCEBIAghBgwlCyAFQRBPDRUgBkUNPyAFQQhqIQggAUEBaiEEIAZBAWshCSABLQAAIAV0IApqIQogBUEISQ0NIAQhASAJIQYgCCEFDBULIAcoAgwiC0UNByAFQRBPDSIgBkUNPiAFQQhqIQggAUEBaiEEIAZBAWshCSABLQAAIAV0IApqIQogBUEISQ0NIAQhASAJIQYgCCEFDCILIAVBH0sNFQwUCyAFQQ9LDRYMFQsgBygCFCIEQYAIcUUEQCAFIQgMFwsgCiEIIAVBD0sNGAwXCyAKIAVBB3F2IQogBUF4cSIFQR9LDQwgBkUNOiAFQQhqIQggAUEBaiEEIAZBAWshCSABLQAAIAV0IApqIQogBUEYSQ0GIAQhASAJIQYgCCEFDAwLIAcoArQBIgggBygCqAEiC08NIwwiCyAPRQ0qIBAgBygCjAE6AAAgB0HI/gA2AgQgD0EBayEPIBBBAWohECAHKAIEIQgMOQsgBygCDCIDRQRAQQAhCAwJCyAFQR9LDQcgBkUNNyAFQQhqIQggAUEBaiEEIAZBAWshCSABLQAAIAV0IApqIQogBUEYSQ0BIAQhASAJIQYgCCEFDAcLIAdBwP4ANgIEDCoLIAlFBEAgBCEBQQAhBiAIIQUgDSEEDDgLIAVBEGohCSABQQJqIQQgBkECayELIAEtAAEgCHQgCmohCiAFQQ9LBEAgBCEBIAshBiAJIQUMBgsgC0UEQCAEIQFBACEGIAkhBSANIQQMOAsgBUEYaiEIIAFBA2ohBCAGQQNrIQsgAS0AAiAJdCAKaiEKIAVBB0sEQCAEIQEgCyEGIAghBQwGCyALRQRAIAQhAUEAIQYgCCEFIA0hBAw4CyAFQSBqIQUgBkEEayEGIAEtAAMgCHQgCmohCiABQQRqIQEMBQsgCUUEQCAEIQFBACEGIAghBSANIQQMNwsgBUEQaiEFIAZBAmshBiABLQABIAh0IApqIQogAUECaiEBDBwLIAlFBEAgBCEBQQAhBiAIIQUgDSEEDDYLIAVBEGohCSABQQJqIQQgBkECayELIAEtAAEgCHQgCmohCiAFQQ9LBEAgBCEBIAshBiAJIQUMBgsgC0UEQCAEIQFBACEGIAkhBSANIQQMNgsgBUEYaiEIIAFBA2ohBCAGQQNrIQsgAS0AAiAJdCAKaiEKIAUEQCAEIQEgCyEGIAghBQwGCyALRQRAIAQhAUEAIQYgCCEFIA0hBAw2CyAFQSBqIQUgBkEEayEGIAEtAAMgCHQgCmohCiABQQRqIQEMBQsgBUEIaiEJIAhFBEAgBCEBQQAhBiAJIQUgDSEEDDULIAFBAmohBCAGQQJrIQggAS0AASAJdCAKaiEKIAVBD0sEQCAEIQEgCCEGDBgLIAVBEGohCSAIRQRAIAQhAUEAIQYgCSEFIA0hBAw1CyABQQNqIQQgBkEDayEIIAEtAAIgCXQgCmohCiAFQQdLBEAgBCEBIAghBgwYCyAFQRhqIQUgCEUEQCAEIQFBACEGIA0hBAw1CyAGQQRrIQYgAS0AAyAFdCAKaiEKIAFBBGohAQwXCyAJDQYgBCEBQQAhBiAIIQUgDSEEDDMLIAlFBEAgBCEBQQAhBiAIIQUgDSEEDDMLIAVBEGohBSAGQQJrIQYgAS0AASAIdCAKaiEKIAFBAmohAQwUCyAMIBYgD2siCSAMKAIUajYCFCAHIAcoAiAgCWo2AiACQCADQQRxRQ0AIAkEQAJAIBAgCWshBCAMKAIcIggoAhQEQCAIQUBrIAQgCUEAQdiAASgCABEIAAwBCyAIIAgoAhwgBCAJQcCAASgCABEAACIENgIcIAwgBDYCMAsLIAcoAhRFDQAgByAeQeCAASgCABEBACIENgIcIAwgBDYCMAsCQCAHKAIMIghBBHFFDQAgBygCHCAKIApBCHRBgID8B3EgCkEYdHIgCkEIdkGA/gNxIApBGHZyciAHKAIUG0YNACAHQdH+ADYCBCAMQaQMNgIYIA8hFiAHKAIEIQgMMQtBACEKQQAhBSAPIRYLIAdBz/4ANgIEDC0LIApB//8DcSIEIApBf3NBEHZHBEAgB0HR/gA2AgQgDEGOCjYCGCAHKAIEIQgMLwsgB0HC/gA2AgQgByAENgKMAUEAIQpBACEFCyAHQcP+ADYCBAsgBygCjAEiBARAIA8gBiAEIAQgBksbIgQgBCAPSxsiCEUNHiAQIAEgCBAHIQQgByAHKAKMASAIazYCjAEgBCAIaiEQIA8gCGshDyABIAhqIQEgBiAIayEGIAcoAgQhCAwtCyAHQb/+ADYCBCAHKAIEIQgMLAsgBUEQaiEFIAZBAmshBiABLQABIAh0IApqIQogAUECaiEBCyAHIAo2AhQgCkH/AXFBCEcEQCAHQdH+ADYCBCAMQYIPNgIYIAcoAgQhCAwrCyAKQYDAA3EEQCAHQdH+ADYCBCAMQY0JNgIYIAcoAgQhCAwrCyAHKAIkIgQEQCAEIApBCHZBAXE2AgALAkAgCkGABHFFDQAgBy0ADEEEcUUNACAUIAo7AAwgBwJ/IAcoAhwhBUEAIBRBDGoiBEUNABogBSAEQQJB1IABKAIAEQAACzYCHAsgB0G2/gA2AgRBACEFQQAhCgsgBkUNKCABQQFqIQQgBkEBayEIIAEtAAAgBXQgCmohCiAFQRhPBEAgBCEBIAghBgwBCyAFQQhqIQkgCEUEQCAEIQFBACEGIAkhBSANIQQMKwsgAUECaiEEIAZBAmshCCABLQABIAl0IApqIQogBUEPSwRAIAQhASAIIQYMAQsgBUEQaiEJIAhFBEAgBCEBQQAhBiAJIQUgDSEEDCsLIAFBA2ohBCAGQQNrIQggAS0AAiAJdCAKaiEKIAVBB0sEQCAEIQEgCCEGDAELIAVBGGohBSAIRQRAIAQhAUEAIQYgDSEEDCsLIAZBBGshBiABLQADIAV0IApqIQogAUEEaiEBCyAHKAIkIgQEQCAEIAo2AgQLAkAgBy0AFUECcUUNACAHLQAMQQRxRQ0AIBQgCjYADCAHAn8gBygCHCEFQQAgFEEMaiIERQ0AGiAFIARBBEHUgAEoAgARAAALNgIcCyAHQbf+ADYCBEEAIQVBACEKCyAGRQ0mIAFBAWohBCAGQQFrIQggAS0AACAFdCAKaiEKIAVBCE8EQCAEIQEgCCEGDAELIAVBCGohBSAIRQRAIAQhAUEAIQYgDSEEDCkLIAZBAmshBiABLQABIAV0IApqIQogAUECaiEBCyAHKAIkIgQEQCAEIApBCHY2AgwgBCAKQf8BcTYCCAsCQCAHLQAVQQJxRQ0AIActAAxBBHFFDQAgFCAKOwAMIAcCfyAHKAIcIQVBACAUQQxqIgRFDQAaIAUgBEECQdSAASgCABEAAAs2AhwLIAdBuP4ANgIEQQAhCEEAIQVBACEKIAcoAhQiBEGACHENAQsgBygCJCIEBEAgBEEANgIQCyAIIQUMAgsgBkUEQEEAIQYgCCEKIA0hBAwmCyABQQFqIQkgBkEBayELIAEtAAAgBXQgCGohCiAFQQhPBEAgCSEBIAshBgwBCyAFQQhqIQUgC0UEQCAJIQFBACEGIA0hBAwmCyAGQQJrIQYgAS0AASAFdCAKaiEKIAFBAmohAQsgByAKQf//A3EiCDYCjAEgBygCJCIFBEAgBSAINgIUC0EAIQUCQCAEQYAEcUUNACAHLQAMQQRxRQ0AIBQgCjsADCAHAn8gBygCHCEIQQAgFEEMaiIERQ0AGiAIIARBAkHUgAEoAgARAAALNgIcC0EAIQoLIAdBuf4ANgIECyAHKAIUIglBgAhxBEAgBiAHKAKMASIIIAYgCEkbIg4EQAJAIAcoAiQiA0UNACADKAIQIgRFDQAgAygCGCILIAMoAhQgCGsiCE0NACAEIAhqIAEgCyAIayAOIAggDmogC0sbEAcaIAcoAhQhCQsCQCAJQYAEcUUNACAHLQAMQQRxRQ0AIAcCfyAHKAIcIQRBACABRQ0AGiAEIAEgDkHUgAEoAgARAAALNgIcCyAHIAcoAowBIA5rIgg2AowBIAYgDmshBiABIA5qIQELIAgNEwsgB0G6/gA2AgQgB0EANgKMAQsCQCAHLQAVQQhxBEBBACEIIAZFDQQDQCABIAhqLQAAIQMCQCAHKAIkIgtFDQAgCygCHCIERQ0AIAcoAowBIgkgCygCIE8NACAHIAlBAWo2AowBIAQgCWogAzoAAAsgA0EAIAYgCEEBaiIISxsNAAsCQCAHLQAVQQJxRQ0AIActAAxBBHFFDQAgBwJ/IAcoAhwhBEEAIAFFDQAaIAQgASAIQdSAASgCABEAAAs2AhwLIAEgCGohASAGIAhrIQYgA0UNAQwTCyAHKAIkIgRFDQAgBEEANgIcCyAHQbv+ADYCBCAHQQA2AowBCwJAIActABVBEHEEQEEAIQggBkUNAwNAIAEgCGotAAAhAwJAIAcoAiQiC0UNACALKAIkIgRFDQAgBygCjAEiCSALKAIoTw0AIAcgCUEBajYCjAEgBCAJaiADOgAACyADQQAgBiAIQQFqIghLGw0ACwJAIActABVBAnFFDQAgBy0ADEEEcUUNACAHAn8gBygCHCEEQQAgAUUNABogBCABIAhB1IABKAIAEQAACzYCHAsgASAIaiEBIAYgCGshBiADRQ0BDBILIAcoAiQiBEUNACAEQQA2AiQLIAdBvP4ANgIECyAHKAIUIgtBgARxBEACQCAFQQ9LDQAgBkUNHyAFQQhqIQggAUEBaiEEIAZBAWshCSABLQAAIAV0IApqIQogBUEITwRAIAQhASAJIQYgCCEFDAELIAlFBEAgBCEBQQAhBiAIIQUgDSEEDCILIAVBEGohBSAGQQJrIQYgAS0AASAIdCAKaiEKIAFBAmohAQsCQCAHLQAMQQRxRQ0AIAogBy8BHEYNACAHQdH+ADYCBCAMQdcMNgIYIAcoAgQhCAwgC0EAIQpBACEFCyAHKAIkIgQEQCAEQQE2AjAgBCALQQl2QQFxNgIsCwJAIActAAxBBHFFDQAgC0UNACAHIB5B5IABKAIAEQEAIgQ2AhwgDCAENgIwCyAHQb/+ADYCBCAHKAIEIQgMHgtBACEGDA4LAkAgC0ECcUUNACAKQZ+WAkcNACAHKAIoRQRAIAdBDzYCKAtBACEKIAdBADYCHCAUQZ+WAjsADCAHIBRBDGoiBAR/QQAgBEECQdSAASgCABEAAAVBAAs2AhwgB0G1/gA2AgRBACEFIAcoAgQhCAwdCyAHKAIkIgQEQCAEQX82AjALAkAgC0EBcQRAIApBCHRBgP4DcSAKQQh2akEfcEUNAQsgB0HR/gA2AgQgDEH2CzYCGCAHKAIEIQgMHQsgCkEPcUEIRwRAIAdB0f4ANgIEIAxBgg82AhggBygCBCEIDB0LIApBBHYiBEEPcSIJQQhqIQsgCUEHTUEAIAcoAigiCAR/IAgFIAcgCzYCKCALCyALTxtFBEAgBUEEayEFIAdB0f4ANgIEIAxB+gw2AhggBCEKIAcoAgQhCAwdCyAHQQE2AhxBACEFIAdBADYCFCAHQYACIAl0NgIYIAxBATYCMCAHQb3+AEG//gAgCkGAwABxGzYCBEEAIQogBygCBCEIDBwLIAcgCkEIdEGAgPwHcSAKQRh0ciAKQQh2QYD+A3EgCkEYdnJyIgQ2AhwgDCAENgIwIAdBvv4ANgIEQQAhCkEAIQULIAcoAhBFBEAgDCAPNgIQIAwgEDYCDCAMIAY2AgQgDCABNgIAIAcgBTYCiAEgByAKNgKEAUECIRcMIAsgB0EBNgIcIAxBATYCMCAHQb/+ADYCBAsCfwJAIAcoAghFBEAgBUEDSQ0BIAUMAgsgB0HO/gA2AgQgCiAFQQdxdiEKIAVBeHEhBSAHKAIEIQgMGwsgBkUNGSAGQQFrIQYgAS0AACAFdCAKaiEKIAFBAWohASAFQQhqCyEEIAcgCkEBcTYCCAJAAkACQAJAAkAgCkEBdkEDcUEBaw4DAQIDAAsgB0HB/gA2AgQMAwsgB0Gw2wA2ApgBIAdCiYCAgNAANwOgASAHQbDrADYCnAEgB0HH/gA2AgQMAgsgB0HE/gA2AgQMAQsgB0HR/gA2AgQgDEHXDTYCGAsgBEEDayEFIApBA3YhCiAHKAIEIQgMGQsgByAKQR9xIghBgQJqNgKsASAHIApBBXZBH3EiBEEBajYCsAEgByAKQQp2QQ9xQQRqIgs2AqgBIAVBDmshBSAKQQ52IQogCEEdTUEAIARBHkkbRQRAIAdB0f4ANgIEIAxB6gk2AhggBygCBCEIDBkLIAdBxf4ANgIEQQAhCCAHQQA2ArQBCyAIIQQDQCAFQQJNBEAgBkUNGCAGQQFrIQYgAS0AACAFdCAKaiEKIAVBCGohBSABQQFqIQELIAcgBEEBaiIINgK0ASAHIARBAXRBsOwAai8BAEEBdGogCkEHcTsBvAEgBUEDayEFIApBA3YhCiALIAgiBEsNAAsLIAhBEk0EQEESIAhrIQ1BAyAIa0EDcSIEBEADQCAHIAhBAXRBsOwAai8BAEEBdGpBADsBvAEgCEEBaiEIIARBAWsiBA0ACwsgDUEDTwRAA0AgB0G8AWoiDSAIQQF0IgRBsOwAai8BAEEBdGpBADsBACANIARBsuwAai8BAEEBdGpBADsBACANIARBtOwAai8BAEEBdGpBADsBACANIARBtuwAai8BAEEBdGpBADsBACAIQQRqIghBE0cNAAsLIAdBEzYCtAELIAdBBzYCoAEgByAYNgKYASAHIBg2ArgBQQAhCEEAIBxBEyAaIB0gGRBOIg0EQCAHQdH+ADYCBCAMQfQINgIYIAcoAgQhCAwXCyAHQcb+ADYCBCAHQQA2ArQBQQAhDQsgBygCrAEiFSAHKAKwAWoiESAISwRAQX8gBygCoAF0QX9zIRIgBygCmAEhGwNAIAYhCSABIQsCQCAFIgMgGyAKIBJxIhNBAnRqLQABIg5PBEAgBSEEDAELA0AgCUUNDSALLQAAIAN0IQ4gC0EBaiELIAlBAWshCSADQQhqIgQhAyAEIBsgCiAOaiIKIBJxIhNBAnRqLQABIg5JDQALIAshASAJIQYLAkAgGyATQQJ0ai8BAiIFQQ9NBEAgByAIQQFqIgk2ArQBIAcgCEEBdGogBTsBvAEgBCAOayEFIAogDnYhCiAJIQgMAQsCfwJ/AkACQAJAIAVBEGsOAgABAgsgDkECaiIFIARLBEADQCAGRQ0bIAZBAWshBiABLQAAIAR0IApqIQogAUEBaiEBIARBCGoiBCAFSQ0ACwsgBCAOayEFIAogDnYhBCAIRQRAIAdB0f4ANgIEIAxBvAk2AhggBCEKIAcoAgQhCAwdCyAFQQJrIQUgBEECdiEKIARBA3FBA2ohCSAIQQF0IAdqLwG6AQwDCyAOQQNqIgUgBEsEQANAIAZFDRogBkEBayEGIAEtAAAgBHQgCmohCiABQQFqIQEgBEEIaiIEIAVJDQALCyAEIA5rQQNrIQUgCiAOdiIEQQN2IQogBEEHcUEDagwBCyAOQQdqIgUgBEsEQANAIAZFDRkgBkEBayEGIAEtAAAgBHQgCmohCiABQQFqIQEgBEEIaiIEIAVJDQALCyAEIA5rQQdrIQUgCiAOdiIEQQd2IQogBEH/AHFBC2oLIQlBAAshAyAIIAlqIBFLDRMgCUEBayEEIAlBA3EiCwRAA0AgByAIQQF0aiADOwG8ASAIQQFqIQggCUEBayEJIAtBAWsiCw0ACwsgBEEDTwRAA0AgByAIQQF0aiIEIAM7Ab4BIAQgAzsBvAEgBCADOwHAASAEIAM7AcIBIAhBBGohCCAJQQRrIgkNAAsLIAcgCDYCtAELIAggEUkNAAsLIAcvAbwFRQRAIAdB0f4ANgIEIAxB0Qs2AhggBygCBCEIDBYLIAdBCjYCoAEgByAYNgKYASAHIBg2ArgBQQEgHCAVIBogHSAZEE4iDQRAIAdB0f4ANgIEIAxB2Ag2AhggBygCBCEIDBYLIAdBCTYCpAEgByAHKAK4ATYCnAFBAiAHIAcoAqwBQQF0akG8AWogBygCsAEgGiAfIBkQTiINBEAgB0HR/gA2AgQgDEGmCTYCGCAHKAIEIQgMFgsgB0HH/gA2AgRBACENCyAHQcj+ADYCBAsCQCAGQQ9JDQAgD0GEAkkNACAMIA82AhAgDCAQNgIMIAwgBjYCBCAMIAE2AgAgByAFNgKIASAHIAo2AoQBIAwgFkHogAEoAgARBwAgBygCiAEhBSAHKAKEASEKIAwoAgQhBiAMKAIAIQEgDCgCECEPIAwoAgwhECAHKAIEQb/+AEcNByAHQX82ApBHIAcoAgQhCAwUCyAHQQA2ApBHIAUhCSAGIQggASEEAkAgBygCmAEiEiAKQX8gBygCoAF0QX9zIhVxIg5BAnRqLQABIgsgBU0EQCAFIQMMAQsDQCAIRQ0PIAQtAAAgCXQhCyAEQQFqIQQgCEEBayEIIAlBCGoiAyEJIAMgEiAKIAtqIgogFXEiDkECdGotAAEiC0kNAAsLIBIgDkECdGoiAS8BAiETAkBBACABLQAAIhEgEUHwAXEbRQRAIAshBgwBCyAIIQYgBCEBAkAgAyIFIAsgEiAKQX8gCyARanRBf3MiFXEgC3YgE2oiEUECdGotAAEiDmpPBEAgAyEJDAELA0AgBkUNDyABLQAAIAV0IQ4gAUEBaiEBIAZBAWshBiAFQQhqIgkhBSALIBIgCiAOaiIKIBVxIAt2IBNqIhFBAnRqLQABIg5qIAlLDQALIAEhBCAGIQgLIBIgEUECdGoiAS0AACERIAEvAQIhEyAHIAs2ApBHIAsgDmohBiAJIAtrIQMgCiALdiEKIA4hCwsgByAGNgKQRyAHIBNB//8DcTYCjAEgAyALayEFIAogC3YhCiARRQRAIAdBzf4ANgIEDBALIBFBIHEEQCAHQb/+ADYCBCAHQX82ApBHDBALIBFBwABxBEAgB0HR/gA2AgQgDEHQDjYCGAwQCyAHQcn+ADYCBCAHIBFBD3EiAzYClAELAkAgA0UEQCAHKAKMASELIAQhASAIIQYMAQsgBSEJIAghBiAEIQsCQCADIAVNBEAgBCEBDAELA0AgBkUNDSAGQQFrIQYgCy0AACAJdCAKaiEKIAtBAWoiASELIAlBCGoiCSADSQ0ACwsgByAHKAKQRyADajYCkEcgByAHKAKMASAKQX8gA3RBf3NxaiILNgKMASAJIANrIQUgCiADdiEKCyAHQcr+ADYCBCAHIAs2ApRHCyAFIQkgBiEIIAEhBAJAIAcoApwBIhIgCkF/IAcoAqQBdEF/cyIVcSIOQQJ0ai0AASIDIAVNBEAgBSELDAELA0AgCEUNCiAELQAAIAl0IQMgBEEBaiEEIAhBAWshCCAJQQhqIgshCSALIBIgAyAKaiIKIBVxIg5BAnRqLQABIgNJDQALCyASIA5BAnRqIgEvAQIhEwJAIAEtAAAiEUHwAXEEQCAHKAKQRyEGIAMhCQwBCyAIIQYgBCEBAkAgCyIFIAMgEiAKQX8gAyARanRBf3MiFXEgA3YgE2oiEUECdGotAAEiCWpPBEAgCyEODAELA0AgBkUNCiABLQAAIAV0IQkgAUEBaiEBIAZBAWshBiAFQQhqIg4hBSADIBIgCSAKaiIKIBVxIAN2IBNqIhFBAnRqLQABIglqIA5LDQALIAEhBCAGIQgLIBIgEUECdGoiAS0AACERIAEvAQIhEyAHIAcoApBHIANqIgY2ApBHIA4gA2shCyAKIAN2IQoLIAcgBiAJajYCkEcgCyAJayEFIAogCXYhCiARQcAAcQRAIAdB0f4ANgIEIAxB7A42AhggBCEBIAghBiAHKAIEIQgMEgsgB0HL/gA2AgQgByARQQ9xIgM2ApQBIAcgE0H//wNxNgKQAQsCQCADRQRAIAQhASAIIQYMAQsgBSEJIAghBiAEIQsCQCADIAVNBEAgBCEBDAELA0AgBkUNCCAGQQFrIQYgCy0AACAJdCAKaiEKIAtBAWoiASELIAlBCGoiCSADSQ0ACwsgByAHKAKQRyADajYCkEcgByAHKAKQASAKQX8gA3RBf3NxajYCkAEgCSADayEFIAogA3YhCgsgB0HM/gA2AgQLIA9FDQACfyAHKAKQASIIIBYgD2siBEsEQAJAIAggBGsiCCAHKAIwTQ0AIAcoAoxHRQ0AIAdB0f4ANgIEIAxBuQw2AhggBygCBCEIDBILAn8CQAJ/IAcoAjQiBCAISQRAIAcoAjggBygCLCAIIARrIghragwBCyAHKAI4IAQgCGtqCyILIBAgDyAQaiAQa0EBaqwiISAPIAcoAowBIgQgCCAEIAhJGyIEIAQgD0sbIgitIiIgISAiVBsiIqciCWoiBEkgCyAQT3ENACALIBBNIAkgC2ogEEtxDQAgECALIAkQBxogBAwBCyAQIAsgCyAQayIEIARBH3UiBGogBHMiCRAHIAlqIQQgIiAJrSIkfSIjUEUEQCAJIAtqIQkDQAJAICMgJCAjICRUGyIiQiBUBEAgIiEhDAELICIiIUIgfSImQgWIQgF8QgODIiVQRQRAA0AgBCAJKQAANwAAIAQgCSkAGDcAGCAEIAkpABA3ABAgBCAJKQAINwAIICFCIH0hISAJQSBqIQkgBEEgaiEEICVCAX0iJUIAUg0ACwsgJkLgAFQNAANAIAQgCSkAADcAACAEIAkpABg3ABggBCAJKQAQNwAQIAQgCSkACDcACCAEIAkpADg3ADggBCAJKQAwNwAwIAQgCSkAKDcAKCAEIAkpACA3ACAgBCAJKQBYNwBYIAQgCSkAUDcAUCAEIAkpAEg3AEggBCAJKQBANwBAIAQgCSkAYDcAYCAEIAkpAGg3AGggBCAJKQBwNwBwIAQgCSkAeDcAeCAJQYABaiEJIARBgAFqIQQgIUKAAX0iIUIfVg0ACwsgIUIQWgRAIAQgCSkAADcAACAEIAkpAAg3AAggIUIQfSEhIAlBEGohCSAEQRBqIQQLICFCCFoEQCAEIAkpAAA3AAAgIUIIfSEhIAlBCGohCSAEQQhqIQQLICFCBFoEQCAEIAkoAAA2AAAgIUIEfSEhIAlBBGohCSAEQQRqIQQLICFCAloEQCAEIAkvAAA7AAAgIUICfSEhIAlBAmohCSAEQQJqIQQLICMgIn0hIyAhUEUEQCAEIAktAAA6AAAgCUEBaiEJIARBAWohBAsgI0IAUg0ACwsgBAsMAQsgECAIIA8gBygCjAEiBCAEIA9LGyIIIA9ByIABKAIAEQQACyEQIAcgBygCjAEgCGsiBDYCjAEgDyAIayEPIAQNAiAHQcj+ADYCBCAHKAIEIQgMDwsgDSEJCyAJIQQMDgsgBygCBCEIDAwLIAEgBmohASAFIAZBA3RqIQUMCgsgBCAIaiEBIAUgCEEDdGohBQwJCyAEIAhqIQEgCyAIQQN0aiEFDAgLIAEgBmohASAFIAZBA3RqIQUMBwsgBCAIaiEBIAUgCEEDdGohBQwGCyAEIAhqIQEgAyAIQQN0aiEFDAULIAEgBmohASAFIAZBA3RqIQUMBAsgB0HR/gA2AgQgDEG8CTYCGCAHKAIEIQgMBAsgBCEBIAghBiAHKAIEIQgMAwtBACEGIAQhBSANIQQMAwsCQAJAIAhFBEAgCiEJDAELIAcoAhRFBEAgCiEJDAELAkAgBUEfSw0AIAZFDQMgBUEIaiEJIAFBAWohBCAGQQFrIQsgAS0AACAFdCAKaiEKIAVBGE8EQCAEIQEgCyEGIAkhBQwBCyALRQRAIAQhAUEAIQYgCSEFIA0hBAwGCyAFQRBqIQsgAUECaiEEIAZBAmshAyABLQABIAl0IApqIQogBUEPSwRAIAQhASADIQYgCyEFDAELIANFBEAgBCEBQQAhBiALIQUgDSEEDAYLIAVBGGohCSABQQNqIQQgBkEDayEDIAEtAAIgC3QgCmohCiAFQQdLBEAgBCEBIAMhBiAJIQUMAQsgA0UEQCAEIQFBACEGIAkhBSANIQQMBgsgBUEgaiEFIAZBBGshBiABLQADIAl0IApqIQogAUEEaiEBC0EAIQkgCEEEcQRAIAogBygCIEcNAgtBACEFCyAHQdD+ADYCBEEBIQQgCSEKDAMLIAdB0f4ANgIEIAxBjQw2AhggBygCBCEIDAELC0EAIQYgDSEECyAMIA82AhAgDCAQNgIMIAwgBjYCBCAMIAE2AgAgByAFNgKIASAHIAo2AoQBAkAgBygCLA0AIA8gFkYNAiAHKAIEIgFB0P4ASw0CIAFBzv4ASQ0ACwJ/IBYgD2shCiAHKAIMQQRxIQkCQAJAAkAgDCgCHCIDKAI4Ig1FBEBBASEIIAMgAygCACIBKAIgIAEoAiggAygCmEdBASADKAIodGpBARAoIg02AjggDUUNAQsgAygCLCIGRQRAIANCADcDMCADQQEgAygCKHQiBjYCLAsgBiAKTQRAAkAgCQRAAkAgBiAKTw0AIAogBmshBSAQIAprIQEgDCgCHCIGKAIUBEAgBkFAayABIAVBAEHYgAEoAgARCAAMAQsgBiAGKAIcIAEgBUHAgAEoAgARAAAiATYCHCAMIAE2AjALIAMoAiwiDUUNASAQIA1rIQUgAygCOCEBIAwoAhwiBigCFARAIAZBQGsgASAFIA1B3IABKAIAEQgADAILIAYgBigCHCABIAUgDUHEgAEoAgARBAAiATYCHCAMIAE2AjAMAQsgDSAQIAZrIAYQBxoLIANBADYCNCADIAMoAiw2AjBBAAwECyAKIAYgAygCNCIFayIBIAEgCksbIQsgECAKayEGIAUgDWohBQJAIAkEQAJAIAtFDQAgDCgCHCIBKAIUBEAgAUFAayAFIAYgC0HcgAEoAgARCAAMAQsgASABKAIcIAUgBiALQcSAASgCABEEACIBNgIcIAwgATYCMAsgCiALayIFRQ0BIBAgBWshBiADKAI4IQEgDCgCHCINKAIUBEAgDUFAayABIAYgBUHcgAEoAgARCAAMBQsgDSANKAIcIAEgBiAFQcSAASgCABEEACIBNgIcIAwgATYCMAwECyAFIAYgCxAHGiAKIAtrIgUNAgtBACEIIANBACADKAI0IAtqIgUgBSADKAIsIgFGGzYCNCABIAMoAjAiAU0NACADIAEgC2o2AjALIAgMAgsgAygCOCAQIAVrIAUQBxoLIAMgBTYCNCADIAMoAiw2AjBBAAtFBEAgDCgCECEPIAwoAgQhFyAHKAKIAQwDCyAHQdL+ADYCBAtBfCEXDAILIAYhFyAFCyEFIAwgICAXayIBIAwoAghqNgIIIAwgFiAPayIGIAwoAhRqNgIUIAcgBygCICAGajYCICAMIAcoAghBAEdBBnQgBWogBygCBCIFQb/+AEZBB3RqQYACIAVBwv4ARkEIdCAFQcf+AEYbajYCLCAEIARBeyAEGyABIAZyGyEXCyAUQRBqJAAgFwshASACIAIpAwAgADUCIH03AwACQAJAAkACQCABQQVqDgcBAgICAgMAAgtBAQ8LIAAoAhQNAEEDDwsgACgCACIABEAgACABNgIEIABBDTYCAAtBAiEBCyABCwkAIABBAToADAtEAAJAIAJC/////w9YBEAgACgCFEUNAQsgACgCACIABEAgAEEANgIEIABBEjYCAAtBAA8LIAAgATYCECAAIAI+AhRBAQu5AQEEfyAAQRBqIQECfyAALQAEBEAgARCEAQwBC0F+IQMCQCABRQ0AIAEoAiBFDQAgASgCJCIERQ0AIAEoAhwiAkUNACACKAIAIAFHDQAgAigCBEG0/gBrQR9LDQAgAigCOCIDBEAgBCABKAIoIAMQHiABKAIkIQQgASgCHCECCyAEIAEoAiggAhAeQQAhAyABQQA2AhwLIAMLIgEEQCAAKAIAIgAEQCAAIAE2AgQgAEENNgIACwsgAUUL0gwBBn8gAEIANwIQIABCADcCHCAAQRBqIQICfyAALQAEBEAgACgCCCEBQesMLQAAQTFGBH8Cf0F+IQMCQCACRQ0AIAJBADYCGCACKAIgIgRFBEAgAkEANgIoIAJBJzYCIEEnIQQLIAIoAiRFBEAgAkEoNgIkC0EGIAEgAUF/RhsiBUEASA0AIAVBCUoNAEF8IQMgBCACKAIoQQFB0C4QKCIBRQ0AIAIgATYCHCABIAI2AgAgAUEPNgI0IAFCgICAgKAFNwIcIAFBADYCFCABQYCAAjYCMCABQf//ATYCOCABIAIoAiAgAigCKEGAgAJBAhAoNgJIIAEgAigCICACKAIoIAEoAjBBAhAoIgM2AkwgA0EAIAEoAjBBAXQQGSACKAIgIAIoAihBgIAEQQIQKCEDIAFBgIACNgLoLSABQQA2AkAgASADNgJQIAEgAigCICACKAIoQYCAAkEEECgiAzYCBCABIAEoAugtIgRBAnQ2AgwCQAJAIAEoAkhFDQAgASgCTEUNACABKAJQRQ0AIAMNAQsgAUGaBTYCICACQejAACgCADYCGCACEIQBGkF8DAILIAFBADYCjAEgASAFNgKIASABQgA3AyggASADIARqNgLsLSABIARBA2xBA2s2AvQtQX4hAwJAIAJFDQAgAigCIEUNACACKAIkRQ0AIAIoAhwiAUUNACABKAIAIAJHDQACQAJAIAEoAiAiBEE5aw45AQICAgICAgICAgICAQICAgECAgICAgICAgICAgICAgICAgECAgICAgICAgICAgECAgICAgICAgIBAAsgBEGaBUYNACAEQSpHDQELIAJBAjYCLCACQQA2AgggAkIANwIUIAFBADYCECABIAEoAgQ2AgggASgCFCIDQX9MBEAgAUEAIANrIgM2AhQLIAFBOUEqIANBAkYbNgIgIAIgA0ECRgR/IAFBoAFqQeSAASgCABEBAAVBAQs2AjAgAUF+NgIkIAFBADYCoC4gAUIANwOYLiABQYgXakGg0wA2AgAgASABQcwVajYCgBcgAUH8FmpBjNMANgIAIAEgAUHYE2o2AvQWIAFB8BZqQfjSADYCACABIAFB5AFqNgLoFiABEIgBQQAhAwsgAw0AIAIoAhwiAiACKAIwQQF0NgJEQQAhAyACKAJQQQBBgIAIEBkgAiACKAKIASIEQQxsIgFBtNgAai8BADYClAEgAiABQbDYAGovAQA2ApABIAIgAUGy2ABqLwEANgJ4IAIgAUG22ABqLwEANgJ0QfiAASgCACEFQeyAASgCACEGQYCBASgCACEBIAJCADcCbCACQgA3AmQgAkEANgI8IAJBADYChC4gAkIANwJUIAJBKSABIARBCUYiARs2AnwgAkEqIAYgARs2AoABIAJBKyAFIAEbNgKEAQsgAwsFQXoLDAELAn9BekHrDC0AAEExRw0AGkF+IAJFDQAaIAJBADYCGCACKAIgIgNFBEAgAkEANgIoIAJBJzYCIEEnIQMLIAIoAiRFBEAgAkEoNgIkC0F8IAMgAigCKEEBQaDHABAoIgRFDQAaIAIgBDYCHCAEQQA2AjggBCACNgIAIARBtP4ANgIEIARBzIABKAIAEQkANgKYR0F+IQMCQCACRQ0AIAIoAiBFDQAgAigCJCIFRQ0AIAIoAhwiAUUNACABKAIAIAJHDQAgASgCBEG0/gBrQR9LDQACQAJAIAEoAjgiBgRAIAEoAihBD0cNAQsgAUEPNgIoIAFBADYCDAwBCyAFIAIoAiggBhAeIAFBADYCOCACKAIgIQUgAUEPNgIoIAFBADYCDCAFRQ0BCyACKAIkRQ0AIAIoAhwiAUUNACABKAIAIAJHDQAgASgCBEG0/gBrQR9LDQBBACEDIAFBADYCNCABQgA3AiwgAUEANgIgIAJBADYCCCACQgA3AhQgASgCDCIFBEAgAiAFQQFxNgIwCyABQrT+ADcCBCABQgA3AoQBIAFBADYCJCABQoCAgoAQNwMYIAFCgICAgHA3AxAgAUKBgICAcDcCjEcgASABQfwKaiIFNgK4ASABIAU2ApwBIAEgBTYCmAELQQAgA0UNABogAigCJCACKAIoIAQQHiACQQA2AhwgAwsLIgIEQCAAKAIAIgAEQCAAIAI2AgQgAEENNgIACwsgAkULKQEBfyAALQAERQRAQQAPC0ECIQEgACgCCCIAQQNOBH8gAEEHSgVBAgsLBgAgABAGC2MAQcgAEAkiAEUEQEGEhAEoAgAhASACBEAgAiABNgIEIAJBATYCAAsgAA8LIABBADoADCAAQQE6AAQgACACNgIAIABBADYCOCAAQgA3AzAgACABQQkgAUEBa0EJSRs2AgggAAukCgIIfwF+QfCAAUH0gAEgACgCdEGBCEkbIQYCQANAAkACfwJAIAAoAjxBhQJLDQAgABAvAkAgACgCPCICQYUCSw0AIAENAEEADwsgAkUNAiACQQRPDQBBAAwBCyAAIAAoAmggACgChAERAgALIQMgACAAKAJsOwFgQQIhAgJAIAA1AmggA619IgpCAVMNACAKIAAoAjBBhgJrrVUNACAAKAJwIAAoAnhPDQAgA0UNACAAIAMgBigCABECACICQQVLDQBBAiACIAAoAowBQQFGGyECCwJAIAAoAnAiA0EDSQ0AIAIgA0sNACAAIAAoAvAtIgJBAWo2AvAtIAAoAjwhBCACIAAoAuwtaiAAKAJoIgcgAC8BYEF/c2oiAjoAACAAIAAoAvAtIgVBAWo2AvAtIAUgACgC7C1qIAJBCHY6AAAgACAAKALwLSIFQQFqNgLwLSAFIAAoAuwtaiADQQNrOgAAIAAgACgCgC5BAWo2AoAuIANB/c4Aai0AAEECdCAAakHoCWoiAyADLwEAQQFqOwEAIAAgAkEBayICIAJBB3ZBgAJqIAJBgAJJG0GAywBqLQAAQQJ0akHYE2oiAiACLwEAQQFqOwEAIAAgACgCcCIFQQFrIgM2AnAgACAAKAI8IANrNgI8IAAoAvQtIQggACgC8C0hCSAEIAdqQQNrIgQgACgCaCICSwRAIAAgAkEBaiAEIAJrIgIgBUECayIEIAIgBEkbIAAoAoABEQUAIAAoAmghAgsgAEEANgJkIABBADYCcCAAIAIgA2oiBDYCaCAIIAlHDQJBACECIAAgACgCWCIDQQBOBH8gACgCSCADagVBAAsgBCADa0EAEA8gACAAKAJoNgJYIAAoAgAQCiAAKAIAKAIQDQIMAwsgACgCZARAIAAoAmggACgCSGpBAWstAAAhAyAAIAAoAvAtIgRBAWo2AvAtIAQgACgC7C1qQQA6AAAgACAAKALwLSIEQQFqNgLwLSAEIAAoAuwtakEAOgAAIAAgACgC8C0iBEEBajYC8C0gBCAAKALsLWogAzoAACAAIANBAnRqIgMgAy8B5AFBAWo7AeQBIAAoAvAtIAAoAvQtRgRAIAAgACgCWCIDQQBOBH8gACgCSCADagVBAAsgACgCaCADa0EAEA8gACAAKAJoNgJYIAAoAgAQCgsgACACNgJwIAAgACgCaEEBajYCaCAAIAAoAjxBAWs2AjwgACgCACgCEA0CQQAPBSAAQQE2AmQgACACNgJwIAAgACgCaEEBajYCaCAAIAAoAjxBAWs2AjwMAgsACwsgACgCZARAIAAoAmggACgCSGpBAWstAAAhAiAAIAAoAvAtIgNBAWo2AvAtIAMgACgC7C1qQQA6AAAgACAAKALwLSIDQQFqNgLwLSADIAAoAuwtakEAOgAAIAAgACgC8C0iA0EBajYC8C0gAyAAKALsLWogAjoAACAAIAJBAnRqIgIgAi8B5AFBAWo7AeQBIAAoAvAtIAAoAvQtRhogAEEANgJkCyAAIAAoAmgiA0ECIANBAkkbNgKELiABQQRGBEAgACAAKAJYIgFBAE4EfyAAKAJIIAFqBUEACyADIAFrQQEQDyAAIAAoAmg2AlggACgCABAKQQNBAiAAKAIAKAIQGw8LIAAoAvAtBEBBACECIAAgACgCWCIBQQBOBH8gACgCSCABagVBAAsgAyABa0EAEA8gACAAKAJoNgJYIAAoAgAQCiAAKAIAKAIQRQ0BC0EBIQILIAIL2BACEH8BfiAAKAKIAUEFSCEOA0ACQAJ/AkACQAJAAn8CQAJAIAAoAjxBhQJNBEAgABAvIAAoAjwiA0GFAksNASABDQFBAA8LIA4NASAIIQMgBSEHIAohDSAGQf//A3FFDQEMAwsgA0UNA0EAIANBBEkNARoLIAAgACgCaEH4gAEoAgARAgALIQZBASECQQAhDSAAKAJoIgOtIAatfSISQgFTDQIgEiAAKAIwQYYCa61VDQIgBkUNAiAAIAZB8IABKAIAEQIAIgZBASAGQfz/A3EbQQEgACgCbCINQf//A3EgA0H//wNxSRshBiADIQcLAkAgACgCPCIEIAZB//8DcSICQQRqTQ0AIAZB//8DcUEDTQRAQQEgBkEBa0H//wNxIglFDQQaIANB//8DcSIEIAdBAWpB//8DcSIDSw0BIAAgAyAJIAQgA2tBAWogAyAJaiAESxtB7IABKAIAEQUADAELAkAgACgCeEEEdCACSQ0AIARBBEkNACAGQQFrQf//A3EiDCAHQQFqQf//A3EiBGohCSAEIANB//8DcSIDTwRAQeyAASgCACELIAMgCUkEQCAAIAQgDCALEQUADAMLIAAgBCADIARrQQFqIAsRBQAMAgsgAyAJTw0BIAAgAyAJIANrQeyAASgCABEFAAwBCyAGIAdqQf//A3EiA0UNACAAIANBAWtB+IABKAIAEQIAGgsgBgwCCyAAIAAoAmgiBUECIAVBAkkbNgKELiABQQRGBEBBACEDIAAgACgCWCIBQQBOBH8gACgCSCABagVBAAsgBSABa0EBEA8gACAAKAJoNgJYIAAoAgAQCkEDQQIgACgCACgCEBsPCyAAKALwLQRAQQAhAkEAIQMgACAAKAJYIgFBAE4EfyAAKAJIIAFqBUEACyAFIAFrQQAQDyAAIAAoAmg2AlggACgCABAKIAAoAgAoAhBFDQMLQQEhAgwCCyADIQdBAQshBEEAIQYCQCAODQAgACgCPEGHAkkNACACIAdB//8DcSIQaiIDIAAoAkRBhgJrTw0AIAAgAzYCaEEAIQogACADQfiAASgCABECACEFAn8CQCAAKAJoIgitIAWtfSISQgFTDQAgEiAAKAIwQYYCa61VDQAgBUUNACAAIAVB8IABKAIAEQIAIQYgAC8BbCIKIAhB//8DcSIFTw0AIAZB//8DcSIDQQRJDQAgCCAEQf//A3FBAkkNARogCCACIApBAWpLDQEaIAggAiAFQQFqSw0BGiAIIAAoAkgiCSACa0EBaiICIApqLQAAIAIgBWotAABHDQEaIAggCUEBayICIApqIgwtAAAgAiAFaiIPLQAARw0BGiAIIAUgCCAAKAIwQYYCayICa0H//wNxQQAgAiAFSRsiEU0NARogCCADQf8BSw0BGiAGIQUgCCECIAQhAyAIIAoiCUECSQ0BGgNAAkAgA0EBayEDIAVBAWohCyAJQQFrIQkgAkEBayECIAxBAWsiDC0AACAPQQFrIg8tAABHDQAgA0H//wNxRQ0AIBEgAkH//wNxTw0AIAVB//8DcUH+AUsNACALIQUgCUH//wNxQQFLDQELCyAIIANB//8DcUEBSw0BGiAIIAtB//8DcUECRg0BGiAIQQFqIQggAyEEIAshBiAJIQogAgwBC0EBIQYgCAshBSAAIBA2AmgLAn8gBEH//wNxIgNBA00EQCAEQf//A3EiA0UNAyAAKAJIIAdB//8DcWotAAAhBCAAIAAoAvAtIgJBAWo2AvAtIAIgACgC7C1qQQA6AAAgACAAKALwLSICQQFqNgLwLSACIAAoAuwtakEAOgAAIAAgACgC8C0iAkEBajYC8C0gAiAAKALsLWogBDoAACAAIARBAnRqIgRB5AFqIAQvAeQBQQFqOwEAIAAgACgCPEEBazYCPCAAKALwLSICIAAoAvQtRiIEIANBAUYNARogACgCSCAHQQFqQf//A3FqLQAAIQkgACACQQFqNgLwLSAAKALsLSACakEAOgAAIAAgACgC8C0iAkEBajYC8C0gAiAAKALsLWpBADoAACAAIAAoAvAtIgJBAWo2AvAtIAIgACgC7C1qIAk6AAAgACAJQQJ0aiICQeQBaiACLwHkAUEBajsBACAAIAAoAjxBAWs2AjwgBCAAKALwLSICIAAoAvQtRmoiBCADQQJGDQEaIAAoAkggB0ECakH//wNxai0AACEHIAAgAkEBajYC8C0gACgC7C0gAmpBADoAACAAIAAoAvAtIgJBAWo2AvAtIAIgACgC7C1qQQA6AAAgACAAKALwLSICQQFqNgLwLSACIAAoAuwtaiAHOgAAIAAgB0ECdGoiB0HkAWogBy8B5AFBAWo7AQAgACAAKAI8QQFrNgI8IAQgACgC8C0gACgC9C1GagwBCyAAIAAoAvAtIgJBAWo2AvAtIAIgACgC7C1qIAdB//8DcSANQf//A3FrIgc6AAAgACAAKALwLSICQQFqNgLwLSACIAAoAuwtaiAHQQh2OgAAIAAgACgC8C0iAkEBajYC8C0gAiAAKALsLWogBEEDazoAACAAIAAoAoAuQQFqNgKALiADQf3OAGotAABBAnQgAGpB6AlqIgQgBC8BAEEBajsBACAAIAdBAWsiBCAEQQd2QYACaiAEQYACSRtBgMsAai0AAEECdGpB2BNqIgQgBC8BAEEBajsBACAAIAAoAjwgA2s2AjwgACgC8C0gACgC9C1GCyEEIAAgACgCaCADaiIHNgJoIARFDQFBACECQQAhBCAAIAAoAlgiA0EATgR/IAAoAkggA2oFQQALIAcgA2tBABAPIAAgACgCaDYCWCAAKAIAEAogACgCACgCEA0BCwsgAgu0BwIEfwF+AkADQAJAAkACQAJAIAAoAjxBhQJNBEAgABAvAkAgACgCPCICQYUCSw0AIAENAEEADwsgAkUNBCACQQRJDQELIAAgACgCaEH4gAEoAgARAgAhAiAANQJoIAKtfSIGQgFTDQAgBiAAKAIwQYYCa61VDQAgAkUNACAAIAJB8IABKAIAEQIAIgJBBEkNACAAIAAoAvAtIgNBAWo2AvAtIAMgACgC7C1qIAAoAmggACgCbGsiAzoAACAAIAAoAvAtIgRBAWo2AvAtIAQgACgC7C1qIANBCHY6AAAgACAAKALwLSIEQQFqNgLwLSAEIAAoAuwtaiACQQNrOgAAIAAgACgCgC5BAWo2AoAuIAJB/c4Aai0AAEECdCAAakHoCWoiBCAELwEAQQFqOwEAIAAgA0EBayIDIANBB3ZBgAJqIANBgAJJG0GAywBqLQAAQQJ0akHYE2oiAyADLwEAQQFqOwEAIAAgACgCPCACayIFNgI8IAAoAvQtIQMgACgC8C0hBCAAKAJ4IAJPQQAgBUEDSxsNASAAIAAoAmggAmoiAjYCaCAAIAJBAWtB+IABKAIAEQIAGiADIARHDQQMAgsgACgCSCAAKAJoai0AACECIAAgACgC8C0iA0EBajYC8C0gAyAAKALsLWpBADoAACAAIAAoAvAtIgNBAWo2AvAtIAMgACgC7C1qQQA6AAAgACAAKALwLSIDQQFqNgLwLSADIAAoAuwtaiACOgAAIAAgAkECdGoiAkHkAWogAi8B5AFBAWo7AQAgACAAKAI8QQFrNgI8IAAgACgCaEEBajYCaCAAKALwLSAAKAL0LUcNAwwBCyAAIAAoAmhBAWoiBTYCaCAAIAUgAkEBayICQeyAASgCABEFACAAIAAoAmggAmo2AmggAyAERw0CC0EAIQNBACECIAAgACgCWCIEQQBOBH8gACgCSCAEagVBAAsgACgCaCAEa0EAEA8gACAAKAJoNgJYIAAoAgAQCiAAKAIAKAIQDQEMAgsLIAAgACgCaCIEQQIgBEECSRs2AoQuIAFBBEYEQEEAIQIgACAAKAJYIgFBAE4EfyAAKAJIIAFqBUEACyAEIAFrQQEQDyAAIAAoAmg2AlggACgCABAKQQNBAiAAKAIAKAIQGw8LIAAoAvAtBEBBACEDQQAhAiAAIAAoAlgiAUEATgR/IAAoAkggAWoFQQALIAQgAWtBABAPIAAgACgCaDYCWCAAKAIAEAogACgCACgCEEUNAQtBASEDCyADC80JAgl/An4gAUEERiEGIAAoAiwhAgJAAkACQCABQQRGBEAgAkECRg0CIAIEQCAAQQAQUCAAQQA2AiwgACAAKAJoNgJYIAAoAgAQCiAAKAIAKAIQRQ0ECyAAIAYQTyAAQQI2AiwMAQsgAg0BIAAoAjxFDQEgACAGEE8gAEEBNgIsCyAAIAAoAmg2AlgLQQJBASABQQRGGyEKA0ACQCAAKAIMIAAoAhBBCGpLDQAgACgCABAKIAAoAgAiAigCEA0AQQAhAyABQQRHDQIgAigCBA0CIAAoAqAuDQIgACgCLEVBAXQPCwJAAkAgACgCPEGFAk0EQCAAEC8CQCAAKAI8IgNBhQJLDQAgAQ0AQQAPCyADRQ0CIAAoAiwEfyADBSAAIAYQTyAAIAo2AiwgACAAKAJoNgJYIAAoAjwLQQRJDQELIAAgACgCaEH4gAEoAgARAgAhBCAAKAJoIgKtIAStfSILQgFTDQAgCyAAKAIwQYYCa61VDQAgAiAAKAJIIgJqIgMvAAAgAiAEaiICLwAARw0AIANBAmogAkECakHQgAEoAgARAgBBAmoiA0EESQ0AIAAoAjwiAiADIAIgA0kbIgJBggIgAkGCAkkbIgdB/c4Aai0AACICQQJ0IgRBhMkAajMBACEMIARBhskAai8BACEDIAJBCGtBE00EQCAHQQNrIARBgNEAaigCAGutIAOthiAMhCEMIARBsNYAaigCACADaiEDCyAAKAKgLiEFIAMgC6dBAWsiCCAIQQd2QYACaiAIQYACSRtBgMsAai0AACICQQJ0IglBgsoAai8BAGohBCAJQYDKAGozAQAgA62GIAyEIQsgACkDmC4hDAJAIAUgAkEESQR/IAQFIAggCUGA0gBqKAIAa60gBK2GIAuEIQsgCUGw1wBqKAIAIARqCyICaiIDQT9NBEAgCyAFrYYgDIQhCwwBCyAFQcAARgRAIAAoAgQgACgCEGogDDcAACAAIAAoAhBBCGo2AhAgAiEDDAELIAAoAgQgACgCEGogCyAFrYYgDIQ3AAAgACAAKAIQQQhqNgIQIANBQGohAyALQcAAIAVrrYghCwsgACALNwOYLiAAIAM2AqAuIAAgACgCPCAHazYCPCAAIAAoAmggB2o2AmgMAgsgACgCSCAAKAJoai0AAEECdCICQYDBAGozAQAhCyAAKQOYLiEMAkAgACgCoC4iBCACQYLBAGovAQAiAmoiA0E/TQRAIAsgBK2GIAyEIQsMAQsgBEHAAEYEQCAAKAIEIAAoAhBqIAw3AAAgACAAKAIQQQhqNgIQIAIhAwwBCyAAKAIEIAAoAhBqIAsgBK2GIAyENwAAIAAgACgCEEEIajYCECADQUBqIQMgC0HAACAEa62IIQsLIAAgCzcDmC4gACADNgKgLiAAIAAoAmhBAWo2AmggACAAKAI8QQFrNgI8DAELCyAAIAAoAmgiAkECIAJBAkkbNgKELiAAKAIsIQIgAUEERgRAAkAgAkUNACAAQQEQUCAAQQA2AiwgACAAKAJoNgJYIAAoAgAQCiAAKAIAKAIQDQBBAg8LQQMPCyACBEBBACEDIABBABBQIABBADYCLCAAIAAoAmg2AlggACgCABAKIAAoAgAoAhBFDQELQQEhAwsgAwucAQEFfyACQQFOBEAgAiAAKAJIIAFqIgNqQQJqIQQgA0ECaiECIAAoAlQhAyAAKAJQIQUDQCAAIAItAAAgA0EFdEHg/wFxcyIDNgJUIAUgA0EBdGoiBi8BACIHIAFB//8DcUcEQCAAKAJMIAEgACgCOHFB//8DcUEBdGogBzsBACAGIAE7AQALIAFBAWohASACQQFqIgIgBEkNAAsLC1sBAn8gACAAKAJIIAFqLQACIAAoAlRBBXRB4P8BcXMiAjYCVCABIAAoAlAgAkEBdGoiAy8BACICRwRAIAAoAkwgACgCOCABcUEBdGogAjsBACADIAE7AQALIAILEwAgAUEFdEHg/wFxIAJB/wFxcwsGACABEAYLLwAjAEEQayIAJAAgAEEMaiABIAJsEIwBIQEgACgCDCECIABBEGokAEEAIAIgARsLjAoCAX4CfyMAQfAAayIGJAACQAJAAkACQAJAAkACQAJAIAQODwABBwIEBQYGBgYGBgYGAwYLQn8hBQJAIAAgBkHkAGpCDBARIgNCf1cEQCABBEAgASAAKAIMNgIAIAEgACgCEDYCBAsMAQsCQCADQgxSBEAgAQRAIAFBADYCBCABQRE2AgALDAELIAEoAhQhBEEAIQJCASEFA0AgBkHkAGogAmoiAiACLQAAIARB/f8DcSICQQJyIAJBA3NsQQh2cyICOgAAIAYgAjoAKCABAn8gASgCDEF/cyECQQAgBkEoaiIERQ0AGiACIARBAUHUgAEoAgARAAALQX9zIgI2AgwgASABKAIQIAJB/wFxakGFiKLAAGxBAWoiAjYCECAGIAJBGHY6ACggAQJ/IAEoAhRBf3MhAkEAIAZBKGoiBEUNABogAiAEQQFB1IABKAIAEQAAC0F/cyIENgIUIAVCDFIEQCAFpyECIAVCAXwhBQwBCwtCACEFIAAgBkEoahAhQQBIDQEgBigCUCEAIwBBEGsiAiQAIAIgADYCDCAGAn8gAkEMahCNASIARQRAIAZBITsBJEEADAELAn8gACgCFCIEQdAATgRAIARBCXQMAQsgAEHQADYCFEGAwAILIQQgBiAAKAIMIAQgACgCEEEFdGpqQaDAAWo7ASQgACgCBEEFdCAAKAIIQQt0aiAAKAIAQQF2ags7ASYgAkEQaiQAIAYtAG8iACAGLQBXRg0BIAYtACcgAEYNASABBEAgAUEANgIEIAFBGzYCAAsLQn8hBQsgBkHwAGokACAFDwtCfyEFIAAgAiADEBEiA0J/VwRAIAEEQCABIAAoAgw2AgAgASAAKAIQNgIECwwGCyMAQRBrIgAkAAJAIANQDQAgASgCFCEEIAJFBEBCASEFA0AgACACIAdqLQAAIARB/f8DcSIEQQJyIARBA3NsQQh2czoADyABAn8gASgCDEF/cyEEQQAgAEEPaiIHRQ0AGiAEIAdBAUHUgAEoAgARAAALQX9zIgQ2AgwgASABKAIQIARB/wFxakGFiKLAAGxBAWoiBDYCECAAIARBGHY6AA8gAQJ/IAEoAhRBf3MhBEEAIABBD2oiB0UNABogBCAHQQFB1IABKAIAEQAAC0F/cyIENgIUIAMgBVENAiAFpyEHIAVCAXwhBQwACwALQgEhBQNAIAAgAiAHai0AACAEQf3/A3EiBEECciAEQQNzbEEIdnMiBDoADyACIAdqIAQ6AAAgAQJ/IAEoAgxBf3MhBEEAIABBD2oiB0UNABogBCAHQQFB1IABKAIAEQAAC0F/cyIENgIMIAEgASgCECAEQf8BcWpBhYiiwABsQQFqIgQ2AhAgACAEQRh2OgAPIAECfyABKAIUQX9zIQRBACAAQQ9qIgdFDQAaIAQgB0EBQdSAASgCABEAAAtBf3MiBDYCFCADIAVRDQEgBachByAFQgF8IQUMAAsACyAAQRBqJAAgAyEFDAULIAJBADsBMiACIAIpAwAiA0KAAYQ3AwAgA0IIg1ANBCACIAIpAyBCDH03AyAMBAsgBkKFgICAcDcDECAGQoOAgIDAADcDCCAGQoGAgIAgNwMAQQAgBhAkIQUMAwsgA0IIWgR+IAIgASgCADYCACACIAEoAgQ2AgRCCAVCfwshBQwCCyABEAYMAQsgAQRAIAFBADYCBCABQRI2AgALQn8hBQsgBkHwAGokACAFC60DAgJ/An4jAEEQayIGJAACQAJAAkAgBEUNACABRQ0AIAJBAUYNAQtBACEDIABBCGoiAARAIABBADYCBCAAQRI2AgALDAELIANBAXEEQEEAIQMgAEEIaiIABEAgAEEANgIEIABBGDYCAAsMAQtBGBAJIgVFBEBBACEDIABBCGoiAARAIABBADYCBCAAQQ42AgALDAELIAVBADYCCCAFQgA3AgAgBUGQ8dmiAzYCFCAFQvis0ZGR8dmiIzcCDAJAIAQQIiICRQ0AIAKtIQhBACEDQYfTru5+IQJCASEHA0AgBiADIARqLQAAOgAPIAUgBkEPaiIDBH8gAiADQQFB1IABKAIAEQAABUEAC0F/cyICNgIMIAUgBSgCECACQf8BcWpBhYiiwABsQQFqIgI2AhAgBiACQRh2OgAPIAUCfyAFKAIUQX9zIQJBACAGQQ9qIgNFDQAaIAIgA0EBQdSAASgCABEAAAtBf3M2AhQgByAIUQ0BIAUoAgxBf3MhAiAHpyEDIAdCAXwhBwwACwALIAAgAUElIAUQQiIDDQAgBRAGQQAhAwsgBkEQaiQAIAMLnRoCBn4FfyMAQdAAayILJAACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQAJAAkACQCADDhQFBhULAwQJDgACCBAKDw0HEQERDBELAkBByAAQCSIBBEAgAUIANwMAIAFCADcDMCABQQA2AiggAUIANwMgIAFCADcDGCABQgA3AxAgAUIANwMIIAFCADcDOCABQQgQCSIDNgIEIAMNASABEAYgAARAIABBADYCBCAAQQ42AgALCyAAQQA2AhQMFAsgA0IANwMAIAAgATYCFCABQUBrQgA3AwAgAUIANwM4DBQLAkACQCACUARAQcgAEAkiA0UNFCADQgA3AwAgA0IANwMwIANBADYCKCADQgA3AyAgA0IANwMYIANCADcDECADQgA3AwggA0IANwM4IANBCBAJIgE2AgQgAQ0BIAMQBiAABEAgAEEANgIEIABBDjYCAAsMFAsgAiAAKAIQIgEpAzBWBEAgAARAIABBADYCBCAAQRI2AgALDBQLIAEoAigEQCAABEAgAEEANgIEIABBHTYCAAsMFAsgASgCBCEDAkAgASkDCCIGQgF9IgdQDQADQAJAIAIgAyAHIAR9QgGIIAR8IgWnQQN0aikDAFQEQCAFQgF9IQcMAQsgBSAGUQRAIAYhBQwDCyADIAVCAXwiBKdBA3RqKQMAIAJWDQILIAQhBSAEIAdUDQALCwJAIAIgAyAFpyIKQQN0aikDAH0iBFBFBEAgASgCACIDIApBBHRqKQMIIQcMAQsgASgCACIDIAVCAX0iBadBBHRqKQMIIgchBAsgAiAHIAR9VARAIAAEQCAAQQA2AgQgAEEcNgIACwwUCyADIAVCAXwiBUEAIAAQiQEiA0UNEyADKAIAIAMoAggiCkEEdGpBCGsgBDcDACADKAIEIApBA3RqIAI3AwAgAyACNwMwIAMgASkDGCIGIAMpAwgiBEIBfSIHIAYgB1QbNwMYIAEgAzYCKCADIAE2AiggASAENwMgIAMgBTcDIAwBCyABQgA3AwALIAAgAzYCFCADIAQ3A0AgAyACNwM4QgAhBAwTCyAAKAIQIgEEQAJAIAEoAigiA0UEQCABKQMYIQIMAQsgA0EANgIoIAEoAihCADcDICABIAEpAxgiAiABKQMgIgUgAiAFVhsiAjcDGAsgASkDCCACVgRAA0AgASgCACACp0EEdGooAgAQBiACQgF8IgIgASkDCFQNAAsLIAEoAgAQBiABKAIEEAYgARAGCyAAKAIUIQEgAEEANgIUIAAgATYCEAwSCyACQghaBH4gASAAKAIANgIAIAEgACgCBDYCBEIIBUJ/CyEEDBELIAAoAhAiAQRAAkAgASgCKCIDRQRAIAEpAxghAgwBCyADQQA2AiggASgCKEIANwMgIAEgASkDGCICIAEpAyAiBSACIAVWGyICNwMYCyABKQMIIAJWBEADQCABKAIAIAKnQQR0aigCABAGIAJCAXwiAiABKQMIVA0ACwsgASgCABAGIAEoAgQQBiABEAYLIAAoAhQiAQRAAkAgASgCKCIDRQRAIAEpAxghAgwBCyADQQA2AiggASgCKEIANwMgIAEgASkDGCICIAEpAyAiBSACIAVWGyICNwMYCyABKQMIIAJWBEADQCABKAIAIAKnQQR0aigCABAGIAJCAXwiAiABKQMIVA0ACwsgASgCABAGIAEoAgQQBiABEAYLIAAQBgwQCyAAKAIQIgBCADcDOCAAQUBrQgA3AwAMDwsgAkJ/VwRAIAAEQCAAQQA2AgQgAEESNgIACwwOCyACIAAoAhAiAykDMCADKQM4IgZ9IgUgAiAFVBsiBVANDiABIAMpA0AiB6ciAEEEdCIBIAMoAgBqIgooAgAgBiADKAIEIABBA3RqKQMAfSICp2ogBSAKKQMIIAJ9IgYgBSAGVBsiBKcQByEKIAcgBCADKAIAIgAgAWopAwggAn1RrXwhAiAFIAZWBEADQCAKIASnaiAAIAKnQQR0IgFqIgAoAgAgBSAEfSIGIAApAwgiByAGIAdUGyIGpxAHGiACIAYgAygCACIAIAFqKQMIUa18IQIgBSAEIAZ8IgRWDQALCyADIAI3A0AgAyADKQM4IAR8NwM4DA4LQn8hBEHIABAJIgNFDQ0gA0IANwMAIANCADcDMCADQQA2AiggA0IANwMgIANCADcDGCADQgA3AxAgA0IANwMIIANCADcDOCADQQgQCSIBNgIEIAFFBEAgAxAGIAAEQCAAQQA2AgQgAEEONgIACwwOCyABQgA3AwAgACgCECIBBEACQCABKAIoIgpFBEAgASkDGCEEDAELIApBADYCKCABKAIoQgA3AyAgASABKQMYIgIgASkDICIFIAIgBVYbIgQ3AxgLIAEpAwggBFYEQANAIAEoAgAgBKdBBHRqKAIAEAYgBEIBfCIEIAEpAwhUDQALCyABKAIAEAYgASgCBBAGIAEQBgsgACADNgIQQgAhBAwNCyAAKAIUIgEEQAJAIAEoAigiA0UEQCABKQMYIQIMAQsgA0EANgIoIAEoAihCADcDICABIAEpAxgiAiABKQMgIgUgAiAFVhsiAjcDGAsgASkDCCACVgRAA0AgASgCACACp0EEdGooAgAQBiACQgF8IgIgASkDCFQNAAsLIAEoAgAQBiABKAIEEAYgARAGCyAAQQA2AhQMDAsgACgCECIDKQM4IAMpAzAgASACIAAQRCIHQgBTDQogAyAHNwM4AkAgAykDCCIGQgF9IgJQDQAgAygCBCEAA0ACQCAHIAAgAiAEfUIBiCAEfCIFp0EDdGopAwBUBEAgBUIBfSECDAELIAUgBlEEQCAGIQUMAwsgACAFQgF8IgSnQQN0aikDACAHVg0CCyAEIQUgAiAEVg0ACwsgAyAFNwNAQgAhBAwLCyAAKAIUIgMpAzggAykDMCABIAIgABBEIgdCAFMNCSADIAc3AzgCQCADKQMIIgZCAX0iAlANACADKAIEIQADQAJAIAcgACACIAR9QgGIIAR8IgWnQQN0aikDAFQEQCAFQgF9IQIMAQsgBSAGUQRAIAYhBQwDCyAAIAVCAXwiBKdBA3RqKQMAIAdWDQILIAQhBSACIARWDQALCyADIAU3A0BCACEEDAoLIAJCN1gEQCAABEAgAEEANgIEIABBEjYCAAsMCQsgARAqIAEgACgCDDYCKCAAKAIQKQMwIQIgAUEANgIwIAEgAjcDICABIAI3AxggAULcATcDAEI4IQQMCQsgACABKAIANgIMDAgLIAtBQGtBfzYCACALQouAgICwAjcDOCALQoyAgIDQATcDMCALQo+AgICgATcDKCALQpGAgICQATcDICALQoeAgICAATcDGCALQoWAgIDgADcDECALQoOAgIDAADcDCCALQoGAgIAgNwMAQQAgCxAkIQQMBwsgACgCECkDOCIEQn9VDQYgAARAIABBPTYCBCAAQR42AgALDAULIAAoAhQpAzgiBEJ/VQ0FIAAEQCAAQT02AgQgAEEeNgIACwwEC0J/IQQgAkJ/VwRAIAAEQCAAQQA2AgQgAEESNgIACwwFCyACIAAoAhQiAykDOCACfCIFQv//A3wiBFYEQCAABEAgAEEANgIEIABBEjYCAAsMBAsCQCAFIAMoAgQiCiADKQMIIganQQN0aikDACIHWA0AAkAgBCAHfUIQiCAGfCIIIAMpAxAiCVgNAEIQIAkgCVAbIQUDQCAFIgRCAYYhBSAEIAhUDQALIAQgCVQNACADKAIAIASnIgpBBHQQNCIMRQ0DIAMgDDYCACADKAIEIApBA3RBCGoQNCIKRQ0DIAMgBDcDECADIAo2AgQgAykDCCEGCyAGIAhaDQAgAygCACEMA0AgDCAGp0EEdGoiDUGAgAQQCSIONgIAIA5FBEAgAARAIABBADYCBCAAQQ42AgALDAYLIA1CgIAENwMIIAMgBkIBfCIFNwMIIAogBadBA3RqIAdCgIAEfCIHNwMAIAMpAwgiBiAIVA0ACwsgAykDQCEFIAMpAzghBwJAIAJQBEBCACEEDAELIAWnIgBBBHQiDCADKAIAaiINKAIAIAcgCiAAQQN0aikDAH0iBqdqIAEgAiANKQMIIAZ9IgcgAiAHVBsiBKcQBxogBSAEIAMoAgAiACAMaikDCCAGfVGtfCEFIAIgB1YEQANAIAAgBadBBHQiCmoiACgCACABIASnaiACIAR9IgYgACkDCCIHIAYgB1QbIganEAcaIAUgBiADKAIAIgAgCmopAwhRrXwhBSAEIAZ8IgQgAlQNAAsLIAMpAzghBwsgAyAFNwNAIAMgBCAHfCICNwM4IAIgAykDMFgNBCADIAI3AzAMBAsgAARAIABBADYCBCAAQRw2AgALDAILIAAEQCAAQQA2AgQgAEEONgIACyAABEAgAEEANgIEIABBDjYCAAsMAQsgAEEANgIUC0J/IQQLIAtB0ABqJAAgBAtIAQF/IABCADcCBCAAIAE2AgACQCABQQBIDQBBsBMoAgAgAUwNACABQQJ0QcATaigCAEEBRw0AQYSEASgCACECCyAAIAI2AgQLDgAgAkGx893xeWxBEHYLvgEAIwBBEGsiACQAIABBADoACEGAgQFBAjYCAEH8gAFBAzYCAEH4gAFBBDYCAEH0gAFBBTYCAEHwgAFBBjYCAEHsgAFBBzYCAEHogAFBCDYCAEHkgAFBCTYCAEHggAFBCjYCAEHcgAFBCzYCAEHYgAFBDDYCAEHUgAFBDTYCAEHQgAFBDjYCAEHMgAFBDzYCAEHIgAFBEDYCAEHEgAFBETYCAEHAgAFBEjYCACAAQRBqJAAgAkGx893xeWxBEHYLuQEBAX8jAEEQayIBJAAgAUEAOgAIQYCBAUECNgIAQfyAAUEDNgIAQfiAAUEENgIAQfSAAUEFNgIAQfCAAUEGNgIAQeyAAUEHNgIAQeiAAUEINgIAQeSAAUEJNgIAQeCAAUEKNgIAQdyAAUELNgIAQdiAAUEMNgIAQdSAAUENNgIAQdCAAUEONgIAQcyAAUEPNgIAQciAAUEQNgIAQcSAAUERNgIAQcCAAUESNgIAIAAQjgEgAUEQaiQAC78BAQF/IwBBEGsiAiQAIAJBADoACEGAgQFBAjYCAEH8gAFBAzYCAEH4gAFBBDYCAEH0gAFBBTYCAEHwgAFBBjYCAEHsgAFBBzYCAEHogAFBCDYCAEHkgAFBCTYCAEHggAFBCjYCAEHcgAFBCzYCAEHYgAFBDDYCAEHUgAFBDTYCAEHQgAFBDjYCAEHMgAFBDzYCAEHIgAFBEDYCAEHEgAFBETYCAEHAgAFBEjYCACAAIAEQkAEhACACQRBqJAAgAAu+AQEBfyMAQRBrIgIkACACQQA6AAhBgIEBQQI2AgBB/IABQQM2AgBB+IABQQQ2AgBB9IABQQU2AgBB8IABQQY2AgBB7IABQQc2AgBB6IABQQg2AgBB5IABQQk2AgBB4IABQQo2AgBB3IABQQs2AgBB2IABQQw2AgBB1IABQQ02AgBB0IABQQ42AgBBzIABQQ82AgBByIABQRA2AgBBxIABQRE2AgBBwIABQRI2AgAgACABEFohACACQRBqJAAgAAu+AQEBfyMAQRBrIgIkACACQQA6AAhBgIEBQQI2AgBB/IABQQM2AgBB+IABQQQ2AgBB9IABQQU2AgBB8IABQQY2AgBB7IABQQc2AgBB6IABQQg2AgBB5IABQQk2AgBB4IABQQo2AgBB3IABQQs2AgBB2IABQQw2AgBB1IABQQ02AgBB0IABQQ42AgBBzIABQQ82AgBByIABQRA2AgBBxIABQRE2AgBBwIABQRI2AgAgACABEFshACACQRBqJAAgAAu9AQEBfyMAQRBrIgMkACADQQA6AAhBgIEBQQI2AgBB/IABQQM2AgBB+IABQQQ2AgBB9IABQQU2AgBB8IABQQY2AgBB7IABQQc2AgBB6IABQQg2AgBB5IABQQk2AgBB4IABQQo2AgBB3IABQQs2AgBB2IABQQw2AgBB1IABQQ02AgBB0IABQQ42AgBBzIABQQ82AgBByIABQRA2AgBBxIABQRE2AgBBwIABQRI2AgAgACABIAIQjwEgA0EQaiQAC4UBAgR/AX4jAEEQayIBJAACQCAAKQMwUARADAELA0ACQCAAIAVBACABQQ9qIAFBCGoQZiIEQX9GDQAgAS0AD0EDRw0AIAIgASgCCEGAgICAf3FBgICAgHpGaiECC0F/IQMgBEF/Rg0BIAIhAyAFQgF8IgUgACkDMFQNAAsLIAFBEGokACADCwuMdSUAQYAIC7ELaW5zdWZmaWNpZW50IG1lbW9yeQBuZWVkIGRpY3Rpb25hcnkALSsgICAwWDB4AFppcCBhcmNoaXZlIGluY29uc2lzdGVudABJbnZhbGlkIGFyZ3VtZW50AGludmFsaWQgbGl0ZXJhbC9sZW5ndGhzIHNldABpbnZhbGlkIGNvZGUgbGVuZ3RocyBzZXQAdW5rbm93biBoZWFkZXIgZmxhZ3Mgc2V0AGludmFsaWQgZGlzdGFuY2VzIHNldABpbnZhbGlkIGJpdCBsZW5ndGggcmVwZWF0AEZpbGUgYWxyZWFkeSBleGlzdHMAdG9vIG1hbnkgbGVuZ3RoIG9yIGRpc3RhbmNlIHN5bWJvbHMAaW52YWxpZCBzdG9yZWQgYmxvY2sgbGVuZ3RocwAlcyVzJXMAYnVmZmVyIGVycm9yAE5vIGVycm9yAHN0cmVhbSBlcnJvcgBUZWxsIGVycm9yAEludGVybmFsIGVycm9yAFNlZWsgZXJyb3IAV3JpdGUgZXJyb3IAZmlsZSBlcnJvcgBSZWFkIGVycm9yAFpsaWIgZXJyb3IAZGF0YSBlcnJvcgBDUkMgZXJyb3IAaW5jb21wYXRpYmxlIHZlcnNpb24AaW52YWxpZCBjb2RlIC0tIG1pc3NpbmcgZW5kLW9mLWJsb2NrAGluY29ycmVjdCBoZWFkZXIgY2hlY2sAaW5jb3JyZWN0IGxlbmd0aCBjaGVjawBpbmNvcnJlY3QgZGF0YSBjaGVjawBpbnZhbGlkIGRpc3RhbmNlIHRvbyBmYXIgYmFjawBoZWFkZXIgY3JjIG1pc21hdGNoADEuMi4xMy56bGliLW5nAGludmFsaWQgd2luZG93IHNpemUAUmVhZC1vbmx5IGFyY2hpdmUATm90IGEgemlwIGFyY2hpdmUAUmVzb3VyY2Ugc3RpbGwgaW4gdXNlAE1hbGxvYyBmYWlsdXJlAGludmFsaWQgYmxvY2sgdHlwZQBGYWlsdXJlIHRvIGNyZWF0ZSB0ZW1wb3JhcnkgZmlsZQBDYW4ndCBvcGVuIGZpbGUATm8gc3VjaCBmaWxlAFByZW1hdHVyZSBlbmQgb2YgZmlsZQBDYW4ndCByZW1vdmUgZmlsZQBpbnZhbGlkIGxpdGVyYWwvbGVuZ3RoIGNvZGUAaW52YWxpZCBkaXN0YW5jZSBjb2RlAHVua25vd24gY29tcHJlc3Npb24gbWV0aG9kAHN0cmVhbSBlbmQAQ29tcHJlc3NlZCBkYXRhIGludmFsaWQATXVsdGktZGlzayB6aXAgYXJjaGl2ZXMgbm90IHN1cHBvcnRlZABPcGVyYXRpb24gbm90IHN1cHBvcnRlZABFbmNyeXB0aW9uIG1ldGhvZCBub3Qgc3VwcG9ydGVkAENvbXByZXNzaW9uIG1ldGhvZCBub3Qgc3VwcG9ydGVkAEVudHJ5IGhhcyBiZWVuIGRlbGV0ZWQAQ29udGFpbmluZyB6aXAgYXJjaGl2ZSB3YXMgY2xvc2VkAENsb3NpbmcgemlwIGFyY2hpdmUgZmFpbGVkAFJlbmFtaW5nIHRlbXBvcmFyeSBmaWxlIGZhaWxlZABFbnRyeSBoYXMgYmVlbiBjaGFuZ2VkAE5vIHBhc3N3b3JkIHByb3ZpZGVkAFdyb25nIHBhc3N3b3JkIHByb3ZpZGVkAFVua25vd24gZXJyb3IgJWQAQUUAKG51bGwpADogAFBLBgcAUEsGBgBQSwUGAFBLAwQAUEsBAgAAAAA/BQAAwAcAAJMIAAB4CAAAbwUAAJEFAAB6BQAAsgUAAFYIAAAbBwAA1gQAAAsHAADqBgAAnAUAAMgGAACyCAAAHggAACgHAABHBAAAoAYAAGAFAAAuBAAAPgcAAD8IAAD+BwAAjgYAAMkIAADeCAAA5gcAALIGAABVBQAAqAcAACAAQcgTCxEBAAAAAQAAAAEAAAABAAAAAQBB7BMLCQEAAAABAAAAAgBBmBQLAQEAQbgUCwEBAEHSFAukLDomOyZlJmYmYyZgJiIg2CXLJdklQiZAJmomayY8JrolxCWVITwgtgCnAKwlqCGRIZMhkiGQIR8ilCGyJbwlIAAhACIAIwAkACUAJgAnACgAKQAqACsALAAtAC4ALwAwADEAMgAzADQANQA2ADcAOAA5ADoAOwA8AD0APgA/AEAAQQBCAEMARABFAEYARwBIAEkASgBLAEwATQBOAE8AUABRAFIAUwBUAFUAVgBXAFgAWQBaAFsAXABdAF4AXwBgAGEAYgBjAGQAZQBmAGcAaABpAGoAawBsAG0AbgBvAHAAcQByAHMAdAB1AHYAdwB4AHkAegB7AHwAfQB+AAIjxwD8AOkA4gDkAOAA5QDnAOoA6wDoAO8A7gDsAMQAxQDJAOYAxgD0APYA8gD7APkA/wDWANwAogCjAKUApyCSAeEA7QDzAPoA8QDRAKoAugC/ABAjrAC9ALwAoQCrALsAkSWSJZMlAiUkJWElYiVWJVUlYyVRJVclXSVcJVslECUUJTQlLCUcJQAlPCVeJV8lWiVUJWklZiVgJVAlbCVnJWglZCVlJVklWCVSJVMlayVqJRglDCWIJYQljCWQJYAlsQPfAJMDwAOjA8MDtQDEA6YDmAOpA7QDHiLGA7UDKSJhIrEAZSJkIiAjISP3AEgisAAZIrcAGiJ/ILIAoCWgAAAAAACWMAd3LGEO7rpRCZkZxG0Hj/RqcDWlY+mjlWSeMojbDqS43Hke6dXgiNnSlytMtgm9fLF+By2455Edv5BkELcd8iCwakhxufPeQb6EfdTaGuvk3W1RtdT0x4XTg1aYbBPAqGtkevli/ezJZYpPXAEU2WwGY2M9D/r1DQiNyCBuO14QaUzkQWDVcnFnotHkAzxH1ARL/YUN0mu1CqX6qLU1bJiyQtbJu9tA+bys42zYMnVc30XPDdbcWT3Rq6ww2SY6AN5RgFHXyBZh0L+19LQhI8SzVpmVus8Ppb24nrgCKAiIBV+y2QzGJOkLsYd8by8RTGhYqx1hwT0tZraQQdx2BnHbAbwg0pgqENXviYWxcR+1tgal5L+fM9S46KLJB3g0+QAPjqgJlhiYDuG7DWp/LT1tCJdsZJEBXGPm9FFra2JhbBzYMGWFTgBi8u2VBmx7pQEbwfQIglfED/XG2bBlUOm3Euq4vot8iLn83x3dYkkt2hXzfNOMZUzU+1hhsk3OUbU6dAC8o+Iwu9RBpd9K15XYPW3E0aT79NbTaulpQ/zZbjRGiGet0Lhg2nMtBETlHQMzX0wKqsl8Dd08cQVQqkECJxAQC76GIAzJJbVoV7OFbyAJ1Ga5n+Rhzg753l6YydkpIpjQsLSo18cXPbNZgQ20LjtcvbetbLrAIIO47bazv5oM4rYDmtKxdDlH1eqvd9KdFSbbBIMW3HMSC2PjhDtklD5qbQ2oWmp6C88O5J3/CZMnrgAKsZ4HfUSTD/DSowiHaPIBHv7CBmldV2L3y2dlgHE2bBnnBmtudhvU/uAr04laetoQzErdZ2/fufn5776OQ763F9WOsGDoo9bWfpPRocTC2DhS8t9P8We70WdXvKbdBrU/SzaySNorDdhMGwqv9koDNmB6BEHD72DfVd9nqO+ObjF5vmlGjLNhyxqDZryg0m8lNuJoUpV3DMwDRwu7uRYCIi8mBVW+O7rFKAu9spJatCsEarNcp//XwjHP0LWLntksHa7eW7DCZJsm8mPsnKNqdQqTbQKpBgmcPzYO64VnB3ITVwAFgkq/lRR6uOKuK7F7OBu2DJuO0pINvtXlt+/cfCHf2wvU0tOGQuLU8fiz3Whug9ofzRa+gVsmufbhd7Bvd0e3GOZaCIhwag//yjsGZlwLARH/nmWPaa5i+NP/a2FFz2wWeOIKoO7SDddUgwROwrMDOWEmZ6f3FmDQTUdpSdt3bj5KatGu3FrW2WYL30DwO9g3U668qcWeu95/z7JH6f+1MBzyvb2KwrrKMJOzU6ajtCQFNtC6kwbXzSlX3lS/Z9kjLnpms7hKYcQCG2hdlCtvKje+C7ShjgzDG98FWo3vAi0AAAAARjtnZYx2zsrKTamvWevtTh/QiivVnSOEk6ZE4bLW25307bz4PqAVV3ibcjLrPTbTrQZRtmdL+BkhcJ98JavG4GOQoYWp3Qgq7+ZvT3xAK646e0zL8DblZLYNggGXfR190UZ6GBsL07ddMLTSzpbwM4itl1ZC4D75BNtZnAtQ/BpNa5t/hyYy0MEdVbVSuxFUFIB2Md7N356Y9rj7uYYnh/+9QOI18OlNc8uOKOBtysmmVq2sbBsEAyogY2Yu+zr6aMBdn6KN9DDktpNVdxDXtDErsNH7Zhl+vV1+G5wt4WfaFoYCEFsvrVZgSMjFxgwpg/1rTEmwwuMPi6WGFqD4NVCbn1Ca1jb/3O1Rmk9LFXsJcHIewz3bsYUGvNSkdiOo4k1EzSgA7WJuO4oH/Z3O5rumqYNx6wAsN9BnSTMLPtV1MFmwv33wH/lGl3pq4NObLNu0/uaWHVGgrXo0gd3lSMfmgi0NqyuCS5BM59g2CAaeDW9jVEDGzBJ7oakd8AQvW8tjSpGGyuXXva2ARBvpYQIgjgTIbSerjlZAzq8m37LpHbjXI1AReGVrdh32zTL8sPZVmXq7/DY8gJtTOFvCz35gpaq0LQwF8hZrYGGwL4Eni0jk7cbhS6v9hi6KjRlSzLZ+Nwb715hAwLD902b0HJVdk3lfEDrWGStdsyxA8Wtqe5YOoDY/oeYNWMR1qxwlM5B7QPnd0u+/5rWKnpYq9titTZMS4OQ8VNuDWcd9x7iBRqDdSwsJcg0wbhcJ6zeLT9BQ7oWd+UHDpp4kUADaxRY7vaDcdhQPmk1zars97Bb9BotzN0si3HFwRbni1gFYpO1mPW6gz5Iom6j3JxANcWErahSrZsO77V2k3n774D84wIda8o0u9bS2SZCVxtbs0/2xiRmwGCZfi39DzC07oooWXMdAW/VoBmCSDQK7y5FEgKz0js0FW8j2Yj5bUCbfHWtButcm6BWRHY9wsG0QDPZWd2k8G97GeiC5o+mG/UKvvZonZfAziCPLVO064AlefNtuO7aWx5TwraDxYwvkECUwg3XvfSraqUZNv4g20sPODbWmBEAcCUJ7e2zR3T+Nl+ZY6F2r8UcbkJYiH0vPvllwqNuTPQF01QZmEUagIvAAm0WVytbsOozti1+tnRQj66ZzRiHr2uln0L2M9Hb5bbJNngh4ADenPjtQwjGw9UR3i5IhvcY7jvv9XOtoWxgKLmB/b+Qt1sCiFrGlg2Yu2cVdSbwPEOATSSuHdtqNw5ectqTyVvsNXRDAajgUGzOkUiBUwZht/W7eVpoLTfDe6gvLuY/BhhAgh713RabN6Dng9o9cKrsm82yAQZb/JgV3uR1iEnNQy701a6zYAAAAAFiA4tfxBrR0qYZWo+INaOm6jYo+EwvcnUuLPkqFHaEJ3Z1D3nQbFX0sm/eqZxDJ4D+QKzeWFn2UzpafQwo7QhNSu6DE+z32Z6O9FLDoNir6sLbILRkwno5BsHxZjybjGtemAc1+IFduJqC1uW0ri/M1q2kknC0/h8St3VAUdoQmTPZm8eVwMFK98NKF9nvsz677DhgHfVi7X/26bJFrJS/J68f4YG2RWzjtc4xzZk3GK+avEYJg+bLa4BtlHk3GNUbNJOLvS3JBt8uQlvxArtykwEwLDUYaqFXG+H+bUGc8w9CF62pW00gy1jGfeV0P1SHd7QKIW7uh0NtZdijsCE1wbOqa2eq8OYFqXu7K4WCkkmGCczvn1NBjZzYHrfGpRPVxS5Nc9x0wBHf/50/8wa0XfCN6vvp12eZ6lw4i10peeleoidPR/iqLURz9wNoit5hawGAx3JbDaVx0FKfK61f/SgmAVsxfIw5MvfRFx4O+HUdhabTBN8rsQdUdPJqMa2QabrzNnDgflRzayN6X5IKGFwZVL5FQ9ncRsiG5hy1i4QfPtUiBmRYQAXvBW4pFiwMKp1yqjPH/8gwTKDahznhuISyvx6d6DJ8nmNvUrKaRjCxERiWqEuV9KvAys7xvces8jaZCutsFGjo50lGxB5gJMeVPoLez7Pg3UTtQ2BGaCFjzTaHepe75Xkc5stV5c+pVm6RD080HG1Mv0NXFsJONRVJEJMME53xD5jA3yNh6b0g6rcbObA6eTo7ZWuNTiQJjsV6r5ef982UFKrjuO2Dgbtm3SeiPFBFobcPf/vKAh34QVy74RvR2eKQjPfOaaWVzeL7M9S4dlHXMykSulbwcLndrtaghyO0owx+mo/1V/iMfglelSSEPJav2wbM0tZkz1mIwtYDBaDViFiO+XFx7Pr6L0rjoKIo4Cv9OldevFhU1eL+TY9vnE4EMrJi/RvQYXZFdngsyBR7p5cuIdqaTCJRxOo7C0mIOIAUphR5PcQX8mNiDqjuAA0jseDQZ1yC0+wCJMq2j0bJPdJo5cT7CuZPpaz/FSjO/J539KbjepalaCQwvDKpUr+59HyTQN0ekMuDuImRDtqKGlHIPW8Qqj7kTgwnvsNuJDWeQAjMtyILR+mEEh1k5hGWO9xL6za+SGBoGFE65XpSsbhUfkiRNn3Dz5BkmULyZxIdsQp3xNMJ/Jp1EKYXFxMtSjk/1GNbPF89/SUFsJ8mju+lfPPix394vGFmIjEDZalsLUlQRU9K2xvpU4GWi1AKyZnnf4j75PTWXf2uWz/+JQYR0twvc9FXcdXIDfy3y4ajjZH7ru+ScPBJiyp9K4ihIAWkWAlnp9NXwb6J2qO9AoQAAAADhtlLvg2vUBWLdhuoG16gL52H65IW8fA5kCi7hDK5RF+0YA/iPxYUSbnPX/Qp5+Rzrz6vziRItGWikf/YYXKMu+erxwZs3dyt6gSXEHosLJf89Wcqd4N8gfFaNzxTy8jn1RKDWl5kmPHYvdNMSJVoy85MI3ZFOjjdw+NzYMLhGXdEOFLKz05JYUmXAtzZv7lbX2by5tQQ6U1SyaLw8FhdK3aBFpb99w09ey5GgOsG/Qdt37a65qmtEWBw5qyjk5XPJUrecq48xdko5Y5kuM014z4Ufl61YmX1M7suSJEq0ZMX85ounIWBhRpcyjiKdHG/DK06AofbIakBAmoVgcI26gcbfVeMbWb8CrQtQZqclsYcRd17lzPG0BHqjW2ze3K2NaI5C77UIqA4DWkdqCXSmi78mSelioKMI1PJMeCwulJmafHv7R/qRGvGofn77hp+fTdRw/ZBSmhwmAHV0gn+DlTQtbPfpq4YWX/lpclXXiJPjhWfxPgONEIhRYlDIy+exfpkI06Mf4jIVTQ1WH2Pst6kxA9V0t+k0wuUGXGaa8L3QyB/fDU71PrscGlqxMvu7B2AU2drm/jhstBFIlGjJqSI6Jsv/vMwqSe4jTkPAwq/1ki3NKBTHLJ5GKEQ6Od6ljGsxx1Ht2ybnvzRC7ZHVo1vDOsGGRdAgMBc/geZrrmBQOUECjb+r4zvtRIcxw6Vmh5FKBFoXoOXsRU+NSDq5bP5oVg4j7rzvlbxTi5+SsmopwF0I9Ea36UIUWJm6yIB4DJpvGtEchftnTmqfbWCLftsyZBwGtI79sOZhlRSZl3Siy3gWf02S98kffZPDMZxydWNzEKjlmfEet3axXi3zUOh/HDI1+fbTg6sZt4mF+FY/1xc04lH91VQDEr3wfORcRi4LPpuo4d8t+g67J9TvWpGGADhMAOrZ+lIFqQKO3Ui03DIqaVrYy98IN6/VJtZOY3Q5LL7y080IoDylrN/KRBqNJSbHC8/HcVkgo3t3wULNJS4gEKPEwabxK+GW5hQAILT7Yv0yEYNLYP7nQU4fBvcc8GQqmhqFnMj17Ti3AwyO5exuU2MGj+Ux6evvHwgKWU3naITLDYkymeL5ykU6GHwX1XqhkT+bF8PQ/x3tMR6rv958djk0ncBr2/VkFC0U0kbCdg/AKJe5ksfzs7wmEgXuyXDYaCORbjrM0S6gSTCY8qZSRXRMs/Mmo9f5CEI2T1qtVJLcR7UkjqjdgPFePDajsV7rJVu/XXe021dZVTrhC7pYPI1QuYrfv8lyA2coxFGIShnXYquvhY3PpatsLhP5g0zOf2mteC2GxdxScCRqAJ9Gt4Z1pwHUmsML+nsivaiUQGAufqHWfJEAAAAAQ8umh8eQPNSEW5pTzycIc4zsrvQItzSnS3ySIJ5PEObdhLZhWd8sMhoUirVRaBiVEqO+Epb4JEHVM4LGfZlRFz5S95C6CW3D+cLLRLK+WWTxdf/jdS5lsDblwzfj1kHxoB3ndiRGfSVnjduiLPFJgm867wXrYXVWqKrT0foyoy65+QWpPaKf+n5pOX01Fatddt4N2vKFl4mxTjEOZH2zyCe2FU+j7Y8c4CYpm6tau7vokR08bMqHby8BIeiHq/I5xGBUvkA7zu0D8GhqSIz6SgtHXM2PHMaezNdgGRnk4t9aL0RY3nTeC52/eIzWw+qslQhMKxFT1nhSmHD/9GVGXbeu4Noz9XqJcD7cDjtCTi54ieip/NJy+r8Z1H1qKla7KeHwPK26am/ucczopQ1eyObG+E9inWIcIVbEm4n8F0rKN7HNTmwrng2njRlG2x85BRC5voFLI+3CgIVqF7MHrFR4oSvQIzt4k+id/9iUD9+bX6lYHwQzC1zPlYwOV+VzTZxD9MnH2aeKDH8gwXDtAIK7S4cG4NHURSt3U5AY9ZXT01MSV4jJQRRDb8ZfP/3mHPRbYZivwTLbZGe1c860ZDAFEuO0Xoiw95UuN7zpvBf/IhqQe3mAwziyJkTtgaSCrkoCBSoRmFZp2j7RIqas8WFtCnblNpAlpv02oujLjLqrACo9L1uwbmyQFukn7ITJZCciTuB8uB2jtx6adoScXDVPOtuxFKCI8t8GD7mjlC/6aDKofjOo+z34DnyVUt2t1pl7KlLC4XkRCUf+WnXV3hm+c1md5ekK3i5PjQsdzUtI1mvMzI3xn49GVxjEOsU4h/FjvwOq+exAYV9rEvkvlFEyiRPVaRNAlqK1x93eJ+eeFYFgGk4bM1mFvbSMtj9yz32Z9UsmA6YI7aUhQ5E3AQBakYaEAQvVx8qtUm9gfoMsq9gEqPBCV+s75NCgR3bw44zQd2fXSiQkHOyj8S9uZbLkyOI2v1KxdXT0Nj4IZhZ9w8CR+ZhawrpT/EUcrsrnX2VsYNs+9jOY9VC004nClJBCZBMUGf5AV9JYx4Lh2gHBKnyGRXHm1Qa6QFJNxtJyDg109YpW7qbJnUghYTeb8CL8PXemp6ck5WwBo64Qk4Pt2zUEaYCvVypLCdD/eIsWvLMtkTjot8J7IxFFMF+DZXOUJeL3z7+xtAQZNuacacmlV89OIQxVHWLH85opu2G6anDHPe4rXW6t4PvpeNN5LzsY36i/Q0X7/IjjfLf0cVz0P9fbcGRNiDOv6w+bBTje2M6eWVyVBAofXqKNVCIwrRfpliqTsgx50Hmq/gVKKDhGgY6/wtoU7IERsmvKbSBLiaaGzA39HJ9ONroYFAQAAJ0HAAAsCQAAhgUAAEgFAACnBQAAAAQAADIFAAC8BQAALAkAQYDBAAv3CQwACACMAAgATAAIAMwACAAsAAgArAAIAGwACADsAAgAHAAIAJwACABcAAgA3AAIADwACAC8AAgAfAAIAPwACAACAAgAggAIAEIACADCAAgAIgAIAKIACABiAAgA4gAIABIACACSAAgAUgAIANIACAAyAAgAsgAIAHIACADyAAgACgAIAIoACABKAAgAygAIACoACACqAAgAagAIAOoACAAaAAgAmgAIAFoACADaAAgAOgAIALoACAB6AAgA+gAIAAYACACGAAgARgAIAMYACAAmAAgApgAIAGYACADmAAgAFgAIAJYACABWAAgA1gAIADYACAC2AAgAdgAIAPYACAAOAAgAjgAIAE4ACADOAAgALgAIAK4ACABuAAgA7gAIAB4ACACeAAgAXgAIAN4ACAA+AAgAvgAIAH4ACAD+AAgAAQAIAIEACABBAAgAwQAIACEACAChAAgAYQAIAOEACAARAAgAkQAIAFEACADRAAgAMQAIALEACABxAAgA8QAIAAkACACJAAgASQAIAMkACAApAAgAqQAIAGkACADpAAgAGQAIAJkACABZAAgA2QAIADkACAC5AAgAeQAIAPkACAAFAAgAhQAIAEUACADFAAgAJQAIAKUACABlAAgA5QAIABUACACVAAgAVQAIANUACAA1AAgAtQAIAHUACAD1AAgADQAIAI0ACABNAAgAzQAIAC0ACACtAAgAbQAIAO0ACAAdAAgAnQAIAF0ACADdAAgAPQAIAL0ACAB9AAgA/QAIABMACQATAQkAkwAJAJMBCQBTAAkAUwEJANMACQDTAQkAMwAJADMBCQCzAAkAswEJAHMACQBzAQkA8wAJAPMBCQALAAkACwEJAIsACQCLAQkASwAJAEsBCQDLAAkAywEJACsACQArAQkAqwAJAKsBCQBrAAkAawEJAOsACQDrAQkAGwAJABsBCQCbAAkAmwEJAFsACQBbAQkA2wAJANsBCQA7AAkAOwEJALsACQC7AQkAewAJAHsBCQD7AAkA+wEJAAcACQAHAQkAhwAJAIcBCQBHAAkARwEJAMcACQDHAQkAJwAJACcBCQCnAAkApwEJAGcACQBnAQkA5wAJAOcBCQAXAAkAFwEJAJcACQCXAQkAVwAJAFcBCQDXAAkA1wEJADcACQA3AQkAtwAJALcBCQB3AAkAdwEJAPcACQD3AQkADwAJAA8BCQCPAAkAjwEJAE8ACQBPAQkAzwAJAM8BCQAvAAkALwEJAK8ACQCvAQkAbwAJAG8BCQDvAAkA7wEJAB8ACQAfAQkAnwAJAJ8BCQBfAAkAXwEJAN8ACQDfAQkAPwAJAD8BCQC/AAkAvwEJAH8ACQB/AQkA/wAJAP8BCQAAAAcAQAAHACAABwBgAAcAEAAHAFAABwAwAAcAcAAHAAgABwBIAAcAKAAHAGgABwAYAAcAWAAHADgABwB4AAcABAAHAEQABwAkAAcAZAAHABQABwBUAAcANAAHAHQABwADAAgAgwAIAEMACADDAAgAIwAIAKMACABjAAgA4wAIAAAABQAQAAUACAAFABgABQAEAAUAFAAFAAwABQAcAAUAAgAFABIABQAKAAUAGgAFAAYABQAWAAUADgAFAB4ABQABAAUAEQAFAAkABQAZAAUABQAFABUABQANAAUAHQAFAAMABQATAAUACwAFABsABQAHAAUAFwAFAEGBywAL7AYBAgMEBAUFBgYGBgcHBwcICAgICAgICAkJCQkJCQkJCgoKCgoKCgoKCgoKCgoKCgsLCwsLCwsLCwsLCwsLCwsMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDA0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8AABAREhITExQUFBQVFRUVFhYWFhYWFhYXFxcXFxcXFxgYGBgYGBgYGBgYGBgYGBgZGRkZGRkZGRkZGRkZGRkZGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhobGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwdHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dAAECAwQFBgcICAkJCgoLCwwMDAwNDQ0NDg4ODg8PDw8QEBAQEBAQEBEREREREREREhISEhISEhITExMTExMTExQUFBQUFBQUFBQUFBQUFBQVFRUVFRUVFRUVFRUVFRUVFhYWFhYWFhYWFhYWFhYWFhcXFxcXFxcXFxcXFxcXFxcYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhoaGhobGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbHAAAAAABAAAAAgAAAAMAAAAEAAAABQAAAAYAAAAHAAAACAAAAAoAAAAMAAAADgAAABAAAAAUAAAAGAAAABwAAAAgAAAAKAAAADAAAAA4AAAAQAAAAFAAAABgAAAAcAAAAIAAAACgAAAAwAAAAOAAQYTSAAutAQEAAAACAAAAAwAAAAQAAAAGAAAACAAAAAwAAAAQAAAAGAAAACAAAAAwAAAAQAAAAGAAAACAAAAAwAAAAAABAACAAQAAAAIAAAADAAAABAAAAAYAAAAIAAAADAAAABAAAAAYAAAAIAAAADAAAABAAAAAYAAAgCAAAMApAAABAQAAHgEAAA8AAAAAJQAAQCoAAAAAAAAeAAAADwAAAAAAAADAKgAAAAAAABMAAAAHAEHg0wALTQEAAAABAAAAAQAAAAEAAAACAAAAAgAAAAIAAAACAAAAAwAAAAMAAAADAAAAAwAAAAQAAAAEAAAABAAAAAQAAAAFAAAABQAAAAUAAAAFAEHQ1AALZQEAAAABAAAAAgAAAAIAAAADAAAAAwAAAAQAAAAEAAAABQAAAAUAAAAGAAAABgAAAAcAAAAHAAAACAAAAAgAAAAJAAAACQAAAAoAAAAKAAAACwAAAAsAAAAMAAAADAAAAA0AAAANAEGA1gALIwIAAAADAAAABwAAAAAAAAAQERIACAcJBgoFCwQMAw0CDgEPAEHQ1gALTQEAAAABAAAAAQAAAAEAAAACAAAAAgAAAAIAAAACAAAAAwAAAAMAAAADAAAAAwAAAAQAAAAEAAAABAAAAAQAAAAFAAAABQAAAAUAAAAFAEHA1wALZQEAAAABAAAAAgAAAAIAAAADAAAAAwAAAAQAAAAEAAAABQAAAAUAAAAGAAAABgAAAAcAAAAHAAAACAAAAAgAAAAJAAAACQAAAAoAAAAKAAAACwAAAAsAAAAMAAAADAAAAA0AAAANAEG42AALASwAQcTYAAthLQAAAAQABAAIAAQALgAAAAQABgAQAAYALwAAAAQADAAgABgALwAAAAgAEAAgACAALwAAAAgAEACAAIAALwAAAAgAIACAAAABMAAAACAAgAACAQAEMAAAACAAAgECAQAQMABBsNkAC6UTAwAEAAUABgAHAAgACQAKAAsADQAPABEAEwAXABsAHwAjACsAMwA7AEMAUwBjAHMAgwCjAMMA4wACAQAAAAAAABAAEAAQABAAEAAQABAAEAARABEAEQARABIAEgASABIAEwATABMAEwAUABQAFAAUABUAFQAVABUAEABNAMoAAAABAAIAAwAEAAUABwAJAA0AEQAZACEAMQBBAGEAgQDBAAEBgQEBAgEDAQQBBgEIAQwBEAEYASABMAFAAWAAAAAAEAAQABAAEAARABEAEgASABMAEwAUABQAFQAVABYAFgAXABcAGAAYABkAGQAaABoAGwAbABwAHAAdAB0AQABAAGAHAAAACFAAAAgQABQIcwASBx8AAAhwAAAIMAAACcAAEAcKAAAIYAAACCAAAAmgAAAIAAAACIAAAAhAAAAJ4AAQBwYAAAhYAAAIGAAACZAAEwc7AAAIeAAACDgAAAnQABEHEQAACGgAAAgoAAAJsAAACAgAAAiIAAAISAAACfAAEAcEAAAIVAAACBQAFQjjABMHKwAACHQAAAg0AAAJyAARBw0AAAhkAAAIJAAACagAAAgEAAAIhAAACEQAAAnoABAHCAAACFwAAAgcAAAJmAAUB1MAAAh8AAAIPAAACdgAEgcXAAAIbAAACCwAAAm4AAAIDAAACIwAAAhMAAAJ+AAQBwMAAAhSAAAIEgAVCKMAEwcjAAAIcgAACDIAAAnEABEHCwAACGIAAAgiAAAJpAAACAIAAAiCAAAIQgAACeQAEAcHAAAIWgAACBoAAAmUABQHQwAACHoAAAg6AAAJ1AASBxMAAAhqAAAIKgAACbQAAAgKAAAIigAACEoAAAn0ABAHBQAACFYAAAgWAEAIAAATBzMAAAh2AAAINgAACcwAEQcPAAAIZgAACCYAAAmsAAAIBgAACIYAAAhGAAAJ7AAQBwkAAAheAAAIHgAACZwAFAdjAAAIfgAACD4AAAncABIHGwAACG4AAAguAAAJvAAACA4AAAiOAAAITgAACfwAYAcAAAAIUQAACBEAFQiDABIHHwAACHEAAAgxAAAJwgAQBwoAAAhhAAAIIQAACaIAAAgBAAAIgQAACEEAAAniABAHBgAACFkAAAgZAAAJkgATBzsAAAh5AAAIOQAACdIAEQcRAAAIaQAACCkAAAmyAAAICQAACIkAAAhJAAAJ8gAQBwQAAAhVAAAIFQAQCAIBEwcrAAAIdQAACDUAAAnKABEHDQAACGUAAAglAAAJqgAACAUAAAiFAAAIRQAACeoAEAcIAAAIXQAACB0AAAmaABQHUwAACH0AAAg9AAAJ2gASBxcAAAhtAAAILQAACboAAAgNAAAIjQAACE0AAAn6ABAHAwAACFMAAAgTABUIwwATByMAAAhzAAAIMwAACcYAEQcLAAAIYwAACCMAAAmmAAAIAwAACIMAAAhDAAAJ5gAQBwcAAAhbAAAIGwAACZYAFAdDAAAIewAACDsAAAnWABIHEwAACGsAAAgrAAAJtgAACAsAAAiLAAAISwAACfYAEAcFAAAIVwAACBcAQAgAABMHMwAACHcAAAg3AAAJzgARBw8AAAhnAAAIJwAACa4AAAgHAAAIhwAACEcAAAnuABAHCQAACF8AAAgfAAAJngAUB2MAAAh/AAAIPwAACd4AEgcbAAAIbwAACC8AAAm+AAAIDwAACI8AAAhPAAAJ/gBgBwAAAAhQAAAIEAAUCHMAEgcfAAAIcAAACDAAAAnBABAHCgAACGAAAAggAAAJoQAACAAAAAiAAAAIQAAACeEAEAcGAAAIWAAACBgAAAmRABMHOwAACHgAAAg4AAAJ0QARBxEAAAhoAAAIKAAACbEAAAgIAAAIiAAACEgAAAnxABAHBAAACFQAAAgUABUI4wATBysAAAh0AAAINAAACckAEQcNAAAIZAAACCQAAAmpAAAIBAAACIQAAAhEAAAJ6QAQBwgAAAhcAAAIHAAACZkAFAdTAAAIfAAACDwAAAnZABIHFwAACGwAAAgsAAAJuQAACAwAAAiMAAAITAAACfkAEAcDAAAIUgAACBIAFQijABMHIwAACHIAAAgyAAAJxQARBwsAAAhiAAAIIgAACaUAAAgCAAAIggAACEIAAAnlABAHBwAACFoAAAgaAAAJlQAUB0MAAAh6AAAIOgAACdUAEgcTAAAIagAACCoAAAm1AAAICgAACIoAAAhKAAAJ9QAQBwUAAAhWAAAIFgBACAAAEwczAAAIdgAACDYAAAnNABEHDwAACGYAAAgmAAAJrQAACAYAAAiGAAAIRgAACe0AEAcJAAAIXgAACB4AAAmdABQHYwAACH4AAAg+AAAJ3QASBxsAAAhuAAAILgAACb0AAAgOAAAIjgAACE4AAAn9AGAHAAAACFEAAAgRABUIgwASBx8AAAhxAAAIMQAACcMAEAcKAAAIYQAACCEAAAmjAAAIAQAACIEAAAhBAAAJ4wAQBwYAAAhZAAAIGQAACZMAEwc7AAAIeQAACDkAAAnTABEHEQAACGkAAAgpAAAJswAACAkAAAiJAAAISQAACfMAEAcEAAAIVQAACBUAEAgCARMHKwAACHUAAAg1AAAJywARBw0AAAhlAAAIJQAACasAAAgFAAAIhQAACEUAAAnrABAHCAAACF0AAAgdAAAJmwAUB1MAAAh9AAAIPQAACdsAEgcXAAAIbQAACC0AAAm7AAAIDQAACI0AAAhNAAAJ+wAQBwMAAAhTAAAIEwAVCMMAEwcjAAAIcwAACDMAAAnHABEHCwAACGMAAAgjAAAJpwAACAMAAAiDAAAIQwAACecAEAcHAAAIWwAACBsAAAmXABQHQwAACHsAAAg7AAAJ1wASBxMAAAhrAAAIKwAACbcAAAgLAAAIiwAACEsAAAn3ABAHBQAACFcAAAgXAEAIAAATBzMAAAh3AAAINwAACc8AEQcPAAAIZwAACCcAAAmvAAAIBwAACIcAAAhHAAAJ7wAQBwkAAAhfAAAIHwAACZ8AFAdjAAAIfwAACD8AAAnfABIHGwAACG8AAAgvAAAJvwAACA8AAAiPAAAITwAACf8AEAUBABcFAQETBREAGwUBEBEFBQAZBQEEFQVBAB0FAUAQBQMAGAUBAhQFIQAcBQEgEgUJABoFAQgWBYEAQAUAABAFAgAXBYEBEwUZABsFARgRBQcAGQUBBhUFYQAdBQFgEAUEABgFAQMUBTEAHAUBMBIFDQAaBQEMFgXBAEAFAAAQABEAEgAAAAgABwAJAAYACgAFAAsABAAMAAMADQACAA4AAQAPAEHg7AALQREACgAREREAAAAABQAAAAAAAAkAAAAACwAAAAAAAAAAEQAPChEREQMKBwABAAkLCwAACQYLAAALAAYRAAAAERERAEGx7QALIQsAAAAAAAAAABEACgoREREACgAAAgAJCwAAAAkACwAACwBB6+0ACwEMAEH37QALFQwAAAAADAAAAAAJDAAAAAAADAAADABBpe4ACwEOAEGx7gALFQ0AAAAEDQAAAAAJDgAAAAAADgAADgBB3+4ACwEQAEHr7gALHg8AAAAADwAAAAAJEAAAAAAAEAAAEAAAEgAAABISEgBBou8ACw4SAAAAEhISAAAAAAAACQBB0+8ACwELAEHf7wALFQoAAAAACgAAAAAJCwAAAAAACwAACwBBjfAACwEMAEGZ8AALJwwAAAAADAAAAAAJDAAAAAAADAAADAAAMDEyMzQ1Njc4OUFCQ0RFRgBB5PAACwE+AEGL8QALBf//////AEHQ8QALVxkSRDsCPyxHFD0zMAobBkZLRTcPSQ6OFwNAHTxpKzYfSi0cASAlKSEIDBUWIi4QOD4LNDEYZHR1di9BCX85ESNDMkKJiosFBCYoJw0qHjWMBxpIkxOUlQBBsPIAC4oOSWxsZWdhbCBieXRlIHNlcXVlbmNlAERvbWFpbiBlcnJvcgBSZXN1bHQgbm90IHJlcHJlc2VudGFibGUATm90IGEgdHR5AFBlcm1pc3Npb24gZGVuaWVkAE9wZXJhdGlvbiBub3QgcGVybWl0dGVkAE5vIHN1Y2ggZmlsZSBvciBkaXJlY3RvcnkATm8gc3VjaCBwcm9jZXNzAEZpbGUgZXhpc3RzAFZhbHVlIHRvbyBsYXJnZSBmb3IgZGF0YSB0eXBlAE5vIHNwYWNlIGxlZnQgb24gZGV2aWNlAE91dCBvZiBtZW1vcnkAUmVzb3VyY2UgYnVzeQBJbnRlcnJ1cHRlZCBzeXN0ZW0gY2FsbABSZXNvdXJjZSB0ZW1wb3JhcmlseSB1bmF2YWlsYWJsZQBJbnZhbGlkIHNlZWsAQ3Jvc3MtZGV2aWNlIGxpbmsAUmVhZC1vbmx5IGZpbGUgc3lzdGVtAERpcmVjdG9yeSBub3QgZW1wdHkAQ29ubmVjdGlvbiByZXNldCBieSBwZWVyAE9wZXJhdGlvbiB0aW1lZCBvdXQAQ29ubmVjdGlvbiByZWZ1c2VkAEhvc3QgaXMgZG93bgBIb3N0IGlzIHVucmVhY2hhYmxlAEFkZHJlc3MgaW4gdXNlAEJyb2tlbiBwaXBlAEkvTyBlcnJvcgBObyBzdWNoIGRldmljZSBvciBhZGRyZXNzAEJsb2NrIGRldmljZSByZXF1aXJlZABObyBzdWNoIGRldmljZQBOb3QgYSBkaXJlY3RvcnkASXMgYSBkaXJlY3RvcnkAVGV4dCBmaWxlIGJ1c3kARXhlYyBmb3JtYXQgZXJyb3IASW52YWxpZCBhcmd1bWVudABBcmd1bWVudCBsaXN0IHRvbyBsb25nAFN5bWJvbGljIGxpbmsgbG9vcABGaWxlbmFtZSB0b28gbG9uZwBUb28gbWFueSBvcGVuIGZpbGVzIGluIHN5c3RlbQBObyBmaWxlIGRlc2NyaXB0b3JzIGF2YWlsYWJsZQBCYWQgZmlsZSBkZXNjcmlwdG9yAE5vIGNoaWxkIHByb2Nlc3MAQmFkIGFkZHJlc3MARmlsZSB0b28gbGFyZ2UAVG9vIG1hbnkgbGlua3MATm8gbG9ja3MgYXZhaWxhYmxlAFJlc291cmNlIGRlYWRsb2NrIHdvdWxkIG9jY3VyAFN0YXRlIG5vdCByZWNvdmVyYWJsZQBQcmV2aW91cyBvd25lciBkaWVkAE9wZXJhdGlvbiBjYW5jZWxlZABGdW5jdGlvbiBub3QgaW1wbGVtZW50ZWQATm8gbWVzc2FnZSBvZiBkZXNpcmVkIHR5cGUASWRlbnRpZmllciByZW1vdmVkAERldmljZSBub3QgYSBzdHJlYW0ATm8gZGF0YSBhdmFpbGFibGUARGV2aWNlIHRpbWVvdXQAT3V0IG9mIHN0cmVhbXMgcmVzb3VyY2VzAExpbmsgaGFzIGJlZW4gc2V2ZXJlZABQcm90b2NvbCBlcnJvcgBCYWQgbWVzc2FnZQBGaWxlIGRlc2NyaXB0b3IgaW4gYmFkIHN0YXRlAE5vdCBhIHNvY2tldABEZXN0aW5hdGlvbiBhZGRyZXNzIHJlcXVpcmVkAE1lc3NhZ2UgdG9vIGxhcmdlAFByb3RvY29sIHdyb25nIHR5cGUgZm9yIHNvY2tldABQcm90b2NvbCBub3QgYXZhaWxhYmxlAFByb3RvY29sIG5vdCBzdXBwb3J0ZWQAU29ja2V0IHR5cGUgbm90IHN1cHBvcnRlZABOb3Qgc3VwcG9ydGVkAFByb3RvY29sIGZhbWlseSBub3Qgc3VwcG9ydGVkAEFkZHJlc3MgZmFtaWx5IG5vdCBzdXBwb3J0ZWQgYnkgcHJvdG9jb2wAQWRkcmVzcyBub3QgYXZhaWxhYmxlAE5ldHdvcmsgaXMgZG93bgBOZXR3b3JrIHVucmVhY2hhYmxlAENvbm5lY3Rpb24gcmVzZXQgYnkgbmV0d29yawBDb25uZWN0aW9uIGFib3J0ZWQATm8gYnVmZmVyIHNwYWNlIGF2YWlsYWJsZQBTb2NrZXQgaXMgY29ubmVjdGVkAFNvY2tldCBub3QgY29ubmVjdGVkAENhbm5vdCBzZW5kIGFmdGVyIHNvY2tldCBzaHV0ZG93bgBPcGVyYXRpb24gYWxyZWFkeSBpbiBwcm9ncmVzcwBPcGVyYXRpb24gaW4gcHJvZ3Jlc3MAU3RhbGUgZmlsZSBoYW5kbGUAUmVtb3RlIEkvTyBlcnJvcgBRdW90YSBleGNlZWRlZABObyBtZWRpdW0gZm91bmQAV3JvbmcgbWVkaXVtIHR5cGUATm8gZXJyb3IgaW5mb3JtYXRpb24AQcCAAQuFARMAAAAUAAAAFQAAABYAAAAXAAAAGAAAABkAAAAaAAAAGwAAABwAAAAdAAAAHgAAAB8AAAAgAAAAIQAAACIAAAAjAAAAgERQADEAAAAyAAAAMwAAADQAAAA1AAAANgAAADcAAAA4AAAAOQAAADIAAAAzAAAANAAAADUAAAA2AAAANwAAADgAQfSCAQsCXEQAQbCDAQsQ/////////////////////w=="; + if (!isDataURI(wasmBinaryFile)) { + wasmBinaryFile = locateFile(wasmBinaryFile); + } + function getBinary(file) { + try { + if (file == wasmBinaryFile && wasmBinary) { + return new Uint8Array(wasmBinary); + } + var binary = tryParseAsDataURI(file); + if (binary) { + return binary; + } + if (readBinary) { + return readBinary(file); + } else { + throw "sync fetching of the wasm failed: you can preload it to Module['wasmBinary'] manually, or emcc.py will do that for you when generating HTML (but not JS)"; + } + } catch (err2) { + abort(err2); + } + } + function instantiateSync(file, info) { + var instance; + var module2; + var binary; + try { + binary = getBinary(file); + module2 = new WebAssembly.Module(binary); + instance = new WebAssembly.Instance(module2, info); + } catch (e) { + var str = e.toString(); + err("failed to compile wasm module: " + str); + if (str.includes("imported Memory") || str.includes("memory import")) { + err( + "Memory size incompatibility issues may be due to changing INITIAL_MEMORY at runtime to something too large. Use ALLOW_MEMORY_GROWTH to allow any size memory (and also make sure not to set INITIAL_MEMORY at runtime to something smaller than it was at compile time)." + ); + } + throw e; + } + return [instance, module2]; + } + function createWasm() { + var info = { a: asmLibraryArg }; + function receiveInstance(instance, module2) { + var exports3 = instance.exports; + Module["asm"] = exports3; + wasmMemory = Module["asm"]["g"]; + updateGlobalBufferAndViews(wasmMemory.buffer); + wasmTable = Module["asm"]["W"]; + addOnInit(Module["asm"]["h"]); + removeRunDependency(); + } + addRunDependency(); + if (Module["instantiateWasm"]) { + try { + var exports2 = Module["instantiateWasm"](info, receiveInstance); + return exports2; + } catch (e) { + err("Module.instantiateWasm callback failed with error: " + e); + return false; + } + } + var result = instantiateSync(wasmBinaryFile, info); + receiveInstance(result[0]); + return Module["asm"]; + } + function LE_HEAP_LOAD_F32(byteOffset) { + return HEAP_DATA_VIEW.getFloat32(byteOffset, true); + } + function LE_HEAP_LOAD_F64(byteOffset) { + return HEAP_DATA_VIEW.getFloat64(byteOffset, true); + } + function LE_HEAP_LOAD_I16(byteOffset) { + return HEAP_DATA_VIEW.getInt16(byteOffset, true); + } + function LE_HEAP_LOAD_I32(byteOffset) { + return HEAP_DATA_VIEW.getInt32(byteOffset, true); + } + function LE_HEAP_STORE_I32(byteOffset, value) { + HEAP_DATA_VIEW.setInt32(byteOffset, value, true); + } + function callRuntimeCallbacks(callbacks) { + while (callbacks.length > 0) { + var callback = callbacks.shift(); + if (typeof callback == "function") { + callback(Module); + continue; + } + var func = callback.func; + if (typeof func === "number") { + if (callback.arg === void 0) { + wasmTable.get(func)(); + } else { + wasmTable.get(func)(callback.arg); + } + } else { + func(callback.arg === void 0 ? null : callback.arg); + } + } + } + function _gmtime_r(time, tmPtr) { + var date = new Date(LE_HEAP_LOAD_I32((time >> 2) * 4) * 1e3); + LE_HEAP_STORE_I32((tmPtr >> 2) * 4, date.getUTCSeconds()); + LE_HEAP_STORE_I32((tmPtr + 4 >> 2) * 4, date.getUTCMinutes()); + LE_HEAP_STORE_I32((tmPtr + 8 >> 2) * 4, date.getUTCHours()); + LE_HEAP_STORE_I32((tmPtr + 12 >> 2) * 4, date.getUTCDate()); + LE_HEAP_STORE_I32((tmPtr + 16 >> 2) * 4, date.getUTCMonth()); + LE_HEAP_STORE_I32((tmPtr + 20 >> 2) * 4, date.getUTCFullYear() - 1900); + LE_HEAP_STORE_I32((tmPtr + 24 >> 2) * 4, date.getUTCDay()); + LE_HEAP_STORE_I32((tmPtr + 36 >> 2) * 4, 0); + LE_HEAP_STORE_I32((tmPtr + 32 >> 2) * 4, 0); + var start = Date.UTC(date.getUTCFullYear(), 0, 1, 0, 0, 0, 0); + var yday = (date.getTime() - start) / (1e3 * 60 * 60 * 24) | 0; + LE_HEAP_STORE_I32((tmPtr + 28 >> 2) * 4, yday); + if (!_gmtime_r.GMTString) _gmtime_r.GMTString = allocateUTF8("GMT"); + LE_HEAP_STORE_I32((tmPtr + 40 >> 2) * 4, _gmtime_r.GMTString); + return tmPtr; + } + function ___gmtime_r(a0, a1) { + return _gmtime_r(a0, a1); + } + function _emscripten_memcpy_big(dest, src, num) { + HEAPU8.copyWithin(dest, src, src + num); + } + function emscripten_realloc_buffer(size) { + try { + wasmMemory.grow(size - buffer.byteLength + 65535 >>> 16); + updateGlobalBufferAndViews(wasmMemory.buffer); + return 1; + } catch (e) { + } + } + function _emscripten_resize_heap(requestedSize) { + var oldSize = HEAPU8.length; + requestedSize = requestedSize >>> 0; + var maxHeapSize = 2147483648; + if (requestedSize > maxHeapSize) { + return false; + } + for (var cutDown = 1; cutDown <= 4; cutDown *= 2) { + var overGrownHeapSize = oldSize * (1 + 0.2 / cutDown); + overGrownHeapSize = Math.min( + overGrownHeapSize, + requestedSize + 100663296 + ); + var newSize = Math.min( + maxHeapSize, + alignUp(Math.max(requestedSize, overGrownHeapSize), 65536) + ); + var replacement = emscripten_realloc_buffer(newSize); + if (replacement) { + return true; + } + } + return false; + } + function _setTempRet0(val) { + } + function _time(ptr) { + var ret = Date.now() / 1e3 | 0; + if (ptr) { + LE_HEAP_STORE_I32((ptr >> 2) * 4, ret); + } + return ret; + } + function _tzset() { + if (_tzset.called) return; + _tzset.called = true; + var currentYear = (/* @__PURE__ */ new Date()).getFullYear(); + var winter = new Date(currentYear, 0, 1); + var summer = new Date(currentYear, 6, 1); + var winterOffset = winter.getTimezoneOffset(); + var summerOffset = summer.getTimezoneOffset(); + var stdTimezoneOffset = Math.max(winterOffset, summerOffset); + LE_HEAP_STORE_I32((__get_timezone() >> 2) * 4, stdTimezoneOffset * 60); + LE_HEAP_STORE_I32( + (__get_daylight() >> 2) * 4, + Number(winterOffset != summerOffset) + ); + function extractZone(date) { + var match = date.toTimeString().match(/\(([A-Za-z ]+)\)$/); + return match ? match[1] : "GMT"; + } + var winterName = extractZone(winter); + var summerName = extractZone(summer); + var winterNamePtr = allocateUTF8(winterName); + var summerNamePtr = allocateUTF8(summerName); + if (summerOffset < winterOffset) { + LE_HEAP_STORE_I32((__get_tzname() >> 2) * 4, winterNamePtr); + LE_HEAP_STORE_I32((__get_tzname() + 4 >> 2) * 4, summerNamePtr); + } else { + LE_HEAP_STORE_I32((__get_tzname() >> 2) * 4, summerNamePtr); + LE_HEAP_STORE_I32((__get_tzname() + 4 >> 2) * 4, winterNamePtr); + } + } + function _timegm(tmPtr) { + _tzset(); + var time = Date.UTC( + LE_HEAP_LOAD_I32((tmPtr + 20 >> 2) * 4) + 1900, + LE_HEAP_LOAD_I32((tmPtr + 16 >> 2) * 4), + LE_HEAP_LOAD_I32((tmPtr + 12 >> 2) * 4), + LE_HEAP_LOAD_I32((tmPtr + 8 >> 2) * 4), + LE_HEAP_LOAD_I32((tmPtr + 4 >> 2) * 4), + LE_HEAP_LOAD_I32((tmPtr >> 2) * 4), + 0 + ); + var date = new Date(time); + LE_HEAP_STORE_I32((tmPtr + 24 >> 2) * 4, date.getUTCDay()); + var start = Date.UTC(date.getUTCFullYear(), 0, 1, 0, 0, 0, 0); + var yday = (date.getTime() - start) / (1e3 * 60 * 60 * 24) | 0; + LE_HEAP_STORE_I32((tmPtr + 28 >> 2) * 4, yday); + return date.getTime() / 1e3 | 0; + } + function intArrayFromBase64(s) { + { + var buf; + try { + buf = Buffer.from(s, "base64"); + } catch (_) { + buf = new Buffer(s, "base64"); + } + return new Uint8Array( + buf["buffer"], + buf["byteOffset"], + buf["byteLength"] + ); + } + } + function tryParseAsDataURI(filename) { + if (!isDataURI(filename)) { + return; + } + return intArrayFromBase64(filename.slice(dataURIPrefix.length)); + } + var asmLibraryArg = { + e: ___gmtime_r, + c: _emscripten_memcpy_big, + d: _emscripten_resize_heap, + a: _setTempRet0, + b: _time, + f: _timegm + }; + var asm = createWasm(); + Module["___wasm_call_ctors"] = asm["h"]; + Module["_zip_ext_count_symlinks"] = asm["i"]; + Module["_zip_file_get_external_attributes"] = asm["j"]; + Module["_zipstruct_statS"] = asm["k"]; + Module["_zipstruct_stat_size"] = asm["l"]; + Module["_zipstruct_stat_mtime"] = asm["m"]; + Module["_zipstruct_stat_crc"] = asm["n"]; + Module["_zipstruct_errorS"] = asm["o"]; + Module["_zipstruct_error_code_zip"] = asm["p"]; + Module["_zipstruct_stat_comp_size"] = asm["q"]; + Module["_zipstruct_stat_comp_method"] = asm["r"]; + Module["_zip_close"] = asm["s"]; + Module["_zip_delete"] = asm["t"]; + Module["_zip_dir_add"] = asm["u"]; + Module["_zip_discard"] = asm["v"]; + Module["_zip_error_init_with_code"] = asm["w"]; + Module["_zip_get_error"] = asm["x"]; + Module["_zip_file_get_error"] = asm["y"]; + Module["_zip_error_strerror"] = asm["z"]; + Module["_zip_fclose"] = asm["A"]; + Module["_zip_file_add"] = asm["B"]; + Module["_free"] = asm["C"]; + var _malloc = Module["_malloc"] = asm["D"]; + Module["_zip_source_error"] = asm["E"]; + Module["_zip_source_seek"] = asm["F"]; + Module["_zip_file_set_external_attributes"] = asm["G"]; + Module["_zip_file_set_mtime"] = asm["H"]; + Module["_zip_fopen_index"] = asm["I"]; + Module["_zip_fread"] = asm["J"]; + Module["_zip_get_name"] = asm["K"]; + Module["_zip_get_num_entries"] = asm["L"]; + Module["_zip_source_read"] = asm["M"]; + Module["_zip_name_locate"] = asm["N"]; + Module["_zip_open_from_source"] = asm["O"]; + Module["_zip_set_file_compression"] = asm["P"]; + Module["_zip_source_buffer"] = asm["Q"]; + Module["_zip_source_buffer_create"] = asm["R"]; + Module["_zip_source_close"] = asm["S"]; + Module["_zip_source_free"] = asm["T"]; + Module["_zip_source_keep"] = asm["U"]; + Module["_zip_source_open"] = asm["V"]; + Module["_zip_source_tell"] = asm["X"]; + Module["_zip_stat_index"] = asm["Y"]; + var __get_tzname = Module["__get_tzname"] = asm["Z"]; + var __get_daylight = Module["__get_daylight"] = asm["_"]; + var __get_timezone = Module["__get_timezone"] = asm["$"]; + var stackSave = Module["stackSave"] = asm["aa"]; + var stackRestore = Module["stackRestore"] = asm["ba"]; + var stackAlloc = Module["stackAlloc"] = asm["ca"]; + Module["cwrap"] = cwrap; + Module["getValue"] = getValue; + var calledRun; + dependenciesFulfilled = function runCaller() { + if (!calledRun) run(); + if (!calledRun) dependenciesFulfilled = runCaller; + }; + function run(args) { + if (runDependencies > 0) { + return; + } + preRun(); + if (runDependencies > 0) { + return; + } + function doRun() { + if (calledRun) return; + calledRun = true; + Module["calledRun"] = true; + if (ABORT) return; + initRuntime(); + readyPromiseResolve(Module); + if (Module["onRuntimeInitialized"]) Module["onRuntimeInitialized"](); + postRun(); + } + if (Module["setStatus"]) { + Module["setStatus"]("Running..."); + setTimeout(function() { + setTimeout(function() { + Module["setStatus"](""); + }, 1); + doRun(); + }, 1); + } else { + doRun(); + } + } + Module["run"] = run; + if (Module["preInit"]) { + if (typeof Module["preInit"] == "function") + Module["preInit"] = [Module["preInit"]]; + while (Module["preInit"].length > 0) { + Module["preInit"].pop()(); + } + } + run(); + return createModule2; + }; +}(); +module.exports = createModule; +}(libzipSync)); + +const createModule = libzipSync.exports; + +const number64 = [ + `number`, + // low + `number` + // high +]; +var Errors = /* @__PURE__ */ ((Errors2) => { + Errors2[Errors2["ZIP_ER_OK"] = 0] = "ZIP_ER_OK"; + Errors2[Errors2["ZIP_ER_MULTIDISK"] = 1] = "ZIP_ER_MULTIDISK"; + Errors2[Errors2["ZIP_ER_RENAME"] = 2] = "ZIP_ER_RENAME"; + Errors2[Errors2["ZIP_ER_CLOSE"] = 3] = "ZIP_ER_CLOSE"; + Errors2[Errors2["ZIP_ER_SEEK"] = 4] = "ZIP_ER_SEEK"; + Errors2[Errors2["ZIP_ER_READ"] = 5] = "ZIP_ER_READ"; + Errors2[Errors2["ZIP_ER_WRITE"] = 6] = "ZIP_ER_WRITE"; + Errors2[Errors2["ZIP_ER_CRC"] = 7] = "ZIP_ER_CRC"; + Errors2[Errors2["ZIP_ER_ZIPCLOSED"] = 8] = "ZIP_ER_ZIPCLOSED"; + Errors2[Errors2["ZIP_ER_NOENT"] = 9] = "ZIP_ER_NOENT"; + Errors2[Errors2["ZIP_ER_EXISTS"] = 10] = "ZIP_ER_EXISTS"; + Errors2[Errors2["ZIP_ER_OPEN"] = 11] = "ZIP_ER_OPEN"; + Errors2[Errors2["ZIP_ER_TMPOPEN"] = 12] = "ZIP_ER_TMPOPEN"; + Errors2[Errors2["ZIP_ER_ZLIB"] = 13] = "ZIP_ER_ZLIB"; + Errors2[Errors2["ZIP_ER_MEMORY"] = 14] = "ZIP_ER_MEMORY"; + Errors2[Errors2["ZIP_ER_CHANGED"] = 15] = "ZIP_ER_CHANGED"; + Errors2[Errors2["ZIP_ER_COMPNOTSUPP"] = 16] = "ZIP_ER_COMPNOTSUPP"; + Errors2[Errors2["ZIP_ER_EOF"] = 17] = "ZIP_ER_EOF"; + Errors2[Errors2["ZIP_ER_INVAL"] = 18] = "ZIP_ER_INVAL"; + Errors2[Errors2["ZIP_ER_NOZIP"] = 19] = "ZIP_ER_NOZIP"; + Errors2[Errors2["ZIP_ER_INTERNAL"] = 20] = "ZIP_ER_INTERNAL"; + Errors2[Errors2["ZIP_ER_INCONS"] = 21] = "ZIP_ER_INCONS"; + Errors2[Errors2["ZIP_ER_REMOVE"] = 22] = "ZIP_ER_REMOVE"; + Errors2[Errors2["ZIP_ER_DELETED"] = 23] = "ZIP_ER_DELETED"; + Errors2[Errors2["ZIP_ER_ENCRNOTSUPP"] = 24] = "ZIP_ER_ENCRNOTSUPP"; + Errors2[Errors2["ZIP_ER_RDONLY"] = 25] = "ZIP_ER_RDONLY"; + Errors2[Errors2["ZIP_ER_NOPASSWD"] = 26] = "ZIP_ER_NOPASSWD"; + Errors2[Errors2["ZIP_ER_WRONGPASSWD"] = 27] = "ZIP_ER_WRONGPASSWD"; + Errors2[Errors2["ZIP_ER_OPNOTSUPP"] = 28] = "ZIP_ER_OPNOTSUPP"; + Errors2[Errors2["ZIP_ER_INUSE"] = 29] = "ZIP_ER_INUSE"; + Errors2[Errors2["ZIP_ER_TELL"] = 30] = "ZIP_ER_TELL"; + Errors2[Errors2["ZIP_ER_COMPRESSED_DATA"] = 31] = "ZIP_ER_COMPRESSED_DATA"; + return Errors2; +})(Errors || {}); +const makeInterface = (emZip) => ({ + // Those are getters because they can change after memory growth + get HEAPU8() { + return emZip.HEAPU8; + }, + errors: Errors, + SEEK_SET: 0, + SEEK_CUR: 1, + SEEK_END: 2, + ZIP_CHECKCONS: 4, + ZIP_EXCL: 2, + ZIP_RDONLY: 16, + ZIP_FL_OVERWRITE: 8192, + ZIP_FL_COMPRESSED: 4, + ZIP_OPSYS_DOS: 0, + ZIP_OPSYS_AMIGA: 1, + ZIP_OPSYS_OPENVMS: 2, + ZIP_OPSYS_UNIX: 3, + ZIP_OPSYS_VM_CMS: 4, + ZIP_OPSYS_ATARI_ST: 5, + ZIP_OPSYS_OS_2: 6, + ZIP_OPSYS_MACINTOSH: 7, + ZIP_OPSYS_Z_SYSTEM: 8, + ZIP_OPSYS_CPM: 9, + ZIP_OPSYS_WINDOWS_NTFS: 10, + ZIP_OPSYS_MVS: 11, + ZIP_OPSYS_VSE: 12, + ZIP_OPSYS_ACORN_RISC: 13, + ZIP_OPSYS_VFAT: 14, + ZIP_OPSYS_ALTERNATE_MVS: 15, + ZIP_OPSYS_BEOS: 16, + ZIP_OPSYS_TANDEM: 17, + ZIP_OPSYS_OS_400: 18, + ZIP_OPSYS_OS_X: 19, + ZIP_CM_DEFAULT: -1, + ZIP_CM_STORE: 0, + ZIP_CM_DEFLATE: 8, + uint08S: emZip._malloc(1), + uint32S: emZip._malloc(4), + malloc: emZip._malloc, + free: emZip._free, + getValue: emZip.getValue, + openFromSource: emZip.cwrap(`zip_open_from_source`, `number`, [`number`, `number`, `number`]), + close: emZip.cwrap(`zip_close`, `number`, [`number`]), + discard: emZip.cwrap(`zip_discard`, null, [`number`]), + getError: emZip.cwrap(`zip_get_error`, `number`, [`number`]), + getName: emZip.cwrap(`zip_get_name`, `string`, [`number`, `number`, `number`]), + getNumEntries: emZip.cwrap(`zip_get_num_entries`, `number`, [`number`, `number`]), + delete: emZip.cwrap(`zip_delete`, `number`, [`number`, `number`]), + statIndex: emZip.cwrap(`zip_stat_index`, `number`, [`number`, ...number64, `number`, `number`]), + fopenIndex: emZip.cwrap(`zip_fopen_index`, `number`, [`number`, ...number64, `number`]), + fread: emZip.cwrap(`zip_fread`, `number`, [`number`, `number`, `number`, `number`]), + fclose: emZip.cwrap(`zip_fclose`, `number`, [`number`]), + dir: { + add: emZip.cwrap(`zip_dir_add`, `number`, [`number`, `string`]) + }, + file: { + add: emZip.cwrap(`zip_file_add`, `number`, [`number`, `string`, `number`, `number`]), + getError: emZip.cwrap(`zip_file_get_error`, `number`, [`number`]), + getExternalAttributes: emZip.cwrap(`zip_file_get_external_attributes`, `number`, [`number`, ...number64, `number`, `number`, `number`]), + setExternalAttributes: emZip.cwrap(`zip_file_set_external_attributes`, `number`, [`number`, ...number64, `number`, `number`, `number`]), + setMtime: emZip.cwrap(`zip_file_set_mtime`, `number`, [`number`, ...number64, `number`, `number`]), + setCompression: emZip.cwrap(`zip_set_file_compression`, `number`, [`number`, ...number64, `number`, `number`]) + }, + ext: { + countSymlinks: emZip.cwrap(`zip_ext_count_symlinks`, `number`, [`number`]) + }, + error: { + initWithCode: emZip.cwrap(`zip_error_init_with_code`, null, [`number`, `number`]), + strerror: emZip.cwrap(`zip_error_strerror`, `string`, [`number`]) + }, + name: { + locate: emZip.cwrap(`zip_name_locate`, `number`, [`number`, `string`, `number`]) + }, + source: { + fromUnattachedBuffer: emZip.cwrap(`zip_source_buffer_create`, `number`, [`number`, ...number64, `number`, `number`]), + fromBuffer: emZip.cwrap(`zip_source_buffer`, `number`, [`number`, `number`, ...number64, `number`]), + free: emZip.cwrap(`zip_source_free`, null, [`number`]), + keep: emZip.cwrap(`zip_source_keep`, null, [`number`]), + open: emZip.cwrap(`zip_source_open`, `number`, [`number`]), + close: emZip.cwrap(`zip_source_close`, `number`, [`number`]), + seek: emZip.cwrap(`zip_source_seek`, `number`, [`number`, ...number64, `number`]), + tell: emZip.cwrap(`zip_source_tell`, `number`, [`number`]), + read: emZip.cwrap(`zip_source_read`, `number`, [`number`, `number`, `number`]), + error: emZip.cwrap(`zip_source_error`, `number`, [`number`]) + }, + struct: { + statS: emZip.cwrap(`zipstruct_statS`, `number`, []), + statSize: emZip.cwrap(`zipstruct_stat_size`, `number`, [`number`]), + statCompSize: emZip.cwrap(`zipstruct_stat_comp_size`, `number`, [`number`]), + statCompMethod: emZip.cwrap(`zipstruct_stat_comp_method`, `number`, [`number`]), + statMtime: emZip.cwrap(`zipstruct_stat_mtime`, `number`, [`number`]), + statCrc: emZip.cwrap(`zipstruct_stat_crc`, `number`, [`number`]), + errorS: emZip.cwrap(`zipstruct_errorS`, `number`, []), + errorCodeZip: emZip.cwrap(`zipstruct_error_code_zip`, `number`, [`number`]) + } +}); + +function getArchivePart(path, extension) { + let idx = path.indexOf(extension); + if (idx <= 0) + return null; + let nextCharIdx = idx; + while (idx >= 0) { + nextCharIdx = idx + extension.length; + if (path[nextCharIdx] === ppath.sep) + break; + if (path[idx - 1] === ppath.sep) + return null; + idx = path.indexOf(extension, nextCharIdx); + } + if (path.length > nextCharIdx && path[nextCharIdx] !== ppath.sep) + return null; + return path.slice(0, nextCharIdx); +} +class ZipOpenFS extends MountFS { + static async openPromise(fn, opts) { + const zipOpenFs = new ZipOpenFS(opts); + try { + return await fn(zipOpenFs); + } finally { + zipOpenFs.saveAndClose(); + } + } + constructor(opts = {}) { + const fileExtensions = opts.fileExtensions; + const readOnlyArchives = opts.readOnlyArchives; + const getMountPoint = typeof fileExtensions === `undefined` ? (path) => getArchivePart(path, `.zip`) : (path) => { + for (const extension of fileExtensions) { + const result = getArchivePart(path, extension); + if (result) { + return result; + } + } + return null; + }; + const factorySync = (baseFs, p) => { + return new ZipFS(p, { + baseFs, + readOnly: readOnlyArchives, + stats: baseFs.statSync(p), + customZipImplementation: opts.customZipImplementation + }); + }; + const factoryPromise = async (baseFs, p) => { + const zipOptions = { + baseFs, + readOnly: readOnlyArchives, + stats: await baseFs.statPromise(p), + customZipImplementation: opts.customZipImplementation + }; + return () => { + return new ZipFS(p, zipOptions); + }; + }; + super({ + ...opts, + factorySync, + factoryPromise, + getMountPoint + }); + } +} + +class LibzipError extends Error { + code; + constructor(message, code) { + super(message); + this.name = `Libzip Error`; + this.code = code; + } +} +class LibZipImpl { + libzip; + lzSource; + zip; + listings; + symlinkCount; + filesShouldBeCached = true; + constructor(opts) { + const buffer = `buffer` in opts ? opts.buffer : opts.baseFs.readFileSync(opts.path); + this.libzip = getInstance(); + const errPtr = this.libzip.malloc(4); + try { + let flags = 0; + if (opts.readOnly) + flags |= this.libzip.ZIP_RDONLY; + const lzSource = this.allocateUnattachedSource(buffer); + try { + this.zip = this.libzip.openFromSource(lzSource, flags, errPtr); + this.lzSource = lzSource; + } catch (error) { + this.libzip.source.free(lzSource); + throw error; + } + if (this.zip === 0) { + const error = this.libzip.struct.errorS(); + this.libzip.error.initWithCode(error, this.libzip.getValue(errPtr, `i32`)); + throw this.makeLibzipError(error); + } + } finally { + this.libzip.free(errPtr); + } + const entryCount = this.libzip.getNumEntries(this.zip, 0); + const listings = new Array(entryCount); + for (let t = 0; t < entryCount; ++t) + listings[t] = this.libzip.getName(this.zip, t, 0); + this.listings = listings; + this.symlinkCount = this.libzip.ext.countSymlinks(this.zip); + if (this.symlinkCount === -1) { + throw this.makeLibzipError(this.libzip.getError(this.zip)); + } + } + getSymlinkCount() { + return this.symlinkCount; + } + getListings() { + return this.listings; + } + stat(entry) { + const stat = this.libzip.struct.statS(); + const rc = this.libzip.statIndex(this.zip, entry, 0, 0, stat); + if (rc === -1) + throw this.makeLibzipError(this.libzip.getError(this.zip)); + const size = this.libzip.struct.statSize(stat) >>> 0; + const mtime = this.libzip.struct.statMtime(stat) >>> 0; + const crc = this.libzip.struct.statCrc(stat) >>> 0; + return { size, mtime, crc }; + } + makeLibzipError(error) { + const errorCode = this.libzip.struct.errorCodeZip(error); + const strerror = this.libzip.error.strerror(error); + const libzipError = new LibzipError(strerror, this.libzip.errors[errorCode]); + if (errorCode === this.libzip.errors.ZIP_ER_CHANGED) + throw new Error(`Assertion failed: Unexpected libzip error: ${libzipError.message}`); + return libzipError; + } + setFileSource(target, compression, buffer) { + const lzSource = this.allocateSource(buffer); + try { + const newIndex = this.libzip.file.add(this.zip, target, lzSource, this.libzip.ZIP_FL_OVERWRITE); + if (newIndex === -1) + throw this.makeLibzipError(this.libzip.getError(this.zip)); + if (compression !== null) { + const rc = this.libzip.file.setCompression(this.zip, newIndex, 0, compression[0], compression[1]); + if (rc === -1) { + throw this.makeLibzipError(this.libzip.getError(this.zip)); + } + } + return newIndex; + } catch (error) { + this.libzip.source.free(lzSource); + throw error; + } + } + setMtime(entry, mtime) { + const rc = this.libzip.file.setMtime(this.zip, entry, 0, mtime, 0); + if (rc === -1) { + throw this.makeLibzipError(this.libzip.getError(this.zip)); + } + } + getExternalAttributes(index) { + const attrs = this.libzip.file.getExternalAttributes(this.zip, index, 0, 0, this.libzip.uint08S, this.libzip.uint32S); + if (attrs === -1) + throw this.makeLibzipError(this.libzip.getError(this.zip)); + const opsys = this.libzip.getValue(this.libzip.uint08S, `i8`) >>> 0; + const attributes = this.libzip.getValue(this.libzip.uint32S, `i32`) >>> 0; + return [opsys, attributes]; + } + setExternalAttributes(index, opsys, attributes) { + const rc = this.libzip.file.setExternalAttributes(this.zip, index, 0, 0, opsys, attributes); + if (rc === -1) { + throw this.makeLibzipError(this.libzip.getError(this.zip)); + } + } + locate(name) { + return this.libzip.name.locate(this.zip, name, 0); + } + getFileSource(index) { + const stat = this.libzip.struct.statS(); + const rc = this.libzip.statIndex(this.zip, index, 0, 0, stat); + if (rc === -1) + throw this.makeLibzipError(this.libzip.getError(this.zip)); + const size = this.libzip.struct.statCompSize(stat); + const compressionMethod = this.libzip.struct.statCompMethod(stat); + const buffer = this.libzip.malloc(size); + try { + const file = this.libzip.fopenIndex(this.zip, index, 0, this.libzip.ZIP_FL_COMPRESSED); + if (file === 0) + throw this.makeLibzipError(this.libzip.getError(this.zip)); + try { + const rc2 = this.libzip.fread(file, buffer, size, 0); + if (rc2 === -1) + throw this.makeLibzipError(this.libzip.file.getError(file)); + else if (rc2 < size) + throw new Error(`Incomplete read`); + else if (rc2 > size) + throw new Error(`Overread`); + const memory = this.libzip.HEAPU8.subarray(buffer, buffer + size); + const data = Buffer.from(memory); + return { data, compressionMethod }; + } finally { + this.libzip.fclose(file); + } + } finally { + this.libzip.free(buffer); + } + } + deleteEntry(index) { + const rc = this.libzip.delete(this.zip, index); + if (rc === -1) { + throw this.makeLibzipError(this.libzip.getError(this.zip)); + } + } + addDirectory(path) { + const index = this.libzip.dir.add(this.zip, path); + if (index === -1) + throw this.makeLibzipError(this.libzip.getError(this.zip)); + return index; + } + getBufferAndClose() { + try { + this.libzip.source.keep(this.lzSource); + if (this.libzip.close(this.zip) === -1) + throw this.makeLibzipError(this.libzip.getError(this.zip)); + if (this.libzip.source.open(this.lzSource) === -1) + throw this.makeLibzipError(this.libzip.source.error(this.lzSource)); + if (this.libzip.source.seek(this.lzSource, 0, 0, this.libzip.SEEK_END) === -1) + throw this.makeLibzipError(this.libzip.source.error(this.lzSource)); + const size = this.libzip.source.tell(this.lzSource); + if (size === -1) + throw this.makeLibzipError(this.libzip.source.error(this.lzSource)); + if (this.libzip.source.seek(this.lzSource, 0, 0, this.libzip.SEEK_SET) === -1) + throw this.makeLibzipError(this.libzip.source.error(this.lzSource)); + const buffer = this.libzip.malloc(size); + if (!buffer) + throw new Error(`Couldn't allocate enough memory`); + try { + const rc = this.libzip.source.read(this.lzSource, buffer, size); + if (rc === -1) + throw this.makeLibzipError(this.libzip.source.error(this.lzSource)); + else if (rc < size) + throw new Error(`Incomplete read`); + else if (rc > size) + throw new Error(`Overread`); + let result = Buffer.from(this.libzip.HEAPU8.subarray(buffer, buffer + size)); + if (process.env.YARN_IS_TEST_ENV && process.env.YARN_ZIP_DATA_EPILOGUE) + result = Buffer.concat([result, Buffer.from(process.env.YARN_ZIP_DATA_EPILOGUE)]); + return result; + } finally { + this.libzip.free(buffer); + } + } finally { + this.libzip.source.close(this.lzSource); + this.libzip.source.free(this.lzSource); + } + } + allocateBuffer(content) { + if (!Buffer.isBuffer(content)) + content = Buffer.from(content); + const buffer = this.libzip.malloc(content.byteLength); + if (!buffer) + throw new Error(`Couldn't allocate enough memory`); + const heap = new Uint8Array(this.libzip.HEAPU8.buffer, buffer, content.byteLength); + heap.set(content); + return { buffer, byteLength: content.byteLength }; + } + allocateUnattachedSource(content) { + const error = this.libzip.struct.errorS(); + const { buffer, byteLength } = this.allocateBuffer(content); + const source = this.libzip.source.fromUnattachedBuffer(buffer, byteLength, 0, 1, error); + if (source === 0) { + this.libzip.free(error); + throw this.makeLibzipError(error); + } + return source; + } + allocateSource(content) { + const { buffer, byteLength } = this.allocateBuffer(content); + const source = this.libzip.source.fromBuffer(this.zip, buffer, byteLength, 0, 1); + if (source === 0) { + this.libzip.free(buffer); + throw this.makeLibzipError(this.libzip.getError(this.zip)); + } + return source; + } + discard() { + this.libzip.discard(this.zip); + } +} + +const ZIP_UNIX = 3; +const STORE = 0; +const DEFLATE = 8; +const DEFAULT_COMPRESSION_LEVEL = `mixed`; +function toUnixTimestamp(time) { + if (typeof time === `string` && String(+time) === time) + return +time; + if (typeof time === `number` && Number.isFinite(time)) { + if (time < 0) { + return Date.now() / 1e3; + } else { + return time; + } + } + if (nodeUtils.types.isDate(time)) + return time.getTime() / 1e3; + throw new Error(`Invalid time`); +} +function makeEmptyArchive() { + return Buffer.from([ + 80, + 75, + 5, + 6, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ]); +} +class ZipFS extends BasePortableFakeFS { + baseFs; + path; + stats; + level; + zipImpl; + listings = /* @__PURE__ */ new Map(); + entries = /* @__PURE__ */ new Map(); + /** + * A cache of indices mapped to file sources. + * Populated by `setFileSource` calls. + * Required for supporting read after write. + */ + fileSources = /* @__PURE__ */ new Map(); + symlinkCount; + fds = /* @__PURE__ */ new Map(); + nextFd = 0; + ready = false; + readOnly = false; + constructor(source, opts = {}) { + super(); + if (opts.readOnly) + this.readOnly = true; + const pathOptions = opts; + this.level = typeof pathOptions.level !== `undefined` ? pathOptions.level : DEFAULT_COMPRESSION_LEVEL; + const ZipImplCls = opts.customZipImplementation ?? LibZipImpl; + if (typeof source === `string`) { + const { baseFs = new NodeFS() } = pathOptions; + this.baseFs = baseFs; + this.path = source; + } else { + this.path = null; + this.baseFs = null; + } + if (opts.stats) { + this.stats = opts.stats; + } else { + if (typeof source === `string`) { + try { + this.stats = this.baseFs.statSync(source); + } catch (error) { + if (error.code === `ENOENT` && pathOptions.create) { + this.stats = makeDefaultStats(); + } else { + throw error; + } + } + } else { + this.stats = makeDefaultStats(); + } + } + if (typeof source === `string`) { + if (opts.create) { + this.zipImpl = new ZipImplCls({ buffer: makeEmptyArchive(), readOnly: this.readOnly }); + } else { + this.zipImpl = new ZipImplCls({ path: source, baseFs: this.baseFs, readOnly: this.readOnly, size: this.stats.size }); + } + } else { + this.zipImpl = new ZipImplCls({ buffer: source ?? makeEmptyArchive(), readOnly: this.readOnly }); + } + this.listings.set(PortablePath.root, /* @__PURE__ */ new Set()); + const listings = this.zipImpl.getListings(); + for (let t = 0; t < listings.length; t++) { + const raw = listings[t]; + if (ppath.isAbsolute(raw)) + continue; + const p = ppath.resolve(PortablePath.root, raw); + this.registerEntry(p, t); + if (raw.endsWith(`/`)) { + this.registerListing(p); + } + } + this.symlinkCount = this.zipImpl.getSymlinkCount(); + this.ready = true; + } + getExtractHint(hints) { + for (const fileName of this.entries.keys()) { + const ext = this.pathUtils.extname(fileName); + if (hints.relevantExtensions.has(ext)) { + return true; + } + } + return false; + } + getAllFiles() { + return Array.from(this.entries.keys()); + } + getRealPath() { + if (!this.path) + throw new Error(`ZipFS don't have real paths when loaded from a buffer`); + return this.path; + } + prepareClose() { + if (!this.ready) + throw EBUSY(`archive closed, close`); + unwatchAllFiles(this); + } + getBufferAndClose() { + this.prepareClose(); + if (this.entries.size === 0) { + this.discardAndClose(); + return makeEmptyArchive(); + } + try { + return this.zipImpl.getBufferAndClose(); + } finally { + this.ready = false; + } + } + discardAndClose() { + this.prepareClose(); + this.zipImpl.discard(); + this.ready = false; + } + saveAndClose() { + if (!this.path || !this.baseFs) + throw new Error(`ZipFS cannot be saved and must be discarded when loaded from a buffer`); + if (this.readOnly) { + this.discardAndClose(); + return; + } + const newMode = this.baseFs.existsSync(this.path) || this.stats.mode === DEFAULT_MODE ? void 0 : this.stats.mode; + this.baseFs.writeFileSync(this.path, this.getBufferAndClose(), { mode: newMode }); + this.ready = false; + } + resolve(p) { + return ppath.resolve(PortablePath.root, p); + } + async openPromise(p, flags, mode) { + return this.openSync(p, flags, mode); + } + openSync(p, flags, mode) { + const fd = this.nextFd++; + this.fds.set(fd, { cursor: 0, p }); + return fd; + } + hasOpenFileHandles() { + return !!this.fds.size; + } + async opendirPromise(p, opts) { + return this.opendirSync(p, opts); + } + opendirSync(p, opts = {}) { + const resolvedP = this.resolveFilename(`opendir '${p}'`, p); + if (!this.entries.has(resolvedP) && !this.listings.has(resolvedP)) + throw ENOENT(`opendir '${p}'`); + const directoryListing = this.listings.get(resolvedP); + if (!directoryListing) + throw ENOTDIR(`opendir '${p}'`); + const entries = [...directoryListing]; + const fd = this.openSync(resolvedP, `r`); + const onClose = () => { + this.closeSync(fd); + }; + return opendir(this, resolvedP, entries, { onClose }); + } + async readPromise(fd, buffer, offset, length, position) { + return this.readSync(fd, buffer, offset, length, position); + } + readSync(fd, buffer, offset = 0, length = buffer.byteLength, position = -1) { + const entry = this.fds.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`read`); + const realPosition = position === -1 || position === null ? entry.cursor : position; + const source = this.readFileSync(entry.p); + source.copy(buffer, offset, realPosition, realPosition + length); + const bytesRead = Math.max(0, Math.min(source.length - realPosition, length)); + if (position === -1 || position === null) + entry.cursor += bytesRead; + return bytesRead; + } + async writePromise(fd, buffer, offset, length, position) { + if (typeof buffer === `string`) { + return this.writeSync(fd, buffer, position); + } else { + return this.writeSync(fd, buffer, offset, length, position); + } + } + writeSync(fd, buffer, offset, length, position) { + const entry = this.fds.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`read`); + throw new Error(`Unimplemented`); + } + async closePromise(fd) { + return this.closeSync(fd); + } + closeSync(fd) { + const entry = this.fds.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`read`); + this.fds.delete(fd); + } + createReadStream(p, { encoding } = {}) { + if (p === null) + throw new Error(`Unimplemented`); + const fd = this.openSync(p, `r`); + const stream$1 = Object.assign( + new stream.PassThrough({ + emitClose: true, + autoDestroy: true, + destroy: (error, callback) => { + clearImmediate(immediate); + this.closeSync(fd); + callback(error); + } + }), + { + close() { + stream$1.destroy(); + }, + bytesRead: 0, + path: p, + // "This property is `true` if the underlying file has not been opened yet" + pending: false + } + ); + const immediate = setImmediate(async () => { + try { + const data = await this.readFilePromise(p, encoding); + stream$1.bytesRead = data.length; + stream$1.end(data); + } catch (error) { + stream$1.destroy(error); + } + }); + return stream$1; + } + createWriteStream(p, { encoding } = {}) { + if (this.readOnly) + throw EROFS(`open '${p}'`); + if (p === null) + throw new Error(`Unimplemented`); + const chunks = []; + const fd = this.openSync(p, `w`); + const stream$1 = Object.assign( + new stream.PassThrough({ + autoDestroy: true, + emitClose: true, + destroy: (error, callback) => { + try { + if (error) { + callback(error); + } else { + this.writeFileSync(p, Buffer.concat(chunks), encoding); + callback(null); + } + } catch (err) { + callback(err); + } finally { + this.closeSync(fd); + } + } + }), + { + close() { + stream$1.destroy(); + }, + bytesWritten: 0, + path: p, + // "This property is `true` if the underlying file has not been opened yet" + pending: false + } + ); + stream$1.on(`data`, (chunk) => { + const chunkBuffer = Buffer.from(chunk); + stream$1.bytesWritten += chunkBuffer.length; + chunks.push(chunkBuffer); + }); + return stream$1; + } + async realpathPromise(p) { + return this.realpathSync(p); + } + realpathSync(p) { + const resolvedP = this.resolveFilename(`lstat '${p}'`, p); + if (!this.entries.has(resolvedP) && !this.listings.has(resolvedP)) + throw ENOENT(`lstat '${p}'`); + return resolvedP; + } + async existsPromise(p) { + return this.existsSync(p); + } + existsSync(p) { + if (!this.ready) + throw EBUSY(`archive closed, existsSync '${p}'`); + if (this.symlinkCount === 0) { + const resolvedP2 = ppath.resolve(PortablePath.root, p); + return this.entries.has(resolvedP2) || this.listings.has(resolvedP2); + } + let resolvedP; + try { + resolvedP = this.resolveFilename(`stat '${p}'`, p, void 0, false); + } catch { + return false; + } + if (resolvedP === void 0) + return false; + return this.entries.has(resolvedP) || this.listings.has(resolvedP); + } + async accessPromise(p, mode) { + return this.accessSync(p, mode); + } + accessSync(p, mode = fs.constants.F_OK) { + const resolvedP = this.resolveFilename(`access '${p}'`, p); + if (!this.entries.has(resolvedP) && !this.listings.has(resolvedP)) + throw ENOENT(`access '${p}'`); + if (this.readOnly && mode & fs.constants.W_OK) { + throw EROFS(`access '${p}'`); + } + } + async statPromise(p, opts = { bigint: false }) { + if (opts.bigint) + return this.statSync(p, { bigint: true }); + return this.statSync(p); + } + statSync(p, opts = { bigint: false, throwIfNoEntry: true }) { + const resolvedP = this.resolveFilename(`stat '${p}'`, p, void 0, opts.throwIfNoEntry); + if (resolvedP === void 0) + return void 0; + if (!this.entries.has(resolvedP) && !this.listings.has(resolvedP)) { + if (opts.throwIfNoEntry === false) + return void 0; + throw ENOENT(`stat '${p}'`); + } + if (p[p.length - 1] === `/` && !this.listings.has(resolvedP)) + throw ENOTDIR(`stat '${p}'`); + return this.statImpl(`stat '${p}'`, resolvedP, opts); + } + async fstatPromise(fd, opts) { + return this.fstatSync(fd, opts); + } + fstatSync(fd, opts) { + const entry = this.fds.get(fd); + if (typeof entry === `undefined`) + throw EBADF(`fstatSync`); + const { p } = entry; + const resolvedP = this.resolveFilename(`stat '${p}'`, p); + if (!this.entries.has(resolvedP) && !this.listings.has(resolvedP)) + throw ENOENT(`stat '${p}'`); + if (p[p.length - 1] === `/` && !this.listings.has(resolvedP)) + throw ENOTDIR(`stat '${p}'`); + return this.statImpl(`fstat '${p}'`, resolvedP, opts); + } + async lstatPromise(p, opts = { bigint: false }) { + if (opts.bigint) + return this.lstatSync(p, { bigint: true }); + return this.lstatSync(p); + } + lstatSync(p, opts = { bigint: false, throwIfNoEntry: true }) { + const resolvedP = this.resolveFilename(`lstat '${p}'`, p, false, opts.throwIfNoEntry); + if (resolvedP === void 0) + return void 0; + if (!this.entries.has(resolvedP) && !this.listings.has(resolvedP)) { + if (opts.throwIfNoEntry === false) + return void 0; + throw ENOENT(`lstat '${p}'`); + } + if (p[p.length - 1] === `/` && !this.listings.has(resolvedP)) + throw ENOTDIR(`lstat '${p}'`); + return this.statImpl(`lstat '${p}'`, resolvedP, opts); + } + statImpl(reason, p, opts = {}) { + const entry = this.entries.get(p); + if (typeof entry !== `undefined`) { + const stat = this.zipImpl.stat(entry); + const crc = stat.crc; + const size = stat.size; + const mtimeMs = stat.mtime * 1e3; + const uid = this.stats.uid; + const gid = this.stats.gid; + const blksize = 512; + const blocks = Math.ceil(stat.size / blksize); + const atimeMs = mtimeMs; + const birthtimeMs = mtimeMs; + const ctimeMs = mtimeMs; + const atime = new Date(atimeMs); + const birthtime = new Date(birthtimeMs); + const ctime = new Date(ctimeMs); + const mtime = new Date(mtimeMs); + const type = this.listings.has(p) ? fs.constants.S_IFDIR : this.isSymbolicLink(entry) ? fs.constants.S_IFLNK : fs.constants.S_IFREG; + const defaultMode = type === fs.constants.S_IFDIR ? 493 : 420; + const mode = type | this.getUnixMode(entry, defaultMode) & 511; + const statInstance = Object.assign(new StatEntry(), { uid, gid, size, blksize, blocks, atime, birthtime, ctime, mtime, atimeMs, birthtimeMs, ctimeMs, mtimeMs, mode, crc }); + return opts.bigint === true ? convertToBigIntStats(statInstance) : statInstance; + } + if (this.listings.has(p)) { + const uid = this.stats.uid; + const gid = this.stats.gid; + const size = 0; + const blksize = 512; + const blocks = 0; + const atimeMs = this.stats.mtimeMs; + const birthtimeMs = this.stats.mtimeMs; + const ctimeMs = this.stats.mtimeMs; + const mtimeMs = this.stats.mtimeMs; + const atime = new Date(atimeMs); + const birthtime = new Date(birthtimeMs); + const ctime = new Date(ctimeMs); + const mtime = new Date(mtimeMs); + const mode = fs.constants.S_IFDIR | 493; + const crc = 0; + const statInstance = Object.assign(new StatEntry(), { uid, gid, size, blksize, blocks, atime, birthtime, ctime, mtime, atimeMs, birthtimeMs, ctimeMs, mtimeMs, mode, crc }); + return opts.bigint === true ? convertToBigIntStats(statInstance) : statInstance; + } + throw new Error(`Unreachable`); + } + getUnixMode(index, defaultMode) { + const [opsys, attributes] = this.zipImpl.getExternalAttributes(index); + if (opsys !== ZIP_UNIX) + return defaultMode; + return attributes >>> 16; + } + registerListing(p) { + const existingListing = this.listings.get(p); + if (existingListing) + return existingListing; + const parentListing = this.registerListing(ppath.dirname(p)); + parentListing.add(ppath.basename(p)); + const newListing = /* @__PURE__ */ new Set(); + this.listings.set(p, newListing); + return newListing; + } + registerEntry(p, index) { + const parentListing = this.registerListing(ppath.dirname(p)); + parentListing.add(ppath.basename(p)); + this.entries.set(p, index); + } + unregisterListing(p) { + this.listings.delete(p); + const parentListing = this.listings.get(ppath.dirname(p)); + parentListing?.delete(ppath.basename(p)); + } + unregisterEntry(p) { + this.unregisterListing(p); + const entry = this.entries.get(p); + this.entries.delete(p); + if (typeof entry === `undefined`) + return; + this.fileSources.delete(entry); + if (this.isSymbolicLink(entry)) { + this.symlinkCount--; + } + } + deleteEntry(p, index) { + this.unregisterEntry(p); + this.zipImpl.deleteEntry(index); + } + resolveFilename(reason, p, resolveLastComponent = true, throwIfNoEntry = true) { + if (!this.ready) + throw EBUSY(`archive closed, ${reason}`); + let resolvedP = ppath.resolve(PortablePath.root, p); + if (resolvedP === `/`) + return PortablePath.root; + const fileIndex = this.entries.get(resolvedP); + if (resolveLastComponent && fileIndex !== void 0) { + if (this.symlinkCount !== 0 && this.isSymbolicLink(fileIndex)) { + const target = this.getFileSource(fileIndex).toString(); + return this.resolveFilename(reason, ppath.resolve(ppath.dirname(resolvedP), target), true, throwIfNoEntry); + } else { + return resolvedP; + } + } + while (true) { + const parentP = this.resolveFilename(reason, ppath.dirname(resolvedP), true, throwIfNoEntry); + if (parentP === void 0) + return parentP; + const isDir = this.listings.has(parentP); + const doesExist = this.entries.has(parentP); + if (!isDir && !doesExist) { + if (throwIfNoEntry === false) + return void 0; + throw ENOENT(reason); + } + if (!isDir) + throw ENOTDIR(reason); + resolvedP = ppath.resolve(parentP, ppath.basename(resolvedP)); + if (!resolveLastComponent || this.symlinkCount === 0) + break; + const index = this.zipImpl.locate(resolvedP.slice(1)); + if (index === -1) + break; + if (this.isSymbolicLink(index)) { + const target = this.getFileSource(index).toString(); + resolvedP = ppath.resolve(ppath.dirname(resolvedP), target); + } else { + break; + } + } + return resolvedP; + } + setFileSource(p, content) { + const buffer = Buffer.isBuffer(content) ? content : Buffer.from(content); + const target = ppath.relative(PortablePath.root, p); + let compression = null; + if (this.level !== `mixed`) { + const method = this.level === 0 ? STORE : DEFLATE; + compression = [method, this.level]; + } + const newIndex = this.zipImpl.setFileSource(target, compression, buffer); + this.fileSources.set(newIndex, buffer); + return newIndex; + } + isSymbolicLink(index) { + if (this.symlinkCount === 0) + return false; + const [opsys, attrs] = this.zipImpl.getExternalAttributes(index); + if (opsys !== ZIP_UNIX) + return false; + const attributes = attrs >>> 16; + return (attributes & fs.constants.S_IFMT) === fs.constants.S_IFLNK; + } + getFileSource(index, opts = { asyncDecompress: false }) { + const cachedFileSource = this.fileSources.get(index); + if (typeof cachedFileSource !== `undefined`) + return cachedFileSource; + const { data, compressionMethod } = this.zipImpl.getFileSource(index); + if (compressionMethod === STORE) { + if (this.zipImpl.filesShouldBeCached) + this.fileSources.set(index, data); + return data; + } else if (compressionMethod === DEFLATE) { + if (opts.asyncDecompress) { + return new Promise((resolve, reject) => { + zlib__default.default.inflateRaw(data, (error, result) => { + if (error) { + reject(error); + } else { + if (this.zipImpl.filesShouldBeCached) + this.fileSources.set(index, result); + resolve(result); + } + }); + }); + } else { + const decompressedData = zlib__default.default.inflateRawSync(data); + if (this.zipImpl.filesShouldBeCached) + this.fileSources.set(index, decompressedData); + return decompressedData; + } + } else { + throw new Error(`Unsupported compression method: ${compressionMethod}`); + } + } + async fchmodPromise(fd, mask) { + return this.chmodPromise(this.fdToPath(fd, `fchmod`), mask); + } + fchmodSync(fd, mask) { + return this.chmodSync(this.fdToPath(fd, `fchmodSync`), mask); + } + async chmodPromise(p, mask) { + return this.chmodSync(p, mask); + } + chmodSync(p, mask) { + if (this.readOnly) + throw EROFS(`chmod '${p}'`); + mask &= 493; + const resolvedP = this.resolveFilename(`chmod '${p}'`, p, false); + const entry = this.entries.get(resolvedP); + if (typeof entry === `undefined`) + throw new Error(`Assertion failed: The entry should have been registered (${resolvedP})`); + const oldMod = this.getUnixMode(entry, fs.constants.S_IFREG | 0); + const newMod = oldMod & ~511 | mask; + this.zipImpl.setExternalAttributes(entry, ZIP_UNIX, newMod << 16); + } + async fchownPromise(fd, uid, gid) { + return this.chownPromise(this.fdToPath(fd, `fchown`), uid, gid); + } + fchownSync(fd, uid, gid) { + return this.chownSync(this.fdToPath(fd, `fchownSync`), uid, gid); + } + async chownPromise(p, uid, gid) { + return this.chownSync(p, uid, gid); + } + chownSync(p, uid, gid) { + throw new Error(`Unimplemented`); + } + async renamePromise(oldP, newP) { + return this.renameSync(oldP, newP); + } + renameSync(oldP, newP) { + throw new Error(`Unimplemented`); + } + async copyFilePromise(sourceP, destP, flags) { + const { indexSource, indexDest, resolvedDestP } = this.prepareCopyFile(sourceP, destP, flags); + const source = await this.getFileSource(indexSource, { asyncDecompress: true }); + const newIndex = this.setFileSource(resolvedDestP, source); + if (newIndex !== indexDest) { + this.registerEntry(resolvedDestP, newIndex); + } + } + copyFileSync(sourceP, destP, flags = 0) { + const { indexSource, indexDest, resolvedDestP } = this.prepareCopyFile(sourceP, destP, flags); + const source = this.getFileSource(indexSource); + const newIndex = this.setFileSource(resolvedDestP, source); + if (newIndex !== indexDest) { + this.registerEntry(resolvedDestP, newIndex); + } + } + prepareCopyFile(sourceP, destP, flags = 0) { + if (this.readOnly) + throw EROFS(`copyfile '${sourceP} -> '${destP}'`); + if ((flags & fs.constants.COPYFILE_FICLONE_FORCE) !== 0) + throw ENOSYS(`unsupported clone operation`, `copyfile '${sourceP}' -> ${destP}'`); + const resolvedSourceP = this.resolveFilename(`copyfile '${sourceP} -> ${destP}'`, sourceP); + const indexSource = this.entries.get(resolvedSourceP); + if (typeof indexSource === `undefined`) + throw EINVAL(`copyfile '${sourceP}' -> '${destP}'`); + const resolvedDestP = this.resolveFilename(`copyfile '${sourceP}' -> ${destP}'`, destP); + const indexDest = this.entries.get(resolvedDestP); + if ((flags & (fs.constants.COPYFILE_EXCL | fs.constants.COPYFILE_FICLONE_FORCE)) !== 0 && typeof indexDest !== `undefined`) + throw EEXIST(`copyfile '${sourceP}' -> '${destP}'`); + return { + indexSource, + resolvedDestP, + indexDest + }; + } + async appendFilePromise(p, content, opts) { + if (this.readOnly) + throw EROFS(`open '${p}'`); + if (typeof opts === `undefined`) + opts = { flag: `a` }; + else if (typeof opts === `string`) + opts = { flag: `a`, encoding: opts }; + else if (typeof opts.flag === `undefined`) + opts = { flag: `a`, ...opts }; + return this.writeFilePromise(p, content, opts); + } + appendFileSync(p, content, opts = {}) { + if (this.readOnly) + throw EROFS(`open '${p}'`); + if (typeof opts === `undefined`) + opts = { flag: `a` }; + else if (typeof opts === `string`) + opts = { flag: `a`, encoding: opts }; + else if (typeof opts.flag === `undefined`) + opts = { flag: `a`, ...opts }; + return this.writeFileSync(p, content, opts); + } + fdToPath(fd, reason) { + const path = this.fds.get(fd)?.p; + if (typeof path === `undefined`) + throw EBADF(reason); + return path; + } + async writeFilePromise(p, content, opts) { + const { encoding, mode, index, resolvedP } = this.prepareWriteFile(p, opts); + if (index !== void 0 && typeof opts === `object` && opts.flag && opts.flag.includes(`a`)) + content = Buffer.concat([await this.getFileSource(index, { asyncDecompress: true }), Buffer.from(content)]); + if (encoding !== null) + content = content.toString(encoding); + const newIndex = this.setFileSource(resolvedP, content); + if (newIndex !== index) + this.registerEntry(resolvedP, newIndex); + if (mode !== null) { + await this.chmodPromise(resolvedP, mode); + } + } + writeFileSync(p, content, opts) { + const { encoding, mode, index, resolvedP } = this.prepareWriteFile(p, opts); + if (index !== void 0 && typeof opts === `object` && opts.flag && opts.flag.includes(`a`)) + content = Buffer.concat([this.getFileSource(index), Buffer.from(content)]); + if (encoding !== null) + content = content.toString(encoding); + const newIndex = this.setFileSource(resolvedP, content); + if (newIndex !== index) + this.registerEntry(resolvedP, newIndex); + if (mode !== null) { + this.chmodSync(resolvedP, mode); + } + } + prepareWriteFile(p, opts) { + if (typeof p === `number`) + p = this.fdToPath(p, `read`); + if (this.readOnly) + throw EROFS(`open '${p}'`); + const resolvedP = this.resolveFilename(`open '${p}'`, p); + if (this.listings.has(resolvedP)) + throw EISDIR(`open '${p}'`); + let encoding = null, mode = null; + if (typeof opts === `string`) { + encoding = opts; + } else if (typeof opts === `object`) { + ({ + encoding = null, + mode = null + } = opts); + } + const index = this.entries.get(resolvedP); + return { + encoding, + mode, + resolvedP, + index + }; + } + async unlinkPromise(p) { + return this.unlinkSync(p); + } + unlinkSync(p) { + if (this.readOnly) + throw EROFS(`unlink '${p}'`); + const resolvedP = this.resolveFilename(`unlink '${p}'`, p); + if (this.listings.has(resolvedP)) + throw EISDIR(`unlink '${p}'`); + const index = this.entries.get(resolvedP); + if (typeof index === `undefined`) + throw EINVAL(`unlink '${p}'`); + this.deleteEntry(resolvedP, index); + } + async utimesPromise(p, atime, mtime) { + return this.utimesSync(p, atime, mtime); + } + utimesSync(p, atime, mtime) { + if (this.readOnly) + throw EROFS(`utimes '${p}'`); + const resolvedP = this.resolveFilename(`utimes '${p}'`, p); + this.utimesImpl(resolvedP, mtime); + } + async lutimesPromise(p, atime, mtime) { + return this.lutimesSync(p, atime, mtime); + } + lutimesSync(p, atime, mtime) { + if (this.readOnly) + throw EROFS(`lutimes '${p}'`); + const resolvedP = this.resolveFilename(`utimes '${p}'`, p, false); + this.utimesImpl(resolvedP, mtime); + } + utimesImpl(resolvedP, mtime) { + if (this.listings.has(resolvedP)) { + if (!this.entries.has(resolvedP)) + this.hydrateDirectory(resolvedP); + } + const entry = this.entries.get(resolvedP); + if (entry === void 0) + throw new Error(`Unreachable`); + this.zipImpl.setMtime(entry, toUnixTimestamp(mtime)); + } + async mkdirPromise(p, opts) { + return this.mkdirSync(p, opts); + } + mkdirSync(p, { mode = 493, recursive = false } = {}) { + if (recursive) + return this.mkdirpSync(p, { chmod: mode }); + if (this.readOnly) + throw EROFS(`mkdir '${p}'`); + const resolvedP = this.resolveFilename(`mkdir '${p}'`, p); + if (this.entries.has(resolvedP) || this.listings.has(resolvedP)) + throw EEXIST(`mkdir '${p}'`); + this.hydrateDirectory(resolvedP); + this.chmodSync(resolvedP, mode); + return void 0; + } + async rmdirPromise(p, opts) { + return this.rmdirSync(p, opts); + } + rmdirSync(p, { recursive = false } = {}) { + if (this.readOnly) + throw EROFS(`rmdir '${p}'`); + if (recursive) { + this.removeSync(p); + return; + } + const resolvedP = this.resolveFilename(`rmdir '${p}'`, p); + const directoryListing = this.listings.get(resolvedP); + if (!directoryListing) + throw ENOTDIR(`rmdir '${p}'`); + if (directoryListing.size > 0) + throw ENOTEMPTY(`rmdir '${p}'`); + const index = this.entries.get(resolvedP); + if (typeof index === `undefined`) + throw EINVAL(`rmdir '${p}'`); + this.deleteEntry(p, index); + } + async rmPromise(p, opts) { + return this.rmSync(p, opts); + } + rmSync(p, { recursive = false } = {}) { + if (this.readOnly) + throw EROFS(`rm '${p}'`); + if (recursive) { + this.removeSync(p); + return; + } + const resolvedP = this.resolveFilename(`rm '${p}'`, p); + const directoryListing = this.listings.get(resolvedP); + if (!directoryListing) + throw ENOTDIR(`rm '${p}'`); + if (directoryListing.size > 0) + throw ENOTEMPTY(`rm '${p}'`); + const index = this.entries.get(resolvedP); + if (typeof index === `undefined`) + throw EINVAL(`rm '${p}'`); + this.deleteEntry(p, index); + } + hydrateDirectory(resolvedP) { + const index = this.zipImpl.addDirectory(ppath.relative(PortablePath.root, resolvedP)); + this.registerListing(resolvedP); + this.registerEntry(resolvedP, index); + return index; + } + async linkPromise(existingP, newP) { + return this.linkSync(existingP, newP); + } + linkSync(existingP, newP) { + throw EOPNOTSUPP(`link '${existingP}' -> '${newP}'`); + } + async symlinkPromise(target, p) { + return this.symlinkSync(target, p); + } + symlinkSync(target, p) { + if (this.readOnly) + throw EROFS(`symlink '${target}' -> '${p}'`); + const resolvedP = this.resolveFilename(`symlink '${target}' -> '${p}'`, p); + if (this.listings.has(resolvedP)) + throw EISDIR(`symlink '${target}' -> '${p}'`); + if (this.entries.has(resolvedP)) + throw EEXIST(`symlink '${target}' -> '${p}'`); + const index = this.setFileSource(resolvedP, target); + this.registerEntry(resolvedP, index); + this.zipImpl.setExternalAttributes(index, ZIP_UNIX, (fs.constants.S_IFLNK | 511) << 16); + this.symlinkCount += 1; + } + async readFilePromise(p, encoding) { + if (typeof encoding === `object`) + encoding = encoding ? encoding.encoding : void 0; + const data = await this.readFileBuffer(p, { asyncDecompress: true }); + return encoding ? data.toString(encoding) : data; + } + readFileSync(p, encoding) { + if (typeof encoding === `object`) + encoding = encoding ? encoding.encoding : void 0; + const data = this.readFileBuffer(p); + return encoding ? data.toString(encoding) : data; + } + readFileBuffer(p, opts = { asyncDecompress: false }) { + if (typeof p === `number`) + p = this.fdToPath(p, `read`); + const resolvedP = this.resolveFilename(`open '${p}'`, p); + if (!this.entries.has(resolvedP) && !this.listings.has(resolvedP)) + throw ENOENT(`open '${p}'`); + if (p[p.length - 1] === `/` && !this.listings.has(resolvedP)) + throw ENOTDIR(`open '${p}'`); + if (this.listings.has(resolvedP)) + throw EISDIR(`read`); + const entry = this.entries.get(resolvedP); + if (entry === void 0) + throw new Error(`Unreachable`); + return this.getFileSource(entry, opts); + } + async readdirPromise(p, opts) { + return this.readdirSync(p, opts); + } + readdirSync(p, opts) { + const resolvedP = this.resolveFilename(`scandir '${p}'`, p); + if (!this.entries.has(resolvedP) && !this.listings.has(resolvedP)) + throw ENOENT(`scandir '${p}'`); + const directoryListing = this.listings.get(resolvedP); + if (!directoryListing) + throw ENOTDIR(`scandir '${p}'`); + if (opts?.recursive) { + if (opts?.withFileTypes) { + const entries = Array.from(directoryListing, (name) => { + return Object.assign(this.statImpl(`lstat`, ppath.join(p, name)), { + name, + path: PortablePath.dot, + parentPath: PortablePath.dot + }); + }); + for (const entry of entries) { + if (!entry.isDirectory()) + continue; + const subPath = ppath.join(entry.path, entry.name); + const subListing = this.listings.get(ppath.join(resolvedP, subPath)); + for (const child of subListing) { + entries.push(Object.assign(this.statImpl(`lstat`, ppath.join(p, subPath, child)), { + name: child, + path: subPath, + parentPath: subPath + })); + } + } + return entries; + } else { + const entries = [...directoryListing]; + for (const subPath of entries) { + const subListing = this.listings.get(ppath.join(resolvedP, subPath)); + if (typeof subListing === `undefined`) + continue; + for (const child of subListing) { + entries.push(ppath.join(subPath, child)); + } + } + return entries; + } + } else if (opts?.withFileTypes) { + return Array.from(directoryListing, (name) => { + return Object.assign(this.statImpl(`lstat`, ppath.join(p, name)), { + name, + path: void 0, + parentPath: void 0 + }); + }); + } else { + return [...directoryListing]; + } + } + async readlinkPromise(p) { + const entry = this.prepareReadlink(p); + return (await this.getFileSource(entry, { asyncDecompress: true })).toString(); + } + readlinkSync(p) { + const entry = this.prepareReadlink(p); + return this.getFileSource(entry).toString(); + } + prepareReadlink(p) { + const resolvedP = this.resolveFilename(`readlink '${p}'`, p, false); + if (!this.entries.has(resolvedP) && !this.listings.has(resolvedP)) + throw ENOENT(`readlink '${p}'`); + if (p[p.length - 1] === `/` && !this.listings.has(resolvedP)) + throw ENOTDIR(`open '${p}'`); + if (this.listings.has(resolvedP)) + throw EINVAL(`readlink '${p}'`); + const entry = this.entries.get(resolvedP); + if (entry === void 0) + throw new Error(`Unreachable`); + if (!this.isSymbolicLink(entry)) + throw EINVAL(`readlink '${p}'`); + return entry; + } + async truncatePromise(p, len = 0) { + const resolvedP = this.resolveFilename(`open '${p}'`, p); + const index = this.entries.get(resolvedP); + if (typeof index === `undefined`) + throw EINVAL(`open '${p}'`); + const source = await this.getFileSource(index, { asyncDecompress: true }); + const truncated = Buffer.alloc(len, 0); + source.copy(truncated); + return await this.writeFilePromise(p, truncated); + } + truncateSync(p, len = 0) { + const resolvedP = this.resolveFilename(`open '${p}'`, p); + const index = this.entries.get(resolvedP); + if (typeof index === `undefined`) + throw EINVAL(`open '${p}'`); + const source = this.getFileSource(index); + const truncated = Buffer.alloc(len, 0); + source.copy(truncated); + return this.writeFileSync(p, truncated); + } + async ftruncatePromise(fd, len) { + return this.truncatePromise(this.fdToPath(fd, `ftruncate`), len); + } + ftruncateSync(fd, len) { + return this.truncateSync(this.fdToPath(fd, `ftruncateSync`), len); + } + watch(p, a, b) { + let persistent; + switch (typeof a) { + case `function`: + case `string`: + case `undefined`: + { + persistent = true; + } + break; + default: + { + ({ persistent = true } = a); + } + break; + } + if (!persistent) + return { on: () => { + }, close: () => { + } }; + const interval = setInterval(() => { + }, 24 * 60 * 60 * 1e3); + return { + on: () => { + }, + close: () => { + clearInterval(interval); + } + }; + } + watchFile(p, a, b) { + const resolvedP = ppath.resolve(PortablePath.root, p); + return watchFile(this, resolvedP, a, b); + } + unwatchFile(p, cb) { + const resolvedP = ppath.resolve(PortablePath.root, p); + return unwatchFile(this, resolvedP, cb); + } +} + +const SIGNATURE = { + CENTRAL_DIRECTORY: 33639248, + END_OF_CENTRAL_DIRECTORY: 101010256 +}; +const noCommentCDSize = 22; +class JsZipImpl { + fd; + baseFs; + entries; + filesShouldBeCached = false; + constructor(opts) { + if (`buffer` in opts) + throw new Error(`Buffer based zip archives are not supported`); + if (!opts.readOnly) + throw new Error(`Writable zip archives are not supported`); + this.baseFs = opts.baseFs; + this.fd = this.baseFs.openSync(opts.path, `r`); + try { + this.entries = JsZipImpl.readZipSync(this.fd, this.baseFs, opts.size); + } catch (error) { + this.baseFs.closeSync(this.fd); + this.fd = `closed`; + throw error; + } + } + static readZipSync(fd, baseFs, fileSize) { + if (fileSize < noCommentCDSize) + throw new Error(`Invalid ZIP file: EOCD not found`); + let eocdOffset = -1; + let eocdBuffer = Buffer.alloc(noCommentCDSize); + baseFs.readSync( + fd, + eocdBuffer, + 0, + noCommentCDSize, + fileSize - noCommentCDSize + ); + if (eocdBuffer.readUInt32LE(0) === SIGNATURE.END_OF_CENTRAL_DIRECTORY) { + eocdOffset = 0; + } else { + const bufferSize = Math.min(65557, fileSize); + eocdBuffer = Buffer.alloc(bufferSize); + baseFs.readSync( + fd, + eocdBuffer, + 0, + bufferSize, + Math.max(0, fileSize - bufferSize) + ); + for (let i = eocdBuffer.length - 4; i >= 0; i--) { + if (eocdBuffer.readUInt32LE(i) === SIGNATURE.END_OF_CENTRAL_DIRECTORY) { + eocdOffset = i; + break; + } + } + if (eocdOffset === -1) { + throw new Error(`Not a zip archive`); + } + } + const totalEntries = eocdBuffer.readUInt16LE(eocdOffset + 10); + const centralDirSize = eocdBuffer.readUInt32LE(eocdOffset + 12); + const centralDirOffset = eocdBuffer.readUInt32LE(eocdOffset + 16); + const commentLength = eocdBuffer.readUInt16LE(eocdOffset + 20); + if (eocdOffset + commentLength + noCommentCDSize > eocdBuffer.length) + throw new Error(`Zip archive inconsistent`); + if (totalEntries == 65535 || centralDirSize == 4294967295 || centralDirOffset == 4294967295) + throw new Error(`Zip 64 is not supported`); + if (centralDirSize > fileSize) + throw new Error(`Zip archive inconsistent`); + if (totalEntries > centralDirSize / 46) + throw new Error(`Zip archive inconsistent`); + const cdBuffer = Buffer.alloc(centralDirSize); + if (baseFs.readSync(fd, cdBuffer, 0, cdBuffer.length, centralDirOffset) !== cdBuffer.length) + throw new Error(`Zip archive inconsistent`); + const entries = []; + let offset = 0; + let index = 0; + let sumCompressedSize = 0; + while (index < totalEntries) { + if (offset + 46 > cdBuffer.length) + throw new Error(`Zip archive inconsistent`); + if (cdBuffer.readUInt32LE(offset) !== SIGNATURE.CENTRAL_DIRECTORY) + throw new Error(`Zip archive inconsistent`); + const versionMadeBy = cdBuffer.readUInt16LE(offset + 4); + const os = versionMadeBy >>> 8; + const flags = cdBuffer.readUInt16LE(offset + 8); + if ((flags & 1) !== 0) + throw new Error(`Encrypted zip files are not supported`); + const compressionMethod = cdBuffer.readUInt16LE(offset + 10); + const crc = cdBuffer.readUInt32LE(offset + 16); + const nameLength = cdBuffer.readUInt16LE(offset + 28); + const extraLength = cdBuffer.readUInt16LE(offset + 30); + const commentLength2 = cdBuffer.readUInt16LE(offset + 32); + const localHeaderOffset = cdBuffer.readUInt32LE(offset + 42); + const name = cdBuffer.toString(`utf8`, offset + 46, offset + 46 + nameLength).replaceAll(`\0`, ` `); + if (name.includes(`\0`)) + throw new Error(`Invalid ZIP file`); + const compressedSize = cdBuffer.readUInt32LE(offset + 20); + const externalAttributes = cdBuffer.readUInt32LE(offset + 38); + entries.push({ + name, + os, + mtime: SAFE_TIME, + //we dont care, + crc, + compressionMethod, + isSymbolicLink: os === ZIP_UNIX && (externalAttributes >>> 16 & S_IFMT) === S_IFLNK, + size: cdBuffer.readUInt32LE(offset + 24), + compressedSize, + externalAttributes, + localHeaderOffset + }); + sumCompressedSize += compressedSize; + index += 1; + offset += 46 + nameLength + extraLength + commentLength2; + } + if (sumCompressedSize > fileSize) + throw new Error(`Zip archive inconsistent`); + if (offset !== cdBuffer.length) + throw new Error(`Zip archive inconsistent`); + return entries; + } + getExternalAttributes(index) { + const entry = this.entries[index]; + return [entry.os, entry.externalAttributes]; + } + getListings() { + return this.entries.map((e) => e.name); + } + getSymlinkCount() { + let count = 0; + for (const entry of this.entries) + if (entry.isSymbolicLink) + count += 1; + return count; + } + stat(index) { + const entry = this.entries[index]; + return { + crc: entry.crc, + mtime: entry.mtime, + size: entry.size + }; + } + locate(name) { + for (let ind = 0; ind < this.entries.length; ind++) + if (this.entries[ind].name === name) + return ind; + return -1; + } + getFileSource(index) { + if (this.fd === `closed`) + throw new Error(`ZIP file is closed`); + const entry = this.entries[index]; + const localHeaderBuf = Buffer.alloc(30); + this.baseFs.readSync( + this.fd, + localHeaderBuf, + 0, + localHeaderBuf.length, + entry.localHeaderOffset + ); + const nameLength = localHeaderBuf.readUInt16LE(26); + const extraLength = localHeaderBuf.readUInt16LE(28); + const buffer = Buffer.alloc(entry.compressedSize); + if (this.baseFs.readSync(this.fd, buffer, 0, entry.compressedSize, entry.localHeaderOffset + 30 + nameLength + extraLength) !== entry.compressedSize) + throw new Error(`Invalid ZIP file`); + return { data: buffer, compressionMethod: entry.compressionMethod }; + } + discard() { + if (this.fd !== `closed`) { + this.baseFs.closeSync(this.fd); + this.fd = `closed`; + } + } + addDirectory(path) { + throw new Error(`Not implemented`); + } + deleteEntry(index) { + throw new Error(`Not implemented`); + } + setMtime(index, mtime) { + throw new Error(`Not implemented`); + } + getBufferAndClose() { + throw new Error(`Not implemented`); + } + setFileSource(target, compression, buffer) { + throw new Error(`Not implemented`); + } + setExternalAttributes(index, opsys, attributes) { + throw new Error(`Not implemented`); + } +} + +setFactory(() => { + const emZip = createModule(); + return makeInterface(emZip); +}); + +var ErrorCode = /* @__PURE__ */ ((ErrorCode2) => { + ErrorCode2["API_ERROR"] = `API_ERROR`; + ErrorCode2["BUILTIN_NODE_RESOLUTION_FAILED"] = `BUILTIN_NODE_RESOLUTION_FAILED`; + ErrorCode2["EXPORTS_RESOLUTION_FAILED"] = `EXPORTS_RESOLUTION_FAILED`; + ErrorCode2["MISSING_DEPENDENCY"] = `MISSING_DEPENDENCY`; + ErrorCode2["MISSING_PEER_DEPENDENCY"] = `MISSING_PEER_DEPENDENCY`; + ErrorCode2["QUALIFIED_PATH_RESOLUTION_FAILED"] = `QUALIFIED_PATH_RESOLUTION_FAILED`; + ErrorCode2["INTERNAL"] = `INTERNAL`; + ErrorCode2["UNDECLARED_DEPENDENCY"] = `UNDECLARED_DEPENDENCY`; + ErrorCode2["UNSUPPORTED"] = `UNSUPPORTED`; + return ErrorCode2; +})(ErrorCode || {}); +const MODULE_NOT_FOUND_ERRORS = /* @__PURE__ */ new Set([ + "BUILTIN_NODE_RESOLUTION_FAILED" /* BUILTIN_NODE_RESOLUTION_FAILED */, + "MISSING_DEPENDENCY" /* MISSING_DEPENDENCY */, + "MISSING_PEER_DEPENDENCY" /* MISSING_PEER_DEPENDENCY */, + "QUALIFIED_PATH_RESOLUTION_FAILED" /* QUALIFIED_PATH_RESOLUTION_FAILED */, + "UNDECLARED_DEPENDENCY" /* UNDECLARED_DEPENDENCY */ +]); +function makeError(pnpCode, message, data = {}, code) { + code ??= MODULE_NOT_FOUND_ERRORS.has(pnpCode) ? `MODULE_NOT_FOUND` : pnpCode; + const propertySpec = { + configurable: true, + writable: true, + enumerable: false + }; + return Object.defineProperties(new Error(message), { + code: { + ...propertySpec, + value: code + }, + pnpCode: { + ...propertySpec, + value: pnpCode + }, + data: { + ...propertySpec, + value: data + } + }); +} +function getIssuerModule(parent) { + let issuer = parent; + while (issuer && (issuer.id === `[eval]` || issuer.id === `` || !issuer.filename)) + issuer = issuer.parent; + return issuer || null; +} +function getPathForDisplay(p) { + return npath.normalize(npath.fromPortablePath(p)); +} + +const [major, minor, patch] = process.versions.node.split(`.`).map((value) => parseInt(value, 10)); +const WATCH_MODE_MESSAGE_USES_ARRAYS = major > 19 || major === 19 && minor >= 2 || major === 18 && minor >= 13; + +function readPackageScope(checkPath) { + const rootSeparatorIndex = checkPath.indexOf(npath.sep); + let separatorIndex; + do { + separatorIndex = checkPath.lastIndexOf(npath.sep); + checkPath = checkPath.slice(0, separatorIndex); + if (checkPath.endsWith(`${npath.sep}node_modules`)) + return false; + const pjson = readPackage(checkPath + npath.sep); + if (pjson) { + return { + data: pjson, + path: checkPath + }; + } + } while (separatorIndex > rootSeparatorIndex); + return false; +} +function readPackage(requestPath) { + const jsonPath = npath.resolve(requestPath, `package.json`); + if (!fs__default.default.existsSync(jsonPath)) + return null; + return JSON.parse(fs__default.default.readFileSync(jsonPath, `utf8`)); +} +function ERR_REQUIRE_ESM(filename, parentPath = null) { + const basename = parentPath && path__default.default.basename(filename) === path__default.default.basename(parentPath) ? filename : path__default.default.basename(filename); + const msg = `require() of ES Module ${filename}${parentPath ? ` from ${parentPath}` : ``} not supported. +Instead change the require of ${basename} in ${parentPath} to a dynamic import() which is available in all CommonJS modules.`; + const err = new Error(msg); + err.code = `ERR_REQUIRE_ESM`; + return err; +} +function reportRequiredFilesToWatchMode(paths) { + if (process.env.WATCH_REPORT_DEPENDENCIES && process.send) { + const files = paths.map((filename) => npath.fromPortablePath(VirtualFS.resolveVirtual(filename))); + if (WATCH_MODE_MESSAGE_USES_ARRAYS) { + process.send({ "watch:require": files }); + } else { + for (const filename of files) { + process.send({ "watch:require": filename }); + } + } + } +} + +function applyPatch(pnpapi, opts) { + let enableNativeHooks = true; + process.versions.pnp = String(pnpapi.VERSIONS.std); + const moduleExports = require$$0__default.default; + moduleExports.findPnpApi = (lookupSource) => { + const lookupPath = lookupSource instanceof URL ? url.fileURLToPath(lookupSource) : lookupSource; + const apiPath = opts.manager.findApiPathFor(lookupPath); + if (apiPath === null) + return null; + const apiEntry = opts.manager.getApiEntry(apiPath, true); + return apiEntry.instance.findPackageLocator(lookupPath) ? apiEntry.instance : null; + }; + function getRequireStack(parent) { + const requireStack = []; + for (let cursor = parent; cursor; cursor = cursor.parent) + requireStack.push(cursor.filename || cursor.id); + return requireStack; + } + const originalModuleLoad = require$$0.Module._load; + require$$0.Module._load = function(request, parent, isMain) { + if (request === `pnpapi`) { + const parentApiPath = opts.manager.getApiPathFromParent(parent); + if (parentApiPath) { + return opts.manager.getApiEntry(parentApiPath, true).instance; + } + } + return originalModuleLoad.call(require$$0.Module, request, parent, isMain); + }; + function getIssuerSpecsFromPaths(paths) { + return paths.map((path) => ({ + apiPath: opts.manager.findApiPathFor(path), + path, + module: null + })); + } + function getIssuerSpecsFromModule(module) { + if (module && module.id !== `` && module.id !== `internal/preload` && !module.parent && !module.filename && module.paths.length > 0) { + return [{ + apiPath: opts.manager.findApiPathFor(module.paths[0]), + path: module.paths[0], + module + }]; + } + const issuer = getIssuerModule(module); + if (issuer !== null) { + const path = npath.dirname(issuer.filename); + const apiPath = opts.manager.getApiPathFromParent(issuer); + return [{ apiPath, path, module }]; + } else { + const path = process.cwd(); + const apiPath = opts.manager.findApiPathFor(npath.join(path, `[file]`)) ?? opts.manager.getApiPathFromParent(null); + return [{ apiPath, path, module }]; + } + } + function makeFakeParent(path) { + const fakeParent = new require$$0.Module(``); + const fakeFilePath = npath.join(path, `[file]`); + fakeParent.paths = require$$0.Module._nodeModulePaths(fakeFilePath); + return fakeParent; + } + const pathRegExp = /^(?![a-zA-Z]:[\\/]|\\\\|\.{0,2}(?:\/|$))((?:@[^/]+\/)?[^/]+)\/*(.*|)$/; + const originalModuleResolveFilename = require$$0.Module._resolveFilename; + require$$0.Module._resolveFilename = function(request, parent, isMain, options) { + if (require$$0.isBuiltin(request)) + return request; + if (!enableNativeHooks) + return originalModuleResolveFilename.call(require$$0.Module, request, parent, isMain, options); + if (options && options.plugnplay === false) { + const { plugnplay, ...forwardedOptions } = options; + try { + enableNativeHooks = false; + return originalModuleResolveFilename.call(require$$0.Module, request, parent, isMain, forwardedOptions); + } finally { + enableNativeHooks = true; + } + } + if (options) { + const optionNames = new Set(Object.keys(options)); + optionNames.delete(`paths`); + optionNames.delete(`plugnplay`); + optionNames.delete(`conditions`); + if (optionNames.size > 0) { + throw makeError( + ErrorCode.UNSUPPORTED, + `Some options passed to require() aren't supported by PnP yet (${Array.from(optionNames).join(`, `)})` + ); + } + } + const issuerSpecs = options && options.paths ? getIssuerSpecsFromPaths(options.paths) : getIssuerSpecsFromModule(parent); + if (request.match(pathRegExp) === null) { + const parentDirectory = parent?.filename != null ? npath.dirname(parent.filename) : null; + const absoluteRequest = npath.isAbsolute(request) ? request : parentDirectory !== null ? npath.resolve(parentDirectory, request) : null; + if (absoluteRequest !== null) { + const apiPath = parent && parentDirectory === npath.dirname(absoluteRequest) ? opts.manager.getApiPathFromParent(parent) : opts.manager.findApiPathFor(absoluteRequest); + if (apiPath !== null) { + issuerSpecs.unshift({ + apiPath, + path: parentDirectory, + module: null + }); + } + } + } + let firstError; + for (const { apiPath, path, module } of issuerSpecs) { + let resolution; + const issuerApi = apiPath !== null ? opts.manager.getApiEntry(apiPath, true).instance : null; + try { + if (issuerApi !== null) { + resolution = issuerApi.resolveRequest(request, path !== null ? `${path}/` : null, { + conditions: options?.conditions + }); + } else { + if (path === null) + throw new Error(`Assertion failed: Expected the path to be set`); + resolution = originalModuleResolveFilename.call(require$$0.Module, request, module || makeFakeParent(path), isMain, { + conditions: options?.conditions + }); + } + } catch (error) { + firstError = firstError || error; + continue; + } + if (resolution !== null) { + return resolution; + } + } + const requireStack = getRequireStack(parent); + Object.defineProperty(firstError, `requireStack`, { + configurable: true, + writable: true, + enumerable: false, + value: requireStack + }); + if (requireStack.length > 0) + firstError.message += ` +Require stack: +- ${requireStack.join(` +- `)}`; + if (typeof firstError.pnpCode === `string`) + Error.captureStackTrace(firstError); + throw firstError; + }; + const originalFindPath = require$$0.Module._findPath; + require$$0.Module._findPath = function(request, paths, isMain) { + if (request === `pnpapi`) + return false; + if (!enableNativeHooks) + return originalFindPath.call(require$$0.Module, request, paths, isMain); + const isAbsolute = npath.isAbsolute(request); + if (isAbsolute) + paths = [``]; + else if (!paths || paths.length === 0) + return false; + for (const path of paths) { + let resolution; + try { + const pnpApiPath = opts.manager.findApiPathFor(isAbsolute ? request : path); + if (pnpApiPath !== null) { + const api = opts.manager.getApiEntry(pnpApiPath, true).instance; + resolution = api.resolveRequest(request, path) || false; + } else { + resolution = originalFindPath.call(require$$0.Module, request, [path], isMain); + } + } catch { + continue; + } + if (resolution) { + return resolution; + } + } + return false; + }; + if (!process.features.require_module) { + const originalExtensionJSFunction = require$$0.Module._extensions[`.js`]; + require$$0.Module._extensions[`.js`] = function(module, filename) { + if (filename.endsWith(`.js`)) { + const pkg = readPackageScope(filename); + if (pkg && pkg.data?.type === `module`) { + const err = ERR_REQUIRE_ESM(filename, module.parent?.filename); + Error.captureStackTrace(err); + throw err; + } + } + originalExtensionJSFunction.call(this, module, filename); + }; + } + const originalDlopen = process.dlopen; + process.dlopen = function(...args) { + const [module, filename, ...rest] = args; + return originalDlopen.call( + this, + module, + npath.fromPortablePath(VirtualFS.resolveVirtual(npath.toPortablePath(filename))), + ...rest + ); + }; + const originalEmit = process.emit; + process.emit = function(name, data, ...args) { + if (name === `warning` && typeof data === `object` && data.name === `ExperimentalWarning` && (data.message.includes(`--experimental-loader`) || data.message.includes(`Custom ESM Loaders is an experimental feature`))) + return false; + return originalEmit.apply(process, arguments); + }; + patchFs(fs__default.default, new PosixFS(opts.fakeFs)); +} + +function hydrateRuntimeState(data, { basePath }) { + const portablePath = npath.toPortablePath(basePath); + const absolutePortablePath = ppath.resolve(portablePath); + const ignorePattern = data.ignorePatternData !== null ? new RegExp(data.ignorePatternData) : null; + const packageLocatorsByLocations = /* @__PURE__ */ new Map(); + const packageRegistry = new Map(data.packageRegistryData.map(([packageName, packageStoreData]) => { + return [packageName, new Map(packageStoreData.map(([packageReference, packageInformationData]) => { + if (packageName === null !== (packageReference === null)) + throw new Error(`Assertion failed: The name and reference should be null, or neither should`); + const discardFromLookup = packageInformationData.discardFromLookup ?? false; + const packageLocator = { name: packageName, reference: packageReference }; + const entry = packageLocatorsByLocations.get(packageInformationData.packageLocation); + if (!entry) { + packageLocatorsByLocations.set(packageInformationData.packageLocation, { locator: packageLocator, discardFromLookup }); + } else { + entry.discardFromLookup = entry.discardFromLookup && discardFromLookup; + if (!discardFromLookup) { + entry.locator = packageLocator; + } + } + let resolvedPackageLocation = null; + return [packageReference, { + packageDependencies: new Map(packageInformationData.packageDependencies), + packagePeers: new Set(packageInformationData.packagePeers), + linkType: packageInformationData.linkType, + discardFromLookup, + // we only need this for packages that are used by the currently running script + // this is a lazy getter because `ppath.join` has some overhead + get packageLocation() { + return resolvedPackageLocation || (resolvedPackageLocation = ppath.join(absolutePortablePath, packageInformationData.packageLocation)); + } + }]; + }))]; + })); + const fallbackExclusionList = new Map(data.fallbackExclusionList.map(([packageName, packageReferences]) => { + return [packageName, new Set(packageReferences)]; + })); + const fallbackPool = new Map(data.fallbackPool); + const dependencyTreeRoots = data.dependencyTreeRoots; + const enableTopLevelFallback = data.enableTopLevelFallback; + return { + basePath: portablePath, + dependencyTreeRoots, + enableTopLevelFallback, + fallbackExclusionList, + pnpZipBackend: data.pnpZipBackend, + fallbackPool, + ignorePattern, + packageLocatorsByLocations, + packageRegistry + }; +} + +const ArrayIsArray = Array.isArray; +const JSONStringify = JSON.stringify; +const ObjectGetOwnPropertyNames = Object.getOwnPropertyNames; +const ObjectPrototypeHasOwnProperty = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop); +const RegExpPrototypeExec = (obj, string) => RegExp.prototype.exec.call(obj, string); +const RegExpPrototypeSymbolReplace = (obj, ...rest) => RegExp.prototype[Symbol.replace].apply(obj, rest); +const StringPrototypeEndsWith = (str, ...rest) => String.prototype.endsWith.apply(str, rest); +const StringPrototypeIncludes = (str, ...rest) => String.prototype.includes.apply(str, rest); +const StringPrototypeLastIndexOf = (str, ...rest) => String.prototype.lastIndexOf.apply(str, rest); +const StringPrototypeIndexOf = (str, ...rest) => String.prototype.indexOf.apply(str, rest); +const StringPrototypeReplace = (str, ...rest) => String.prototype.replace.apply(str, rest); +const StringPrototypeSlice = (str, ...rest) => String.prototype.slice.apply(str, rest); +const StringPrototypeStartsWith = (str, ...rest) => String.prototype.startsWith.apply(str, rest); +const SafeMap = Map; +const JSONParse = JSON.parse; + +function createErrorType(code, messageCreator, errorType) { + return class extends errorType { + constructor(...args) { + super(messageCreator(...args)); + this.code = code; + this.name = `${errorType.name} [${code}]`; + } + }; +} +const ERR_PACKAGE_IMPORT_NOT_DEFINED = createErrorType( + `ERR_PACKAGE_IMPORT_NOT_DEFINED`, + (specifier, packagePath, base) => { + return `Package import specifier "${specifier}" is not defined${packagePath ? ` in package ${packagePath}package.json` : ``} imported from ${base}`; + }, + TypeError +); +const ERR_INVALID_MODULE_SPECIFIER = createErrorType( + `ERR_INVALID_MODULE_SPECIFIER`, + (request, reason, base = void 0) => { + return `Invalid module "${request}" ${reason}${base ? ` imported from ${base}` : ``}`; + }, + TypeError +); +const ERR_INVALID_PACKAGE_TARGET = createErrorType( + `ERR_INVALID_PACKAGE_TARGET`, + (pkgPath, key, target, isImport = false, base = void 0) => { + const relError = typeof target === `string` && !isImport && target.length && !StringPrototypeStartsWith(target, `./`); + if (key === `.`) { + assert__default.default(isImport === false); + return `Invalid "exports" main target ${JSONStringify(target)} defined in the package config ${pkgPath}package.json${base ? ` imported from ${base}` : ``}${relError ? `; targets must start with "./"` : ``}`; + } + return `Invalid "${isImport ? `imports` : `exports`}" target ${JSONStringify( + target + )} defined for '${key}' in the package config ${pkgPath}package.json${base ? ` imported from ${base}` : ``}${relError ? `; targets must start with "./"` : ``}`; + }, + Error +); +const ERR_INVALID_PACKAGE_CONFIG = createErrorType( + `ERR_INVALID_PACKAGE_CONFIG`, + (path, base, message) => { + return `Invalid package config ${path}${base ? ` while importing ${base}` : ``}${message ? `. ${message}` : ``}`; + }, + Error +); +const ERR_PACKAGE_PATH_NOT_EXPORTED = createErrorType( + "ERR_PACKAGE_PATH_NOT_EXPORTED", + (pkgPath, subpath, base = void 0) => { + if (subpath === ".") + return `No "exports" main defined in ${pkgPath}package.json${base ? ` imported from ${base}` : ""}`; + return `Package subpath '${subpath}' is not defined by "exports" in ${pkgPath}package.json${base ? ` imported from ${base}` : ""}`; + }, + Error +); + +function filterOwnProperties(source, keys) { + const filtered = /* @__PURE__ */ Object.create(null); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (ObjectPrototypeHasOwnProperty(source, key)) { + filtered[key] = source[key]; + } + } + return filtered; +} + +const packageJSONCache = new SafeMap(); +function getPackageConfig(path, specifier, base, readFileSyncFn) { + const existing = packageJSONCache.get(path); + if (existing !== void 0) { + return existing; + } + const source = readFileSyncFn(path); + if (source === void 0) { + const packageConfig2 = { + pjsonPath: path, + exists: false, + main: void 0, + name: void 0, + type: "none", + exports: void 0, + imports: void 0 + }; + packageJSONCache.set(path, packageConfig2); + return packageConfig2; + } + let packageJSON; + try { + packageJSON = JSONParse(source); + } catch (error) { + throw new ERR_INVALID_PACKAGE_CONFIG( + path, + (base ? `"${specifier}" from ` : "") + url.fileURLToPath(base || specifier), + error.message + ); + } + let { imports, main, name, type } = filterOwnProperties(packageJSON, [ + "imports", + "main", + "name", + "type" + ]); + const exports = ObjectPrototypeHasOwnProperty(packageJSON, "exports") ? packageJSON.exports : void 0; + if (typeof imports !== "object" || imports === null) { + imports = void 0; + } + if (typeof main !== "string") { + main = void 0; + } + if (typeof name !== "string") { + name = void 0; + } + if (type !== "module" && type !== "commonjs") { + type = "none"; + } + const packageConfig = { + pjsonPath: path, + exists: true, + main, + name, + type, + exports, + imports + }; + packageJSONCache.set(path, packageConfig); + return packageConfig; +} +function getPackageScopeConfig(resolved, readFileSyncFn) { + let packageJSONUrl = new URL("./package.json", resolved); + while (true) { + const packageJSONPath2 = packageJSONUrl.pathname; + if (StringPrototypeEndsWith(packageJSONPath2, "node_modules/package.json")) { + break; + } + const packageConfig2 = getPackageConfig( + url.fileURLToPath(packageJSONUrl), + resolved, + void 0, + readFileSyncFn + ); + if (packageConfig2.exists) { + return packageConfig2; + } + const lastPackageJSONUrl = packageJSONUrl; + packageJSONUrl = new URL("../package.json", packageJSONUrl); + if (packageJSONUrl.pathname === lastPackageJSONUrl.pathname) { + break; + } + } + const packageJSONPath = url.fileURLToPath(packageJSONUrl); + const packageConfig = { + pjsonPath: packageJSONPath, + exists: false, + main: void 0, + name: void 0, + type: "none", + exports: void 0, + imports: void 0 + }; + packageJSONCache.set(packageJSONPath, packageConfig); + return packageConfig; +} + +function throwImportNotDefined(specifier, packageJSONUrl, base) { + throw new ERR_PACKAGE_IMPORT_NOT_DEFINED( + specifier, + packageJSONUrl && url.fileURLToPath(new URL(".", packageJSONUrl)), + url.fileURLToPath(base) + ); +} +function throwInvalidSubpath(subpath, packageJSONUrl, internal, base) { + const reason = `request is not a valid subpath for the "${internal ? "imports" : "exports"}" resolution of ${url.fileURLToPath(packageJSONUrl)}`; + throw new ERR_INVALID_MODULE_SPECIFIER( + subpath, + reason, + base && url.fileURLToPath(base) + ); +} +function throwInvalidPackageTarget(subpath, target, packageJSONUrl, internal, base) { + if (typeof target === "object" && target !== null) { + target = JSONStringify(target, null, ""); + } else { + target = `${target}`; + } + throw new ERR_INVALID_PACKAGE_TARGET( + url.fileURLToPath(new URL(".", packageJSONUrl)), + subpath, + target, + internal, + base && url.fileURLToPath(base) + ); +} +const invalidSegmentRegEx = /(^|\\|\/)((\.|%2e)(\.|%2e)?|(n|%6e|%4e)(o|%6f|%4f)(d|%64|%44)(e|%65|%45)(_|%5f)(m|%6d|%4d)(o|%6f|%4f)(d|%64|%44)(u|%75|%55)(l|%6c|%4c)(e|%65|%45)(s|%73|%53))(\\|\/|$)/i; +const patternRegEx = /\*/g; +function resolvePackageTargetString(target, subpath, match, packageJSONUrl, base, pattern, internal, conditions) { + if (subpath !== "" && !pattern && target[target.length - 1] !== "/") + throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base); + if (!StringPrototypeStartsWith(target, "./")) { + if (internal && !StringPrototypeStartsWith(target, "../") && !StringPrototypeStartsWith(target, "/")) { + let isURL = false; + try { + new URL(target); + isURL = true; + } catch { + } + if (!isURL) { + const exportTarget = pattern ? RegExpPrototypeSymbolReplace(patternRegEx, target, () => subpath) : target + subpath; + return exportTarget; + } + } + throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base); + } + if (RegExpPrototypeExec( + invalidSegmentRegEx, + StringPrototypeSlice(target, 2) + ) !== null) + throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base); + const resolved = new URL(target, packageJSONUrl); + const resolvedPath = resolved.pathname; + const packagePath = new URL(".", packageJSONUrl).pathname; + if (!StringPrototypeStartsWith(resolvedPath, packagePath)) + throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base); + if (subpath === "") return resolved; + if (RegExpPrototypeExec(invalidSegmentRegEx, subpath) !== null) { + const request = pattern ? StringPrototypeReplace(match, "*", () => subpath) : match + subpath; + throwInvalidSubpath(request, packageJSONUrl, internal, base); + } + if (pattern) { + return new URL( + RegExpPrototypeSymbolReplace(patternRegEx, resolved.href, () => subpath) + ); + } + return new URL(subpath, resolved); +} +function isArrayIndex(key) { + const keyNum = +key; + if (`${keyNum}` !== key) return false; + return keyNum >= 0 && keyNum < 4294967295; +} +function resolvePackageTarget(packageJSONUrl, target, subpath, packageSubpath, base, pattern, internal, conditions) { + if (typeof target === "string") { + return resolvePackageTargetString( + target, + subpath, + packageSubpath, + packageJSONUrl, + base, + pattern, + internal); + } else if (ArrayIsArray(target)) { + if (target.length === 0) { + return null; + } + let lastException; + for (let i = 0; i < target.length; i++) { + const targetItem = target[i]; + let resolveResult; + try { + resolveResult = resolvePackageTarget( + packageJSONUrl, + targetItem, + subpath, + packageSubpath, + base, + pattern, + internal, + conditions + ); + } catch (e) { + lastException = e; + if (e.code === "ERR_INVALID_PACKAGE_TARGET") { + continue; + } + throw e; + } + if (resolveResult === void 0) { + continue; + } + if (resolveResult === null) { + lastException = null; + continue; + } + return resolveResult; + } + if (lastException === void 0 || lastException === null) + return lastException; + throw lastException; + } else if (typeof target === "object" && target !== null) { + const keys = ObjectGetOwnPropertyNames(target); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (isArrayIndex(key)) { + throw new ERR_INVALID_PACKAGE_CONFIG( + url.fileURLToPath(packageJSONUrl), + base, + '"exports" cannot contain numeric property keys.' + ); + } + } + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key === "default" || conditions.has(key)) { + const conditionalTarget = target[key]; + const resolveResult = resolvePackageTarget( + packageJSONUrl, + conditionalTarget, + subpath, + packageSubpath, + base, + pattern, + internal, + conditions + ); + if (resolveResult === void 0) continue; + return resolveResult; + } + } + return void 0; + } else if (target === null) { + return null; + } + throwInvalidPackageTarget( + packageSubpath, + target, + packageJSONUrl, + internal, + base + ); +} +function patternKeyCompare(a, b) { + const aPatternIndex = StringPrototypeIndexOf(a, "*"); + const bPatternIndex = StringPrototypeIndexOf(b, "*"); + const baseLenA = aPatternIndex === -1 ? a.length : aPatternIndex + 1; + const baseLenB = bPatternIndex === -1 ? b.length : bPatternIndex + 1; + if (baseLenA > baseLenB) return -1; + if (baseLenB > baseLenA) return 1; + if (aPatternIndex === -1) return 1; + if (bPatternIndex === -1) return -1; + if (a.length > b.length) return -1; + if (b.length > a.length) return 1; + return 0; +} +function isConditionalExportsMainSugar(exports, packageJSONUrl, base) { + if (typeof exports === "string" || ArrayIsArray(exports)) return true; + if (typeof exports !== "object" || exports === null) return false; + const keys = ObjectGetOwnPropertyNames(exports); + let isConditionalSugar = false; + let i = 0; + for (let j = 0; j < keys.length; j++) { + const key = keys[j]; + const curIsConditionalSugar = key === "" || key[0] !== "."; + if (i++ === 0) { + isConditionalSugar = curIsConditionalSugar; + } else if (isConditionalSugar !== curIsConditionalSugar) { + throw new ERR_INVALID_PACKAGE_CONFIG( + url.fileURLToPath(packageJSONUrl), + base, + `"exports" cannot contain some keys starting with '.' and some not. The exports object must either be an object of package subpath keys or an object of main entry condition name keys only.` + ); + } + } + return isConditionalSugar; +} +function throwExportsNotFound(subpath, packageJSONUrl, base) { + throw new ERR_PACKAGE_PATH_NOT_EXPORTED( + url.fileURLToPath(new URL(".", packageJSONUrl)), + subpath, + base && url.fileURLToPath(base) + ); +} +const emittedPackageWarnings = /* @__PURE__ */ new Set(); +function emitTrailingSlashPatternDeprecation(match, pjsonUrl, base) { + const pjsonPath = url.fileURLToPath(pjsonUrl); + if (emittedPackageWarnings.has(pjsonPath + "|" + match)) return; + emittedPackageWarnings.add(pjsonPath + "|" + match); + process.emitWarning( + `Use of deprecated trailing slash pattern mapping "${match}" in the "exports" field module resolution of the package at ${pjsonPath}${base ? ` imported from ${url.fileURLToPath(base)}` : ""}. Mapping specifiers ending in "/" is no longer supported.`, + "DeprecationWarning", + "DEP0155" + ); +} +function packageExportsResolve({ + packageJSONUrl, + packageSubpath, + exports, + base, + conditions +}) { + if (isConditionalExportsMainSugar(exports, packageJSONUrl, base)) + exports = { ".": exports }; + if (ObjectPrototypeHasOwnProperty(exports, packageSubpath) && !StringPrototypeIncludes(packageSubpath, "*") && !StringPrototypeEndsWith(packageSubpath, "/")) { + const target = exports[packageSubpath]; + const resolveResult = resolvePackageTarget( + packageJSONUrl, + target, + "", + packageSubpath, + base, + false, + false, + conditions + ); + if (resolveResult == null) { + throwExportsNotFound(packageSubpath, packageJSONUrl, base); + } + return resolveResult; + } + let bestMatch = ""; + let bestMatchSubpath; + const keys = ObjectGetOwnPropertyNames(exports); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const patternIndex = StringPrototypeIndexOf(key, "*"); + if (patternIndex !== -1 && StringPrototypeStartsWith( + packageSubpath, + StringPrototypeSlice(key, 0, patternIndex) + )) { + if (StringPrototypeEndsWith(packageSubpath, "/")) + emitTrailingSlashPatternDeprecation( + packageSubpath, + packageJSONUrl, + base + ); + const patternTrailer = StringPrototypeSlice(key, patternIndex + 1); + if (packageSubpath.length >= key.length && StringPrototypeEndsWith(packageSubpath, patternTrailer) && patternKeyCompare(bestMatch, key) === 1 && StringPrototypeLastIndexOf(key, "*") === patternIndex) { + bestMatch = key; + bestMatchSubpath = StringPrototypeSlice( + packageSubpath, + patternIndex, + packageSubpath.length - patternTrailer.length + ); + } + } + } + if (bestMatch) { + const target = exports[bestMatch]; + const resolveResult = resolvePackageTarget( + packageJSONUrl, + target, + bestMatchSubpath, + bestMatch, + base, + true, + false, + conditions + ); + if (resolveResult == null) { + throwExportsNotFound(packageSubpath, packageJSONUrl, base); + } + return resolveResult; + } + throwExportsNotFound(packageSubpath, packageJSONUrl, base); +} +function packageImportsResolve({ name, base, conditions, readFileSyncFn }) { + if (name === "#" || StringPrototypeStartsWith(name, "#/") || StringPrototypeEndsWith(name, "/")) { + const reason = "is not a valid internal imports specifier name"; + throw new ERR_INVALID_MODULE_SPECIFIER(name, reason, url.fileURLToPath(base)); + } + let packageJSONUrl; + const packageConfig = getPackageScopeConfig(base, readFileSyncFn); + if (packageConfig.exists) { + packageJSONUrl = url.pathToFileURL(packageConfig.pjsonPath); + const imports = packageConfig.imports; + if (imports) { + if (ObjectPrototypeHasOwnProperty(imports, name) && !StringPrototypeIncludes(name, "*")) { + const resolveResult = resolvePackageTarget( + packageJSONUrl, + imports[name], + "", + name, + base, + false, + true, + conditions + ); + if (resolveResult != null) { + return resolveResult; + } + } else { + let bestMatch = ""; + let bestMatchSubpath; + const keys = ObjectGetOwnPropertyNames(imports); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const patternIndex = StringPrototypeIndexOf(key, "*"); + if (patternIndex !== -1 && StringPrototypeStartsWith( + name, + StringPrototypeSlice(key, 0, patternIndex) + )) { + const patternTrailer = StringPrototypeSlice(key, patternIndex + 1); + if (name.length >= key.length && StringPrototypeEndsWith(name, patternTrailer) && patternKeyCompare(bestMatch, key) === 1 && StringPrototypeLastIndexOf(key, "*") === patternIndex) { + bestMatch = key; + bestMatchSubpath = StringPrototypeSlice( + name, + patternIndex, + name.length - patternTrailer.length + ); + } + } + } + if (bestMatch) { + const target = imports[bestMatch]; + const resolveResult = resolvePackageTarget( + packageJSONUrl, + target, + bestMatchSubpath, + bestMatch, + base, + true, + true, + conditions + ); + if (resolveResult != null) { + return resolveResult; + } + } + } + } + } + throwImportNotDefined(name, packageJSONUrl, base); +} + +const flagSymbol = Symbol('arg flag'); + +class ArgError extends Error { + constructor(msg, code) { + super(msg); + this.name = 'ArgError'; + this.code = code; + + Object.setPrototypeOf(this, ArgError.prototype); + } +} + +function arg( + opts, + { + argv = process.argv.slice(2), + permissive = false, + stopAtPositional = false + } = {} +) { + if (!opts) { + throw new ArgError( + 'argument specification object is required', + 'ARG_CONFIG_NO_SPEC' + ); + } + + const result = { _: [] }; + + const aliases = {}; + const handlers = {}; + + for (const key of Object.keys(opts)) { + if (!key) { + throw new ArgError( + 'argument key cannot be an empty string', + 'ARG_CONFIG_EMPTY_KEY' + ); + } + + if (key[0] !== '-') { + throw new ArgError( + `argument key must start with '-' but found: '${key}'`, + 'ARG_CONFIG_NONOPT_KEY' + ); + } + + if (key.length === 1) { + throw new ArgError( + `argument key must have a name; singular '-' keys are not allowed: ${key}`, + 'ARG_CONFIG_NONAME_KEY' + ); + } + + if (typeof opts[key] === 'string') { + aliases[key] = opts[key]; + continue; + } + + let type = opts[key]; + let isFlag = false; + + if ( + Array.isArray(type) && + type.length === 1 && + typeof type[0] === 'function' + ) { + const [fn] = type; + type = (value, name, prev = []) => { + prev.push(fn(value, name, prev[prev.length - 1])); + return prev; + }; + isFlag = fn === Boolean || fn[flagSymbol] === true; + } else if (typeof type === 'function') { + isFlag = type === Boolean || type[flagSymbol] === true; + } else { + throw new ArgError( + `type missing or not a function or valid array type: ${key}`, + 'ARG_CONFIG_VAD_TYPE' + ); + } + + if (key[1] !== '-' && key.length > 2) { + throw new ArgError( + `short argument keys (with a single hyphen) must have only one character: ${key}`, + 'ARG_CONFIG_SHORTOPT_TOOLONG' + ); + } + + handlers[key] = [type, isFlag]; + } + + for (let i = 0, len = argv.length; i < len; i++) { + const wholeArg = argv[i]; + + if (stopAtPositional && result._.length > 0) { + result._ = result._.concat(argv.slice(i)); + break; + } + + if (wholeArg === '--') { + result._ = result._.concat(argv.slice(i + 1)); + break; + } + + if (wholeArg.length > 1 && wholeArg[0] === '-') { + /* eslint-disable operator-linebreak */ + const separatedArguments = + wholeArg[1] === '-' || wholeArg.length === 2 + ? [wholeArg] + : wholeArg + .slice(1) + .split('') + .map((a) => `-${a}`); + /* eslint-enable operator-linebreak */ + + for (let j = 0; j < separatedArguments.length; j++) { + const arg = separatedArguments[j]; + const [originalArgName, argStr] = + arg[1] === '-' ? arg.split(/=(.*)/, 2) : [arg, undefined]; + + let argName = originalArgName; + while (argName in aliases) { + argName = aliases[argName]; + } + + if (!(argName in handlers)) { + if (permissive) { + result._.push(arg); + continue; + } else { + throw new ArgError( + `unknown or unexpected option: ${originalArgName}`, + 'ARG_UNKNOWN_OPTION' + ); + } + } + + const [type, isFlag] = handlers[argName]; + + if (!isFlag && j + 1 < separatedArguments.length) { + throw new ArgError( + `option requires argument (but was followed by another short argument): ${originalArgName}`, + 'ARG_MISSING_REQUIRED_SHORTARG' + ); + } + + if (isFlag) { + result[argName] = type(true, argName, result[argName]); + } else if (argStr === undefined) { + if ( + argv.length < i + 2 || + (argv[i + 1].length > 1 && + argv[i + 1][0] === '-' && + !( + argv[i + 1].match(/^-?\d*(\.(?=\d))?\d*$/) && + (type === Number || + // eslint-disable-next-line no-undef + (typeof BigInt !== 'undefined' && type === BigInt)) + )) + ) { + const extended = + originalArgName === argName ? '' : ` (alias for ${argName})`; + throw new ArgError( + `option requires argument: ${originalArgName}${extended}`, + 'ARG_MISSING_REQUIRED_LONGARG' + ); + } + + result[argName] = type(argv[i + 1], argName, result[argName]); + ++i; + } else { + result[argName] = type(argStr, argName, result[argName]); + } + } + } else { + result._.push(wholeArg); + } + } + + return result; +} + +arg.flag = (fn) => { + fn[flagSymbol] = true; + return fn; +}; + +// Utility types +arg.COUNT = arg.flag((v, name, existingCount) => (existingCount || 0) + 1); + +// Expose error class +arg.ArgError = ArgError; + +var arg_1 = arg; + +/** + @license + The MIT License (MIT) + + Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ +function getOptionValue(opt) { + parseOptions(); + return options[opt]; +} +let options; +function parseOptions() { + if (!options) { + options = { + "--conditions": [], + ...parseArgv(getNodeOptionsEnvArgv()), + ...parseArgv(process.execArgv) + }; + } +} +function parseArgv(argv) { + return arg_1( + { + "--conditions": [String], + "-C": "--conditions" + }, + { + argv, + permissive: true + } + ); +} +function getNodeOptionsEnvArgv() { + const errors = []; + const envArgv = ParseNodeOptionsEnvVar(process.env.NODE_OPTIONS || "", errors); + if (errors.length !== 0) ; + return envArgv; +} +function ParseNodeOptionsEnvVar(node_options, errors) { + const env_argv = []; + let is_in_string = false; + let will_start_new_arg = true; + for (let index = 0; index < node_options.length; ++index) { + let c = node_options[index]; + if (c === "\\" && is_in_string) { + if (index + 1 === node_options.length) { + errors.push("invalid value for NODE_OPTIONS (invalid escape)\n"); + return env_argv; + } else { + c = node_options[++index]; + } + } else if (c === " " && !is_in_string) { + will_start_new_arg = true; + continue; + } else if (c === '"') { + is_in_string = !is_in_string; + continue; + } + if (will_start_new_arg) { + env_argv.push(c); + will_start_new_arg = false; + } else { + env_argv[env_argv.length - 1] += c; + } + } + if (is_in_string) { + errors.push("invalid value for NODE_OPTIONS (unterminated string)\n"); + } + return env_argv; +} + +function makeApi(runtimeState, opts) { + const alwaysWarnOnFallback = Number(process.env.PNP_ALWAYS_WARN_ON_FALLBACK) > 0; + const debugLevel = Number(process.env.PNP_DEBUG_LEVEL); + const pathRegExp = /^(?![a-zA-Z]:[\\/]|\\\\|\.{0,2}(?:\/|$))((?:node:)?(?:@[^/]+\/)?[^/]+)\/*(.*|)$/; + const isStrictRegExp = /^(\/|\.{1,2}(\/|$))/; + const isDirRegExp = /\/$/; + const isRelativeRegexp = /^\.{0,2}\//; + const topLevelLocator = { name: null, reference: null }; + const fallbackLocators = []; + const emittedWarnings = /* @__PURE__ */ new Set(); + if (runtimeState.enableTopLevelFallback === true) + fallbackLocators.push(topLevelLocator); + if (opts.compatibilityMode !== false) { + for (const name of [`react-scripts`, `gatsby`]) { + const packageStore = runtimeState.packageRegistry.get(name); + if (packageStore) { + for (const reference of packageStore.keys()) { + if (reference === null) { + throw new Error(`Assertion failed: This reference shouldn't be null`); + } else { + fallbackLocators.push({ name, reference }); + } + } + } + } + } + const { + ignorePattern, + packageRegistry, + packageLocatorsByLocations + } = runtimeState; + function makeLogEntry(name, args) { + return { + fn: name, + args, + error: null, + result: null + }; + } + function trace(entry) { + const colors = process.stderr?.hasColors?.() ?? process.stdout.isTTY; + const c = (n, str) => `\x1B[${n}m${str}\x1B[0m`; + const error = entry.error; + if (error) + console.error(c(`31;1`, `\u2716 ${entry.error?.message.replace(/\n.*/s, ``)}`)); + else + console.error(c(`33;1`, `\u203C Resolution`)); + if (entry.args.length > 0) + console.error(); + for (const arg of entry.args) + console.error(` ${c(`37;1`, `In \u2190`)} ${nodeUtils.inspect(arg, { colors, compact: true })}`); + if (entry.result) { + console.error(); + console.error(` ${c(`37;1`, `Out \u2192`)} ${nodeUtils.inspect(entry.result, { colors, compact: true })}`); + } + const stack = new Error().stack.match(/(?<=^ +)at.*/gm)?.slice(2) ?? []; + if (stack.length > 0) { + console.error(); + for (const line of stack) { + console.error(` ${c(`38;5;244`, line)}`); + } + } + console.error(); + } + function maybeLog(name, fn) { + if (opts.allowDebug === false) + return fn; + if (Number.isFinite(debugLevel)) { + if (debugLevel >= 2) { + return (...args) => { + const logEntry = makeLogEntry(name, args); + try { + return logEntry.result = fn(...args); + } catch (error) { + throw logEntry.error = error; + } finally { + trace(logEntry); + } + }; + } else if (debugLevel >= 1) { + return (...args) => { + try { + return fn(...args); + } catch (error) { + const logEntry = makeLogEntry(name, args); + logEntry.error = error; + trace(logEntry); + throw error; + } + }; + } + } + return fn; + } + function getPackageInformationSafe(packageLocator) { + const packageInformation = getPackageInformation(packageLocator); + if (!packageInformation) { + throw makeError( + ErrorCode.INTERNAL, + `Couldn't find a matching entry in the dependency tree for the specified parent (this is probably an internal error)` + ); + } + return packageInformation; + } + function isDependencyTreeRoot(packageLocator) { + if (packageLocator.name === null) + return true; + for (const dependencyTreeRoot of runtimeState.dependencyTreeRoots) + if (dependencyTreeRoot.name === packageLocator.name && dependencyTreeRoot.reference === packageLocator.reference) + return true; + return false; + } + const defaultExportsConditions = /* @__PURE__ */ new Set([ + `node`, + `require`, + ...getOptionValue(`--conditions`) + ]); + function applyNodeExportsResolution(unqualifiedPath, conditions = defaultExportsConditions, issuer) { + const locator = findPackageLocator(ppath.join(unqualifiedPath, `internal.js`), { + resolveIgnored: true, + includeDiscardFromLookup: true + }); + if (locator === null) { + throw makeError( + ErrorCode.INTERNAL, + `The locator that owns the "${unqualifiedPath}" path can't be found inside the dependency tree (this is probably an internal error)` + ); + } + const { packageLocation } = getPackageInformationSafe(locator); + const manifestPath = ppath.join(packageLocation, Filename.manifest); + if (!opts.fakeFs.existsSync(manifestPath)) + return null; + const pkgJson = JSON.parse(opts.fakeFs.readFileSync(manifestPath, `utf8`)); + if (pkgJson.exports == null) + return null; + let subpath = ppath.contains(packageLocation, unqualifiedPath); + if (subpath === null) { + throw makeError( + ErrorCode.INTERNAL, + `unqualifiedPath doesn't contain the packageLocation (this is probably an internal error)` + ); + } + if (subpath !== `.` && !isRelativeRegexp.test(subpath)) + subpath = `./${subpath}`; + try { + const resolvedExport = packageExportsResolve({ + packageJSONUrl: url.pathToFileURL(npath.fromPortablePath(manifestPath)), + packageSubpath: subpath, + exports: pkgJson.exports, + base: issuer ? url.pathToFileURL(npath.fromPortablePath(issuer)) : null, + conditions + }); + return npath.toPortablePath(url.fileURLToPath(resolvedExport)); + } catch (error) { + throw makeError( + ErrorCode.EXPORTS_RESOLUTION_FAILED, + error.message, + { unqualifiedPath: getPathForDisplay(unqualifiedPath), locator, pkgJson, subpath: getPathForDisplay(subpath), conditions }, + error.code + ); + } + } + function applyNodeExtensionResolution(unqualifiedPath, candidates, { extensions }) { + let stat; + try { + candidates.push(unqualifiedPath); + stat = opts.fakeFs.statSync(unqualifiedPath); + } catch { + } + if (stat && !stat.isDirectory()) + return opts.fakeFs.realpathSync(unqualifiedPath); + if (stat && stat.isDirectory()) { + let pkgJson; + try { + pkgJson = JSON.parse(opts.fakeFs.readFileSync(ppath.join(unqualifiedPath, Filename.manifest), `utf8`)); + } catch { + } + let nextUnqualifiedPath; + if (pkgJson && pkgJson.main) + nextUnqualifiedPath = ppath.resolve(unqualifiedPath, pkgJson.main); + if (nextUnqualifiedPath && nextUnqualifiedPath !== unqualifiedPath) { + const resolution = applyNodeExtensionResolution(nextUnqualifiedPath, candidates, { extensions }); + if (resolution !== null) { + return resolution; + } + } + } + for (let i = 0, length = extensions.length; i < length; i++) { + const candidateFile = `${unqualifiedPath}${extensions[i]}`; + candidates.push(candidateFile); + if (opts.fakeFs.existsSync(candidateFile)) { + return candidateFile; + } + } + if (stat && stat.isDirectory()) { + for (let i = 0, length = extensions.length; i < length; i++) { + const candidateFile = ppath.format({ dir: unqualifiedPath, name: `index`, ext: extensions[i] }); + candidates.push(candidateFile); + if (opts.fakeFs.existsSync(candidateFile)) { + return candidateFile; + } + } + } + return null; + } + function makeFakeModule(path) { + const fakeModule = new require$$0.Module(path, null); + fakeModule.filename = path; + fakeModule.paths = require$$0.Module._nodeModulePaths(path); + return fakeModule; + } + function callNativeResolution(request, issuer) { + if (issuer.endsWith(`/`)) + issuer = ppath.join(issuer, `internal.js`); + return require$$0.Module._resolveFilename(npath.fromPortablePath(request), makeFakeModule(npath.fromPortablePath(issuer)), false, { plugnplay: false }); + } + function isPathIgnored(path) { + if (ignorePattern === null) + return false; + const subPath = ppath.contains(runtimeState.basePath, path); + if (subPath === null) + return false; + if (ignorePattern.test(subPath.replace(/\/$/, ``))) { + return true; + } else { + return false; + } + } + const VERSIONS = { std: 3, resolveVirtual: 1, getAllLocators: 1 }; + const topLevel = topLevelLocator; + function getPackageInformation({ name, reference }) { + const packageInformationStore = packageRegistry.get(name); + if (!packageInformationStore) + return null; + const packageInformation = packageInformationStore.get(reference); + if (!packageInformation) + return null; + return packageInformation; + } + function findPackageDependents({ name, reference }) { + const dependents = []; + for (const [dependentName, packageInformationStore] of packageRegistry) { + if (dependentName === null) + continue; + for (const [dependentReference, packageInformation] of packageInformationStore) { + if (dependentReference === null) + continue; + const dependencyReference = packageInformation.packageDependencies.get(name); + if (dependencyReference !== reference) + continue; + if (dependentName === name && dependentReference === reference) + continue; + dependents.push({ + name: dependentName, + reference: dependentReference + }); + } + } + return dependents; + } + function findBrokenPeerDependencies(dependency, initialPackage) { + const brokenPackages = /* @__PURE__ */ new Map(); + const alreadyVisited = /* @__PURE__ */ new Set(); + const traversal = (currentPackage) => { + const identifier = JSON.stringify(currentPackage.name); + if (alreadyVisited.has(identifier)) + return; + alreadyVisited.add(identifier); + const dependents = findPackageDependents(currentPackage); + for (const dependent of dependents) { + const dependentInformation = getPackageInformationSafe(dependent); + if (dependentInformation.packagePeers.has(dependency)) { + traversal(dependent); + } else { + let brokenSet = brokenPackages.get(dependent.name); + if (typeof brokenSet === `undefined`) + brokenPackages.set(dependent.name, brokenSet = /* @__PURE__ */ new Set()); + brokenSet.add(dependent.reference); + } + } + }; + traversal(initialPackage); + const brokenList = []; + for (const name of [...brokenPackages.keys()].sort()) + for (const reference of [...brokenPackages.get(name)].sort()) + brokenList.push({ name, reference }); + return brokenList; + } + function findPackageLocator(location, { resolveIgnored = false, includeDiscardFromLookup = false } = {}) { + if (isPathIgnored(location) && !resolveIgnored) + return null; + let relativeLocation = ppath.relative(runtimeState.basePath, location); + if (!relativeLocation.match(isStrictRegExp)) + relativeLocation = `./${relativeLocation}`; + if (!relativeLocation.endsWith(`/`)) + relativeLocation = `${relativeLocation}/`; + do { + const entry = packageLocatorsByLocations.get(relativeLocation); + if (typeof entry === `undefined` || entry.discardFromLookup && !includeDiscardFromLookup) { + relativeLocation = relativeLocation.substring(0, relativeLocation.lastIndexOf(`/`, relativeLocation.length - 2) + 1); + continue; + } + return entry.locator; + } while (relativeLocation !== ``); + return null; + } + function tryReadFile(filePath) { + try { + return opts.fakeFs.readFileSync(npath.toPortablePath(filePath), `utf8`); + } catch (err) { + if (err.code === `ENOENT`) + return void 0; + throw err; + } + } + function resolveToUnqualified(request, issuer, { considerBuiltins = true } = {}) { + if (request.startsWith(`#`)) + throw new Error(`resolveToUnqualified can not handle private import mappings`); + if (request === `pnpapi`) + return npath.toPortablePath(opts.pnpapiResolution); + if (considerBuiltins && require$$0.isBuiltin(request)) + return null; + const requestForDisplay = getPathForDisplay(request); + const issuerForDisplay = issuer && getPathForDisplay(issuer); + if (issuer && isPathIgnored(issuer)) { + if (!ppath.isAbsolute(request) || findPackageLocator(request) === null) { + const result = callNativeResolution(request, issuer); + if (result === false) { + throw makeError( + ErrorCode.BUILTIN_NODE_RESOLUTION_FAILED, + `The builtin node resolution algorithm was unable to resolve the requested module (it didn't go through the pnp resolver because the issuer was explicitely ignored by the regexp) + +Require request: "${requestForDisplay}" +Required by: ${issuerForDisplay} +`, + { request: requestForDisplay, issuer: issuerForDisplay } + ); + } + return npath.toPortablePath(result); + } + } + let unqualifiedPath; + const dependencyNameMatch = request.match(pathRegExp); + if (!dependencyNameMatch) { + if (ppath.isAbsolute(request)) { + unqualifiedPath = ppath.normalize(request); + } else { + if (!issuer) { + throw makeError( + ErrorCode.API_ERROR, + `The resolveToUnqualified function must be called with a valid issuer when the path isn't a builtin nor absolute`, + { request: requestForDisplay, issuer: issuerForDisplay } + ); + } + const absoluteIssuer = ppath.resolve(issuer); + if (issuer.match(isDirRegExp)) { + unqualifiedPath = ppath.normalize(ppath.join(absoluteIssuer, request)); + } else { + unqualifiedPath = ppath.normalize(ppath.join(ppath.dirname(absoluteIssuer), request)); + } + } + } else { + if (!issuer) { + throw makeError( + ErrorCode.API_ERROR, + `The resolveToUnqualified function must be called with a valid issuer when the path isn't a builtin nor absolute`, + { request: requestForDisplay, issuer: issuerForDisplay } + ); + } + const [, dependencyName, subPath] = dependencyNameMatch; + const issuerLocator = findPackageLocator(issuer); + if (!issuerLocator) { + const result = callNativeResolution(request, issuer); + if (result === false) { + throw makeError( + ErrorCode.BUILTIN_NODE_RESOLUTION_FAILED, + `The builtin node resolution algorithm was unable to resolve the requested module (it didn't go through the pnp resolver because the issuer doesn't seem to be part of the Yarn-managed dependency tree). + +Require path: "${requestForDisplay}" +Required by: ${issuerForDisplay} +`, + { request: requestForDisplay, issuer: issuerForDisplay } + ); + } + return npath.toPortablePath(result); + } + const issuerInformation = getPackageInformationSafe(issuerLocator); + let dependencyReference = issuerInformation.packageDependencies.get(dependencyName); + let fallbackReference = null; + if (dependencyReference == null) { + if (issuerLocator.name !== null) { + const exclusionEntry = runtimeState.fallbackExclusionList.get(issuerLocator.name); + const canUseFallbacks = !exclusionEntry || !exclusionEntry.has(issuerLocator.reference); + if (canUseFallbacks) { + for (let t = 0, T = fallbackLocators.length; t < T; ++t) { + const fallbackInformation = getPackageInformationSafe(fallbackLocators[t]); + const reference = fallbackInformation.packageDependencies.get(dependencyName); + if (reference == null) + continue; + if (alwaysWarnOnFallback) + fallbackReference = reference; + else + dependencyReference = reference; + break; + } + if (runtimeState.enableTopLevelFallback) { + if (dependencyReference == null && fallbackReference === null) { + const reference = runtimeState.fallbackPool.get(dependencyName); + if (reference != null) { + fallbackReference = reference; + } + } + } + } + } + } + let error = null; + if (dependencyReference === null) { + if (isDependencyTreeRoot(issuerLocator)) { + error = makeError( + ErrorCode.MISSING_PEER_DEPENDENCY, + `Your application tried to access ${dependencyName} (a peer dependency); this isn't allowed as there is no ancestor to satisfy the requirement. Use a devDependency if needed. + +Required package: ${dependencyName}${dependencyName !== requestForDisplay ? ` (via "${requestForDisplay}")` : ``} +Required by: ${issuerForDisplay} +`, + { request: requestForDisplay, issuer: issuerForDisplay, dependencyName } + ); + } else { + const brokenAncestors = findBrokenPeerDependencies(dependencyName, issuerLocator); + if (brokenAncestors.every((ancestor) => isDependencyTreeRoot(ancestor))) { + error = makeError( + ErrorCode.MISSING_PEER_DEPENDENCY, + `${issuerLocator.name} tried to access ${dependencyName} (a peer dependency) but it isn't provided by your application; this makes the require call ambiguous and unsound. + +Required package: ${dependencyName}${dependencyName !== requestForDisplay ? ` (via "${requestForDisplay}")` : ``} +Required by: ${issuerLocator.name}@${issuerLocator.reference} (via ${issuerForDisplay}) +${brokenAncestors.map((ancestorLocator) => `Ancestor breaking the chain: ${ancestorLocator.name}@${ancestorLocator.reference} +`).join(``)} +`, + { request: requestForDisplay, issuer: issuerForDisplay, issuerLocator: Object.assign({}, issuerLocator), dependencyName, brokenAncestors } + ); + } else { + error = makeError( + ErrorCode.MISSING_PEER_DEPENDENCY, + `${issuerLocator.name} tried to access ${dependencyName} (a peer dependency) but it isn't provided by its ancestors; this makes the require call ambiguous and unsound. + +Required package: ${dependencyName}${dependencyName !== requestForDisplay ? ` (via "${requestForDisplay}")` : ``} +Required by: ${issuerLocator.name}@${issuerLocator.reference} (via ${issuerForDisplay}) + +${brokenAncestors.map((ancestorLocator) => `Ancestor breaking the chain: ${ancestorLocator.name}@${ancestorLocator.reference} +`).join(``)} +`, + { request: requestForDisplay, issuer: issuerForDisplay, issuerLocator: Object.assign({}, issuerLocator), dependencyName, brokenAncestors } + ); + } + } + } else if (dependencyReference === void 0) { + if (!considerBuiltins && require$$0.isBuiltin(request)) { + if (isDependencyTreeRoot(issuerLocator)) { + error = makeError( + ErrorCode.UNDECLARED_DEPENDENCY, + `Your application tried to access ${dependencyName}. While this module is usually interpreted as a Node builtin, your resolver is running inside a non-Node resolution context where such builtins are ignored. Since ${dependencyName} isn't otherwise declared in your dependencies, this makes the require call ambiguous and unsound. + +Required package: ${dependencyName}${dependencyName !== requestForDisplay ? ` (via "${requestForDisplay}")` : ``} +Required by: ${issuerForDisplay} +`, + { request: requestForDisplay, issuer: issuerForDisplay, dependencyName } + ); + } else { + error = makeError( + ErrorCode.UNDECLARED_DEPENDENCY, + `${issuerLocator.name} tried to access ${dependencyName}. While this module is usually interpreted as a Node builtin, your resolver is running inside a non-Node resolution context where such builtins are ignored. Since ${dependencyName} isn't otherwise declared in ${issuerLocator.name}'s dependencies, this makes the require call ambiguous and unsound. + +Required package: ${dependencyName}${dependencyName !== requestForDisplay ? ` (via "${requestForDisplay}")` : ``} +Required by: ${issuerForDisplay} +`, + { request: requestForDisplay, issuer: issuerForDisplay, issuerLocator: Object.assign({}, issuerLocator), dependencyName } + ); + } + } else { + if (isDependencyTreeRoot(issuerLocator)) { + error = makeError( + ErrorCode.UNDECLARED_DEPENDENCY, + `Your application tried to access ${dependencyName}, but it isn't declared in your dependencies; this makes the require call ambiguous and unsound. + +Required package: ${dependencyName}${dependencyName !== requestForDisplay ? ` (via "${requestForDisplay}")` : ``} +Required by: ${issuerForDisplay} +`, + { request: requestForDisplay, issuer: issuerForDisplay, dependencyName } + ); + } else { + error = makeError( + ErrorCode.UNDECLARED_DEPENDENCY, + `${issuerLocator.name} tried to access ${dependencyName}, but it isn't declared in its dependencies; this makes the require call ambiguous and unsound. + +Required package: ${dependencyName}${dependencyName !== requestForDisplay ? ` (via "${requestForDisplay}")` : ``} +Required by: ${issuerLocator.name}@${issuerLocator.reference} (via ${issuerForDisplay}) +`, + { request: requestForDisplay, issuer: issuerForDisplay, issuerLocator: Object.assign({}, issuerLocator), dependencyName } + ); + } + } + } + if (dependencyReference == null) { + if (fallbackReference === null || error === null) + throw error || new Error(`Assertion failed: Expected an error to have been set`); + dependencyReference = fallbackReference; + const message = error.message.replace(/\n.*/g, ``); + error.message = message; + if (!emittedWarnings.has(message) && debugLevel !== 0) { + emittedWarnings.add(message); + process.emitWarning(error); + } + } + const dependencyLocator = Array.isArray(dependencyReference) ? { name: dependencyReference[0], reference: dependencyReference[1] } : { name: dependencyName, reference: dependencyReference }; + const dependencyInformation = getPackageInformationSafe(dependencyLocator); + if (!dependencyInformation.packageLocation) { + throw makeError( + ErrorCode.MISSING_DEPENDENCY, + `A dependency seems valid but didn't get installed for some reason. This might be caused by a partial install, such as dev vs prod. + +Required package: ${dependencyLocator.name}@${dependencyLocator.reference}${dependencyLocator.name !== requestForDisplay ? ` (via "${requestForDisplay}")` : ``} +Required by: ${issuerLocator.name}@${issuerLocator.reference} (via ${issuerForDisplay}) +`, + { request: requestForDisplay, issuer: issuerForDisplay, dependencyLocator: Object.assign({}, dependencyLocator) } + ); + } + const dependencyLocation = dependencyInformation.packageLocation; + if (subPath) { + unqualifiedPath = ppath.join(dependencyLocation, subPath); + } else { + unqualifiedPath = dependencyLocation; + } + } + return ppath.normalize(unqualifiedPath); + } + function resolveUnqualifiedExport(request, unqualifiedPath, conditions = defaultExportsConditions, issuer) { + if (isStrictRegExp.test(request)) + return unqualifiedPath; + const unqualifiedExportPath = applyNodeExportsResolution(unqualifiedPath, conditions, issuer); + if (unqualifiedExportPath) { + return ppath.normalize(unqualifiedExportPath); + } else { + return unqualifiedPath; + } + } + function resolveUnqualified(unqualifiedPath, { extensions = Object.keys(require$$0.Module._extensions) } = {}) { + const candidates = []; + const qualifiedPath = applyNodeExtensionResolution(unqualifiedPath, candidates, { extensions }); + if (qualifiedPath) { + reportRequiredFilesToWatchMode([qualifiedPath]); + return ppath.normalize(qualifiedPath); + } else { + reportRequiredFilesToWatchMode(candidates); + const unqualifiedPathForDisplay = getPathForDisplay(unqualifiedPath); + const containingPackage = findPackageLocator(unqualifiedPath); + if (containingPackage) { + const { packageLocation } = getPackageInformationSafe(containingPackage); + let exists = true; + try { + opts.fakeFs.accessSync(packageLocation); + } catch (err) { + if (err?.code === `ENOENT`) { + exists = false; + } else { + const readableError = (err?.message ?? err ?? `empty exception thrown`).replace(/^[A-Z]/, ($0) => $0.toLowerCase()); + throw makeError(ErrorCode.QUALIFIED_PATH_RESOLUTION_FAILED, `Required package exists but could not be accessed (${readableError}). + +Missing package: ${containingPackage.name}@${containingPackage.reference} +Expected package location: ${getPathForDisplay(packageLocation)} +`, { unqualifiedPath: unqualifiedPathForDisplay, extensions }); + } + } + if (!exists) { + const errorMessage = packageLocation.includes(`/unplugged/`) ? `Required unplugged package missing from disk. This may happen when switching branches without running installs (unplugged packages must be fully materialized on disk to work).` : `Required package missing from disk. If you keep your packages inside your repository then restarting the Node process may be enough. Otherwise, try to run an install first.`; + throw makeError( + ErrorCode.QUALIFIED_PATH_RESOLUTION_FAILED, + `${errorMessage} + +Missing package: ${containingPackage.name}@${containingPackage.reference} +Expected package location: ${getPathForDisplay(packageLocation)} +`, + { unqualifiedPath: unqualifiedPathForDisplay, extensions } + ); + } + } + throw makeError( + ErrorCode.QUALIFIED_PATH_RESOLUTION_FAILED, + `Qualified path resolution failed: we looked for the following paths, but none could be accessed. + +Source path: ${unqualifiedPathForDisplay} +${candidates.map((candidate) => `Not found: ${getPathForDisplay(candidate)} +`).join(``)}`, + { unqualifiedPath: unqualifiedPathForDisplay, extensions } + ); + } + } + function resolvePrivateRequest(request, issuer, opts2) { + if (!issuer) + throw new Error(`Assertion failed: An issuer is required to resolve private import mappings`); + const resolved = packageImportsResolve({ + name: request, + base: url.pathToFileURL(npath.fromPortablePath(issuer)), + conditions: opts2.conditions ?? defaultExportsConditions, + readFileSyncFn: tryReadFile + }); + if (resolved instanceof URL) { + return resolveUnqualified(npath.toPortablePath(url.fileURLToPath(resolved)), { extensions: opts2.extensions }); + } else { + if (resolved.startsWith(`#`)) + throw new Error(`Mapping from one private import to another isn't allowed`); + return resolveRequest(resolved, issuer, opts2); + } + } + function resolveRequest(request, issuer, opts2 = {}) { + try { + if (request.startsWith(`#`)) + return resolvePrivateRequest(request, issuer, opts2); + const { considerBuiltins, extensions, conditions } = opts2; + const unqualifiedPath = resolveToUnqualified(request, issuer, { considerBuiltins }); + if (request === `pnpapi`) + return unqualifiedPath; + if (unqualifiedPath === null) + return null; + const isIssuerIgnored = () => issuer !== null ? isPathIgnored(issuer) : false; + const remappedPath = (!considerBuiltins || !require$$0.isBuiltin(request)) && !isIssuerIgnored() ? resolveUnqualifiedExport(request, unqualifiedPath, conditions, issuer) : unqualifiedPath; + return resolveUnqualified(remappedPath, { extensions }); + } catch (error) { + if (Object.hasOwn(error, `pnpCode`)) + Object.assign(error.data, { request: getPathForDisplay(request), issuer: issuer && getPathForDisplay(issuer) }); + throw error; + } + } + function resolveVirtual(request) { + const normalized = ppath.normalize(request); + const resolved = VirtualFS.resolveVirtual(normalized); + return resolved !== normalized ? resolved : null; + } + return { + VERSIONS, + topLevel, + getLocator: (name, referencish) => { + if (Array.isArray(referencish)) { + return { name: referencish[0], reference: referencish[1] }; + } else { + return { name, reference: referencish }; + } + }, + getDependencyTreeRoots: () => { + return [...runtimeState.dependencyTreeRoots]; + }, + getAllLocators() { + const locators = []; + for (const [name, entry] of packageRegistry) + for (const reference of entry.keys()) + if (name !== null && reference !== null) + locators.push({ name, reference }); + return locators; + }, + getPackageInformation: (locator) => { + const info = getPackageInformation(locator); + if (info === null) + return null; + const packageLocation = npath.fromPortablePath(info.packageLocation); + const nativeInfo = { ...info, packageLocation }; + return nativeInfo; + }, + findPackageLocator: (path) => { + return findPackageLocator(npath.toPortablePath(path)); + }, + resolveToUnqualified: maybeLog(`resolveToUnqualified`, (request, issuer, opts2) => { + const portableIssuer = issuer !== null ? npath.toPortablePath(issuer) : null; + const resolution = resolveToUnqualified(npath.toPortablePath(request), portableIssuer, opts2); + if (resolution === null) + return null; + return npath.fromPortablePath(resolution); + }), + resolveUnqualified: maybeLog(`resolveUnqualified`, (unqualifiedPath, opts2) => { + return npath.fromPortablePath(resolveUnqualified(npath.toPortablePath(unqualifiedPath), opts2)); + }), + resolveRequest: maybeLog(`resolveRequest`, (request, issuer, opts2) => { + const portableIssuer = issuer !== null ? npath.toPortablePath(issuer) : null; + const resolution = resolveRequest(npath.toPortablePath(request), portableIssuer, opts2); + if (resolution === null) + return null; + return npath.fromPortablePath(resolution); + }), + resolveVirtual: maybeLog(`resolveVirtual`, (path) => { + const result = resolveVirtual(npath.toPortablePath(path)); + if (result !== null) { + return npath.fromPortablePath(result); + } else { + return null; + } + }) + }; +} + +function makeManager(pnpapi, opts) { + const initialApiPath = npath.toPortablePath(pnpapi.resolveToUnqualified(`pnpapi`, null)); + const initialApiStats = opts.fakeFs.statSync(npath.toPortablePath(initialApiPath)); + const apiMetadata = /* @__PURE__ */ new Map([ + [initialApiPath, { + instance: pnpapi, + stats: initialApiStats, + lastRefreshCheck: Date.now() + }] + ]); + function loadApiInstance(pnpApiPath) { + const nativePath = npath.fromPortablePath(pnpApiPath); + const module = new require$$0.Module(nativePath, null); + module.load(nativePath); + return module.exports; + } + function refreshApiEntry(pnpApiPath, apiEntry) { + const timeNow = Date.now(); + if (timeNow - apiEntry.lastRefreshCheck < 500) + return; + apiEntry.lastRefreshCheck = timeNow; + const stats = opts.fakeFs.statSync(pnpApiPath); + if (stats.mtime > apiEntry.stats.mtime) { + process.emitWarning(`[Warning] The runtime detected new information in a PnP file; reloading the API instance (${npath.fromPortablePath(pnpApiPath)})`); + apiEntry.stats = stats; + apiEntry.instance = loadApiInstance(pnpApiPath); + } + } + function getApiEntry(pnpApiPath, refresh = false) { + let apiEntry = apiMetadata.get(pnpApiPath); + if (typeof apiEntry !== `undefined`) { + if (refresh) { + refreshApiEntry(pnpApiPath, apiEntry); + } + } else { + apiMetadata.set(pnpApiPath, apiEntry = { + instance: loadApiInstance(pnpApiPath), + stats: opts.fakeFs.statSync(pnpApiPath), + lastRefreshCheck: Date.now() + }); + } + return apiEntry; + } + const findApiPathCache = /* @__PURE__ */ new Map(); + function addToCacheAndReturn(start, end, target) { + if (target !== null) { + target = VirtualFS.resolveVirtual(target); + target = opts.fakeFs.realpathSync(target); + } + let curr; + let next = start; + do { + curr = next; + findApiPathCache.set(curr, target); + next = ppath.dirname(curr); + } while (curr !== end); + return target; + } + function findApiPathFor(modulePath) { + let bestCandidate = null; + for (const [apiPath, apiEntry] of apiMetadata) { + const locator = apiEntry.instance.findPackageLocator(modulePath); + if (!locator) + continue; + if (apiMetadata.size === 1) + return apiPath; + const packageInformation = apiEntry.instance.getPackageInformation(locator); + if (!packageInformation) + throw new Error(`Assertion failed: Couldn't get package information for '${modulePath}'`); + if (!bestCandidate) + bestCandidate = { packageLocation: packageInformation.packageLocation, apiPaths: [] }; + if (packageInformation.packageLocation === bestCandidate.packageLocation) { + bestCandidate.apiPaths.push(apiPath); + } else if (packageInformation.packageLocation.length > bestCandidate.packageLocation.length) { + bestCandidate = { packageLocation: packageInformation.packageLocation, apiPaths: [apiPath] }; + } + } + if (bestCandidate) { + if (bestCandidate.apiPaths.length === 1) + return bestCandidate.apiPaths[0]; + const controlSegment = bestCandidate.apiPaths.map((apiPath) => ` ${npath.fromPortablePath(apiPath)}`).join(` +`); + throw new Error(`Unable to locate pnpapi, the module '${modulePath}' is controlled by multiple pnpapi instances. +This is usually caused by using the global cache (enableGlobalCache: true) + +Controlled by: +${controlSegment} +`); + } + const start = ppath.resolve(npath.toPortablePath(modulePath)); + let curr; + let next = start; + do { + curr = next; + const cached = findApiPathCache.get(curr); + if (cached !== void 0) + return addToCacheAndReturn(start, curr, cached); + const cjsCandidate = ppath.join(curr, Filename.pnpCjs); + if (opts.fakeFs.existsSync(cjsCandidate) && opts.fakeFs.statSync(cjsCandidate).isFile()) + return addToCacheAndReturn(start, curr, cjsCandidate); + const legacyCjsCandidate = ppath.join(curr, Filename.pnpJs); + if (opts.fakeFs.existsSync(legacyCjsCandidate) && opts.fakeFs.statSync(legacyCjsCandidate).isFile()) + return addToCacheAndReturn(start, curr, legacyCjsCandidate); + next = ppath.dirname(curr); + } while (curr !== PortablePath.root); + return addToCacheAndReturn(start, curr, null); + } + const moduleToApiPathCache = /* @__PURE__ */ new WeakMap(); + function getApiPathFromParent(parent) { + if (parent == null) + return initialApiPath; + let apiPath = moduleToApiPathCache.get(parent); + if (typeof apiPath !== `undefined`) + return apiPath; + apiPath = parent.filename ? findApiPathFor(parent.filename) : null; + moduleToApiPathCache.set(parent, apiPath); + return apiPath; + } + return { + getApiPathFromParent, + findApiPathFor, + getApiEntry + }; +} + +const localFs = { ...fs__default.default }; +const nodeFs = new NodeFS(localFs); +const defaultRuntimeState = $$SETUP_STATE(hydrateRuntimeState); +const defaultPnpapiResolution = __filename; +const customZipImplementation = defaultRuntimeState.pnpZipBackend === `js` ? JsZipImpl : void 0; +const defaultFsLayer = new VirtualFS({ + baseFs: new ZipOpenFS({ + customZipImplementation, + baseFs: nodeFs, + maxOpenFiles: 80, + readOnlyArchives: true + }) +}); +class DynamicFS extends ProxiedFS { + baseFs = defaultFsLayer; + constructor() { + super(ppath); + } + mapToBase(p) { + return p; + } + mapFromBase(p) { + return p; + } +} +const dynamicFsLayer = new DynamicFS(); +let manager; +const defaultApi = Object.assign(makeApi(defaultRuntimeState, { + fakeFs: dynamicFsLayer, + pnpapiResolution: defaultPnpapiResolution +}), { + /** + * Can be used to generate a different API than the default one (for example + * to map it on `/` rather than the local directory path, or to use a + * different FS layer than the default one). + */ + makeApi: ({ + basePath = void 0, + fakeFs = dynamicFsLayer, + pnpapiResolution = defaultPnpapiResolution, + ...rest + }) => { + const apiRuntimeState = typeof basePath !== `undefined` ? $$SETUP_STATE(hydrateRuntimeState, basePath) : defaultRuntimeState; + return makeApi(apiRuntimeState, { + fakeFs, + pnpapiResolution, + ...rest + }); + }, + /** + * Will inject the specified API into the environment, monkey-patching FS. Is + * automatically called when the hook is loaded through `--require`. + */ + setup: (api) => { + applyPatch(api || defaultApi, { + fakeFs: defaultFsLayer, + manager + }); + dynamicFsLayer.baseFs = new NodeFS(fs__default.default); + } +}); +manager = makeManager(defaultApi, { + fakeFs: dynamicFsLayer +}); +if (module.parent && module.parent.id === `internal/preload`) { + defaultApi.setup(); + if (module.filename) { + delete require$$0__default.default._cache[module.filename]; + } +} +if (process.mainModule === module) { + const reportError = (code, message, data) => { + process.stdout.write(`${JSON.stringify([{ code, message, data }, null])} +`); + }; + const reportSuccess = (resolution) => { + process.stdout.write(`${JSON.stringify([null, resolution])} +`); + }; + const processResolution = (request, issuer) => { + try { + reportSuccess(defaultApi.resolveRequest(request, issuer)); + } catch (error) { + reportError(error.code, error.message, error.data); + } + }; + const processRequest = (data) => { + try { + const [request, issuer] = JSON.parse(data); + processResolution(request, issuer); + } catch (error) { + reportError(`INVALID_JSON`, error.message, error.data); + } + }; + if (process.argv.length > 2) { + if (process.argv.length !== 4) { + process.stderr.write(`Usage: ${process.argv[0]} ${process.argv[1]} +`); + process.exitCode = 64; + } else { + processResolution(process.argv[2], process.argv[3]); + } + } else { + let buffer = ``; + const decoder = new StringDecoder__default.default.StringDecoder(); + process.stdin.on(`data`, (chunk) => { + buffer += decoder.write(chunk); + do { + const index = buffer.indexOf(` +`); + if (index === -1) + break; + const line = buffer.slice(0, index); + buffer = buffer.slice(index + 1); + processRequest(line); + } while (true); + }); + } +} + +module.exports = defaultApi; diff --git a/.pnp.loader.mjs b/.pnp.loader.mjs new file mode 100644 index 0000000..15cab03 --- /dev/null +++ b/.pnp.loader.mjs @@ -0,0 +1,2129 @@ +/* eslint-disable */ +// @ts-nocheck + +import fs from 'fs'; +import { URL as URL$1, fileURLToPath, pathToFileURL } from 'url'; +import path from 'path'; +import { createHash } from 'crypto'; +import { EOL } from 'os'; +import esmModule, { createRequire, isBuiltin } from 'module'; +import assert from 'assert'; + +const SAFE_TIME = 456789e3; + +const PortablePath = { + root: `/`, + dot: `.`, + parent: `..` +}; +const npath = Object.create(path); +const ppath = Object.create(path.posix); +npath.cwd = () => process.cwd(); +ppath.cwd = process.platform === `win32` ? () => toPortablePath(process.cwd()) : process.cwd; +if (process.platform === `win32`) { + ppath.resolve = (...segments) => { + if (segments.length > 0 && ppath.isAbsolute(segments[0])) { + return path.posix.resolve(...segments); + } else { + return path.posix.resolve(ppath.cwd(), ...segments); + } + }; +} +const contains = function(pathUtils, from, to) { + from = pathUtils.normalize(from); + to = pathUtils.normalize(to); + if (from === to) + return `.`; + if (!from.endsWith(pathUtils.sep)) + from = from + pathUtils.sep; + if (to.startsWith(from)) { + return to.slice(from.length); + } else { + return null; + } +}; +npath.contains = (from, to) => contains(npath, from, to); +ppath.contains = (from, to) => contains(ppath, from, to); +const WINDOWS_PATH_REGEXP = /^([a-zA-Z]:.*)$/; +const UNC_WINDOWS_PATH_REGEXP = /^\/\/(\.\/)?(.*)$/; +const PORTABLE_PATH_REGEXP = /^\/([a-zA-Z]:.*)$/; +const UNC_PORTABLE_PATH_REGEXP = /^\/unc\/(\.dot\/)?(.*)$/; +function fromPortablePathWin32(p) { + let portablePathMatch, uncPortablePathMatch; + if (portablePathMatch = p.match(PORTABLE_PATH_REGEXP)) + p = portablePathMatch[1]; + else if (uncPortablePathMatch = p.match(UNC_PORTABLE_PATH_REGEXP)) + p = `\\\\${uncPortablePathMatch[1] ? `.\\` : ``}${uncPortablePathMatch[2]}`; + else + return p; + return p.replace(/\//g, `\\`); +} +function toPortablePathWin32(p) { + p = p.replace(/\\/g, `/`); + let windowsPathMatch, uncWindowsPathMatch; + if (windowsPathMatch = p.match(WINDOWS_PATH_REGEXP)) + p = `/${windowsPathMatch[1]}`; + else if (uncWindowsPathMatch = p.match(UNC_WINDOWS_PATH_REGEXP)) + p = `/unc/${uncWindowsPathMatch[1] ? `.dot/` : ``}${uncWindowsPathMatch[2]}`; + return p; +} +const toPortablePath = process.platform === `win32` ? toPortablePathWin32 : (p) => p; +const fromPortablePath = process.platform === `win32` ? fromPortablePathWin32 : (p) => p; +npath.fromPortablePath = fromPortablePath; +npath.toPortablePath = toPortablePath; +function convertPath(targetPathUtils, sourcePath) { + return targetPathUtils === npath ? fromPortablePath(sourcePath) : toPortablePath(sourcePath); +} + +const defaultTime = new Date(SAFE_TIME * 1e3); +const defaultTimeMs = defaultTime.getTime(); +async function copyPromise(destinationFs, destination, sourceFs, source, opts) { + const normalizedDestination = destinationFs.pathUtils.normalize(destination); + const normalizedSource = sourceFs.pathUtils.normalize(source); + const prelayout = []; + const postlayout = []; + const { atime, mtime } = opts.stableTime ? { atime: defaultTime, mtime: defaultTime } : await sourceFs.lstatPromise(normalizedSource); + await destinationFs.mkdirpPromise(destinationFs.pathUtils.dirname(destination), { utimes: [atime, mtime] }); + await copyImpl(prelayout, postlayout, destinationFs, normalizedDestination, sourceFs, normalizedSource, { ...opts, didParentExist: true }); + for (const operation of prelayout) + await operation(); + await Promise.all(postlayout.map((operation) => { + return operation(); + })); +} +async function copyImpl(prelayout, postlayout, destinationFs, destination, sourceFs, source, opts) { + const destinationStat = opts.didParentExist ? await maybeLStat(destinationFs, destination) : null; + const sourceStat = await sourceFs.lstatPromise(source); + const { atime, mtime } = opts.stableTime ? { atime: defaultTime, mtime: defaultTime } : sourceStat; + let updated; + switch (true) { + case sourceStat.isDirectory(): + { + updated = await copyFolder(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts); + } + break; + case sourceStat.isFile(): + { + updated = await copyFile(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts); + } + break; + case sourceStat.isSymbolicLink(): + { + updated = await copySymlink(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts); + } + break; + default: { + throw new Error(`Unsupported file type (${sourceStat.mode})`); + } + } + if (opts.linkStrategy?.type !== `HardlinkFromIndex` || !sourceStat.isFile()) { + if (updated || destinationStat?.mtime?.getTime() !== mtime.getTime() || destinationStat?.atime?.getTime() !== atime.getTime()) { + postlayout.push(() => destinationFs.lutimesPromise(destination, atime, mtime)); + updated = true; + } + if (destinationStat === null || (destinationStat.mode & 511) !== (sourceStat.mode & 511)) { + postlayout.push(() => destinationFs.chmodPromise(destination, sourceStat.mode & 511)); + updated = true; + } + } + return updated; +} +async function maybeLStat(baseFs, p) { + try { + return await baseFs.lstatPromise(p); + } catch { + return null; + } +} +async function copyFolder(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts) { + if (destinationStat !== null && !destinationStat.isDirectory()) { + if (opts.overwrite) { + prelayout.push(async () => destinationFs.removePromise(destination)); + destinationStat = null; + } else { + return false; + } + } + let updated = false; + if (destinationStat === null) { + prelayout.push(async () => { + try { + await destinationFs.mkdirPromise(destination, { mode: sourceStat.mode }); + } catch (err) { + if (err.code !== `EEXIST`) { + throw err; + } + } + }); + updated = true; + } + const entries = await sourceFs.readdirPromise(source); + const nextOpts = opts.didParentExist && !destinationStat ? { ...opts, didParentExist: false } : opts; + if (opts.stableSort) { + for (const entry of entries.sort()) { + if (await copyImpl(prelayout, postlayout, destinationFs, destinationFs.pathUtils.join(destination, entry), sourceFs, sourceFs.pathUtils.join(source, entry), nextOpts)) { + updated = true; + } + } + } else { + const entriesUpdateStatus = await Promise.all(entries.map(async (entry) => { + await copyImpl(prelayout, postlayout, destinationFs, destinationFs.pathUtils.join(destination, entry), sourceFs, sourceFs.pathUtils.join(source, entry), nextOpts); + })); + if (entriesUpdateStatus.some((status) => status)) { + updated = true; + } + } + return updated; +} +async function copyFileViaIndex(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts, linkStrategy) { + const sourceHash = await sourceFs.checksumFilePromise(source, { algorithm: `sha1` }); + const defaultMode = 420; + const sourceMode = sourceStat.mode & 511; + const indexFileName = `${sourceHash}${sourceMode !== defaultMode ? sourceMode.toString(8) : ``}`; + const indexPath = destinationFs.pathUtils.join(linkStrategy.indexPath, sourceHash.slice(0, 2), `${indexFileName}.dat`); + let AtomicBehavior; + ((AtomicBehavior2) => { + AtomicBehavior2[AtomicBehavior2["Lock"] = 0] = "Lock"; + AtomicBehavior2[AtomicBehavior2["Rename"] = 1] = "Rename"; + })(AtomicBehavior || (AtomicBehavior = {})); + let atomicBehavior = 1 /* Rename */; + let indexStat = await maybeLStat(destinationFs, indexPath); + if (destinationStat) { + const isDestinationHardlinkedFromIndex = indexStat && destinationStat.dev === indexStat.dev && destinationStat.ino === indexStat.ino; + const isIndexModified = indexStat?.mtimeMs !== defaultTimeMs; + if (isDestinationHardlinkedFromIndex) { + if (isIndexModified && linkStrategy.autoRepair) { + atomicBehavior = 0 /* Lock */; + indexStat = null; + } + } + if (!isDestinationHardlinkedFromIndex) { + if (opts.overwrite) { + prelayout.push(async () => destinationFs.removePromise(destination)); + destinationStat = null; + } else { + return false; + } + } + } + const tempPath = !indexStat && atomicBehavior === 1 /* Rename */ ? `${indexPath}.${Math.floor(Math.random() * 4294967296).toString(16).padStart(8, `0`)}` : null; + let tempPathCleaned = false; + prelayout.push(async () => { + if (!indexStat) { + if (atomicBehavior === 0 /* Lock */) { + await destinationFs.lockPromise(indexPath, async () => { + const content = await sourceFs.readFilePromise(source); + await destinationFs.writeFilePromise(indexPath, content); + }); + } + if (atomicBehavior === 1 /* Rename */ && tempPath) { + const content = await sourceFs.readFilePromise(source); + await destinationFs.writeFilePromise(tempPath, content); + try { + await destinationFs.linkPromise(tempPath, indexPath); + } catch (err) { + if (err.code === `EEXIST`) { + tempPathCleaned = true; + await destinationFs.unlinkPromise(tempPath); + } else { + throw err; + } + } + } + } + if (!destinationStat) { + await destinationFs.linkPromise(indexPath, destination); + } + }); + postlayout.push(async () => { + if (!indexStat) { + await destinationFs.lutimesPromise(indexPath, defaultTime, defaultTime); + if (sourceMode !== defaultMode) { + await destinationFs.chmodPromise(indexPath, sourceMode); + } + } + if (tempPath && !tempPathCleaned) { + await destinationFs.unlinkPromise(tempPath); + } + }); + return false; +} +async function copyFileDirect(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts) { + if (destinationStat !== null) { + if (opts.overwrite) { + prelayout.push(async () => destinationFs.removePromise(destination)); + destinationStat = null; + } else { + return false; + } + } + prelayout.push(async () => { + const content = await sourceFs.readFilePromise(source); + await destinationFs.writeFilePromise(destination, content); + }); + return true; +} +async function copyFile(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts) { + if (opts.linkStrategy?.type === `HardlinkFromIndex`) { + return copyFileViaIndex(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts, opts.linkStrategy); + } else { + return copyFileDirect(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts); + } +} +async function copySymlink(prelayout, postlayout, destinationFs, destination, destinationStat, sourceFs, source, sourceStat, opts) { + if (destinationStat !== null) { + if (opts.overwrite) { + prelayout.push(async () => destinationFs.removePromise(destination)); + destinationStat = null; + } else { + return false; + } + } + prelayout.push(async () => { + await destinationFs.symlinkPromise(convertPath(destinationFs.pathUtils, await sourceFs.readlinkPromise(source)), destination); + }); + return true; +} + +class FakeFS { + pathUtils; + constructor(pathUtils) { + this.pathUtils = pathUtils; + } + async *genTraversePromise(init, { stableSort = false } = {}) { + const stack = [init]; + while (stack.length > 0) { + const p = stack.shift(); + const entry = await this.lstatPromise(p); + if (entry.isDirectory()) { + const entries = await this.readdirPromise(p); + if (stableSort) { + for (const entry2 of entries.sort()) { + stack.push(this.pathUtils.join(p, entry2)); + } + } else { + throw new Error(`Not supported`); + } + } else { + yield p; + } + } + } + async checksumFilePromise(path, { algorithm = `sha512` } = {}) { + const fd = await this.openPromise(path, `r`); + try { + const CHUNK_SIZE = 65536; + const chunk = Buffer.allocUnsafeSlow(CHUNK_SIZE); + const hash = createHash(algorithm); + let bytesRead = 0; + while ((bytesRead = await this.readPromise(fd, chunk, 0, CHUNK_SIZE)) !== 0) + hash.update(bytesRead === CHUNK_SIZE ? chunk : chunk.slice(0, bytesRead)); + return hash.digest(`hex`); + } finally { + await this.closePromise(fd); + } + } + async removePromise(p, { recursive = true, maxRetries = 5 } = {}) { + let stat; + try { + stat = await this.lstatPromise(p); + } catch (error) { + if (error.code === `ENOENT`) { + return; + } else { + throw error; + } + } + if (stat.isDirectory()) { + if (recursive) { + const entries = await this.readdirPromise(p); + await Promise.all(entries.map((entry) => { + return this.removePromise(this.pathUtils.resolve(p, entry)); + })); + } + for (let t = 0; t <= maxRetries; t++) { + try { + await this.rmdirPromise(p); + break; + } catch (error) { + if (error.code !== `EBUSY` && error.code !== `ENOTEMPTY`) { + throw error; + } else if (t < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, t * 100)); + } + } + } + } else { + await this.unlinkPromise(p); + } + } + removeSync(p, { recursive = true } = {}) { + let stat; + try { + stat = this.lstatSync(p); + } catch (error) { + if (error.code === `ENOENT`) { + return; + } else { + throw error; + } + } + if (stat.isDirectory()) { + if (recursive) + for (const entry of this.readdirSync(p)) + this.removeSync(this.pathUtils.resolve(p, entry)); + this.rmdirSync(p); + } else { + this.unlinkSync(p); + } + } + async mkdirpPromise(p, { chmod, utimes } = {}) { + p = this.resolve(p); + if (p === this.pathUtils.dirname(p)) + return void 0; + const parts = p.split(this.pathUtils.sep); + let createdDirectory; + for (let u = 2; u <= parts.length; ++u) { + const subPath = parts.slice(0, u).join(this.pathUtils.sep); + if (!this.existsSync(subPath)) { + try { + await this.mkdirPromise(subPath); + } catch (error) { + if (error.code === `EEXIST`) { + continue; + } else { + throw error; + } + } + createdDirectory ??= subPath; + if (chmod != null) + await this.chmodPromise(subPath, chmod); + if (utimes != null) { + await this.utimesPromise(subPath, utimes[0], utimes[1]); + } else { + const parentStat = await this.statPromise(this.pathUtils.dirname(subPath)); + await this.utimesPromise(subPath, parentStat.atime, parentStat.mtime); + } + } + } + return createdDirectory; + } + mkdirpSync(p, { chmod, utimes } = {}) { + p = this.resolve(p); + if (p === this.pathUtils.dirname(p)) + return void 0; + const parts = p.split(this.pathUtils.sep); + let createdDirectory; + for (let u = 2; u <= parts.length; ++u) { + const subPath = parts.slice(0, u).join(this.pathUtils.sep); + if (!this.existsSync(subPath)) { + try { + this.mkdirSync(subPath); + } catch (error) { + if (error.code === `EEXIST`) { + continue; + } else { + throw error; + } + } + createdDirectory ??= subPath; + if (chmod != null) + this.chmodSync(subPath, chmod); + if (utimes != null) { + this.utimesSync(subPath, utimes[0], utimes[1]); + } else { + const parentStat = this.statSync(this.pathUtils.dirname(subPath)); + this.utimesSync(subPath, parentStat.atime, parentStat.mtime); + } + } + } + return createdDirectory; + } + async copyPromise(destination, source, { baseFs = this, overwrite = true, stableSort = false, stableTime = false, linkStrategy = null } = {}) { + return await copyPromise(this, destination, baseFs, source, { overwrite, stableSort, stableTime, linkStrategy }); + } + copySync(destination, source, { baseFs = this, overwrite = true } = {}) { + const stat = baseFs.lstatSync(source); + const exists = this.existsSync(destination); + if (stat.isDirectory()) { + this.mkdirpSync(destination); + const directoryListing = baseFs.readdirSync(source); + for (const entry of directoryListing) { + this.copySync(this.pathUtils.join(destination, entry), baseFs.pathUtils.join(source, entry), { baseFs, overwrite }); + } + } else if (stat.isFile()) { + if (!exists || overwrite) { + if (exists) + this.removeSync(destination); + const content = baseFs.readFileSync(source); + this.writeFileSync(destination, content); + } + } else if (stat.isSymbolicLink()) { + if (!exists || overwrite) { + if (exists) + this.removeSync(destination); + const target = baseFs.readlinkSync(source); + this.symlinkSync(convertPath(this.pathUtils, target), destination); + } + } else { + throw new Error(`Unsupported file type (file: ${source}, mode: 0o${stat.mode.toString(8).padStart(6, `0`)})`); + } + const mode = stat.mode & 511; + this.chmodSync(destination, mode); + } + async changeFilePromise(p, content, opts = {}) { + if (Buffer.isBuffer(content)) { + return this.changeFileBufferPromise(p, content, opts); + } else { + return this.changeFileTextPromise(p, content, opts); + } + } + async changeFileBufferPromise(p, content, { mode } = {}) { + let current = Buffer.alloc(0); + try { + current = await this.readFilePromise(p); + } catch { + } + if (Buffer.compare(current, content) === 0) + return; + await this.writeFilePromise(p, content, { mode }); + } + async changeFileTextPromise(p, content, { automaticNewlines, mode } = {}) { + let current = ``; + try { + current = await this.readFilePromise(p, `utf8`); + } catch { + } + const normalizedContent = automaticNewlines ? normalizeLineEndings(current, content) : content; + if (current === normalizedContent) + return; + await this.writeFilePromise(p, normalizedContent, { mode }); + } + changeFileSync(p, content, opts = {}) { + if (Buffer.isBuffer(content)) { + return this.changeFileBufferSync(p, content, opts); + } else { + return this.changeFileTextSync(p, content, opts); + } + } + changeFileBufferSync(p, content, { mode } = {}) { + let current = Buffer.alloc(0); + try { + current = this.readFileSync(p); + } catch { + } + if (Buffer.compare(current, content) === 0) + return; + this.writeFileSync(p, content, { mode }); + } + changeFileTextSync(p, content, { automaticNewlines = false, mode } = {}) { + let current = ``; + try { + current = this.readFileSync(p, `utf8`); + } catch { + } + const normalizedContent = automaticNewlines ? normalizeLineEndings(current, content) : content; + if (current === normalizedContent) + return; + this.writeFileSync(p, normalizedContent, { mode }); + } + async movePromise(fromP, toP) { + try { + await this.renamePromise(fromP, toP); + } catch (error) { + if (error.code === `EXDEV`) { + await this.copyPromise(toP, fromP); + await this.removePromise(fromP); + } else { + throw error; + } + } + } + moveSync(fromP, toP) { + try { + this.renameSync(fromP, toP); + } catch (error) { + if (error.code === `EXDEV`) { + this.copySync(toP, fromP); + this.removeSync(fromP); + } else { + throw error; + } + } + } + async lockPromise(affectedPath, callback) { + const lockPath = `${affectedPath}.flock`; + const interval = 1e3 / 60; + const startTime = Date.now(); + let fd = null; + const isAlive = async () => { + let pid; + try { + [pid] = await this.readJsonPromise(lockPath); + } catch { + return Date.now() - startTime < 500; + } + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }; + while (fd === null) { + try { + fd = await this.openPromise(lockPath, `wx`); + } catch (error) { + if (error.code === `EEXIST`) { + if (!await isAlive()) { + try { + await this.unlinkPromise(lockPath); + continue; + } catch { + } + } + if (Date.now() - startTime < 60 * 1e3) { + await new Promise((resolve) => setTimeout(resolve, interval)); + } else { + throw new Error(`Couldn't acquire a lock in a reasonable time (via ${lockPath})`); + } + } else { + throw error; + } + } + } + await this.writePromise(fd, JSON.stringify([process.pid])); + try { + return await callback(); + } finally { + try { + await this.closePromise(fd); + await this.unlinkPromise(lockPath); + } catch { + } + } + } + async readJsonPromise(p) { + const content = await this.readFilePromise(p, `utf8`); + try { + return JSON.parse(content); + } catch (error) { + error.message += ` (in ${p})`; + throw error; + } + } + readJsonSync(p) { + const content = this.readFileSync(p, `utf8`); + try { + return JSON.parse(content); + } catch (error) { + error.message += ` (in ${p})`; + throw error; + } + } + async writeJsonPromise(p, data, { compact = false } = {}) { + const space = compact ? 0 : 2; + return await this.writeFilePromise(p, `${JSON.stringify(data, null, space)} +`); + } + writeJsonSync(p, data, { compact = false } = {}) { + const space = compact ? 0 : 2; + return this.writeFileSync(p, `${JSON.stringify(data, null, space)} +`); + } + async preserveTimePromise(p, cb) { + const stat = await this.lstatPromise(p); + const result = await cb(); + if (typeof result !== `undefined`) + p = result; + await this.lutimesPromise(p, stat.atime, stat.mtime); + } + async preserveTimeSync(p, cb) { + const stat = this.lstatSync(p); + const result = cb(); + if (typeof result !== `undefined`) + p = result; + this.lutimesSync(p, stat.atime, stat.mtime); + } +} +class BasePortableFakeFS extends FakeFS { + constructor() { + super(ppath); + } +} +function getEndOfLine(content) { + const matches = content.match(/\r?\n/g); + if (matches === null) + return EOL; + const crlf = matches.filter((nl) => nl === `\r +`).length; + const lf = matches.length - crlf; + return crlf > lf ? `\r +` : ` +`; +} +function normalizeLineEndings(originalContent, newContent) { + return newContent.replace(/\r?\n/g, getEndOfLine(originalContent)); +} + +class ProxiedFS extends FakeFS { + getExtractHint(hints) { + return this.baseFs.getExtractHint(hints); + } + resolve(path) { + return this.mapFromBase(this.baseFs.resolve(this.mapToBase(path))); + } + getRealPath() { + return this.mapFromBase(this.baseFs.getRealPath()); + } + async openPromise(p, flags, mode) { + return this.baseFs.openPromise(this.mapToBase(p), flags, mode); + } + openSync(p, flags, mode) { + return this.baseFs.openSync(this.mapToBase(p), flags, mode); + } + async opendirPromise(p, opts) { + return Object.assign(await this.baseFs.opendirPromise(this.mapToBase(p), opts), { path: p }); + } + opendirSync(p, opts) { + return Object.assign(this.baseFs.opendirSync(this.mapToBase(p), opts), { path: p }); + } + async readPromise(fd, buffer, offset, length, position) { + return await this.baseFs.readPromise(fd, buffer, offset, length, position); + } + readSync(fd, buffer, offset, length, position) { + return this.baseFs.readSync(fd, buffer, offset, length, position); + } + async writePromise(fd, buffer, offset, length, position) { + if (typeof buffer === `string`) { + return await this.baseFs.writePromise(fd, buffer, offset); + } else { + return await this.baseFs.writePromise(fd, buffer, offset, length, position); + } + } + writeSync(fd, buffer, offset, length, position) { + if (typeof buffer === `string`) { + return this.baseFs.writeSync(fd, buffer, offset); + } else { + return this.baseFs.writeSync(fd, buffer, offset, length, position); + } + } + async closePromise(fd) { + return this.baseFs.closePromise(fd); + } + closeSync(fd) { + this.baseFs.closeSync(fd); + } + createReadStream(p, opts) { + return this.baseFs.createReadStream(p !== null ? this.mapToBase(p) : p, opts); + } + createWriteStream(p, opts) { + return this.baseFs.createWriteStream(p !== null ? this.mapToBase(p) : p, opts); + } + async realpathPromise(p) { + return this.mapFromBase(await this.baseFs.realpathPromise(this.mapToBase(p))); + } + realpathSync(p) { + return this.mapFromBase(this.baseFs.realpathSync(this.mapToBase(p))); + } + async existsPromise(p) { + return this.baseFs.existsPromise(this.mapToBase(p)); + } + existsSync(p) { + return this.baseFs.existsSync(this.mapToBase(p)); + } + accessSync(p, mode) { + return this.baseFs.accessSync(this.mapToBase(p), mode); + } + async accessPromise(p, mode) { + return this.baseFs.accessPromise(this.mapToBase(p), mode); + } + async statPromise(p, opts) { + return this.baseFs.statPromise(this.mapToBase(p), opts); + } + statSync(p, opts) { + return this.baseFs.statSync(this.mapToBase(p), opts); + } + async fstatPromise(fd, opts) { + return this.baseFs.fstatPromise(fd, opts); + } + fstatSync(fd, opts) { + return this.baseFs.fstatSync(fd, opts); + } + lstatPromise(p, opts) { + return this.baseFs.lstatPromise(this.mapToBase(p), opts); + } + lstatSync(p, opts) { + return this.baseFs.lstatSync(this.mapToBase(p), opts); + } + async fchmodPromise(fd, mask) { + return this.baseFs.fchmodPromise(fd, mask); + } + fchmodSync(fd, mask) { + return this.baseFs.fchmodSync(fd, mask); + } + async chmodPromise(p, mask) { + return this.baseFs.chmodPromise(this.mapToBase(p), mask); + } + chmodSync(p, mask) { + return this.baseFs.chmodSync(this.mapToBase(p), mask); + } + async fchownPromise(fd, uid, gid) { + return this.baseFs.fchownPromise(fd, uid, gid); + } + fchownSync(fd, uid, gid) { + return this.baseFs.fchownSync(fd, uid, gid); + } + async chownPromise(p, uid, gid) { + return this.baseFs.chownPromise(this.mapToBase(p), uid, gid); + } + chownSync(p, uid, gid) { + return this.baseFs.chownSync(this.mapToBase(p), uid, gid); + } + async renamePromise(oldP, newP) { + return this.baseFs.renamePromise(this.mapToBase(oldP), this.mapToBase(newP)); + } + renameSync(oldP, newP) { + return this.baseFs.renameSync(this.mapToBase(oldP), this.mapToBase(newP)); + } + async copyFilePromise(sourceP, destP, flags = 0) { + return this.baseFs.copyFilePromise(this.mapToBase(sourceP), this.mapToBase(destP), flags); + } + copyFileSync(sourceP, destP, flags = 0) { + return this.baseFs.copyFileSync(this.mapToBase(sourceP), this.mapToBase(destP), flags); + } + async appendFilePromise(p, content, opts) { + return this.baseFs.appendFilePromise(this.fsMapToBase(p), content, opts); + } + appendFileSync(p, content, opts) { + return this.baseFs.appendFileSync(this.fsMapToBase(p), content, opts); + } + async writeFilePromise(p, content, opts) { + return this.baseFs.writeFilePromise(this.fsMapToBase(p), content, opts); + } + writeFileSync(p, content, opts) { + return this.baseFs.writeFileSync(this.fsMapToBase(p), content, opts); + } + async unlinkPromise(p) { + return this.baseFs.unlinkPromise(this.mapToBase(p)); + } + unlinkSync(p) { + return this.baseFs.unlinkSync(this.mapToBase(p)); + } + async utimesPromise(p, atime, mtime) { + return this.baseFs.utimesPromise(this.mapToBase(p), atime, mtime); + } + utimesSync(p, atime, mtime) { + return this.baseFs.utimesSync(this.mapToBase(p), atime, mtime); + } + async lutimesPromise(p, atime, mtime) { + return this.baseFs.lutimesPromise(this.mapToBase(p), atime, mtime); + } + lutimesSync(p, atime, mtime) { + return this.baseFs.lutimesSync(this.mapToBase(p), atime, mtime); + } + async mkdirPromise(p, opts) { + return this.baseFs.mkdirPromise(this.mapToBase(p), opts); + } + mkdirSync(p, opts) { + return this.baseFs.mkdirSync(this.mapToBase(p), opts); + } + async rmdirPromise(p, opts) { + return this.baseFs.rmdirPromise(this.mapToBase(p), opts); + } + rmdirSync(p, opts) { + return this.baseFs.rmdirSync(this.mapToBase(p), opts); + } + async rmPromise(p, opts) { + return this.baseFs.rmPromise(this.mapToBase(p), opts); + } + rmSync(p, opts) { + return this.baseFs.rmSync(this.mapToBase(p), opts); + } + async linkPromise(existingP, newP) { + return this.baseFs.linkPromise(this.mapToBase(existingP), this.mapToBase(newP)); + } + linkSync(existingP, newP) { + return this.baseFs.linkSync(this.mapToBase(existingP), this.mapToBase(newP)); + } + async symlinkPromise(target, p, type) { + const mappedP = this.mapToBase(p); + if (this.pathUtils.isAbsolute(target)) + return this.baseFs.symlinkPromise(this.mapToBase(target), mappedP, type); + const mappedAbsoluteTarget = this.mapToBase(this.pathUtils.join(this.pathUtils.dirname(p), target)); + const mappedTarget = this.baseFs.pathUtils.relative(this.baseFs.pathUtils.dirname(mappedP), mappedAbsoluteTarget); + return this.baseFs.symlinkPromise(mappedTarget, mappedP, type); + } + symlinkSync(target, p, type) { + const mappedP = this.mapToBase(p); + if (this.pathUtils.isAbsolute(target)) + return this.baseFs.symlinkSync(this.mapToBase(target), mappedP, type); + const mappedAbsoluteTarget = this.mapToBase(this.pathUtils.join(this.pathUtils.dirname(p), target)); + const mappedTarget = this.baseFs.pathUtils.relative(this.baseFs.pathUtils.dirname(mappedP), mappedAbsoluteTarget); + return this.baseFs.symlinkSync(mappedTarget, mappedP, type); + } + async readFilePromise(p, encoding) { + return this.baseFs.readFilePromise(this.fsMapToBase(p), encoding); + } + readFileSync(p, encoding) { + return this.baseFs.readFileSync(this.fsMapToBase(p), encoding); + } + readdirPromise(p, opts) { + return this.baseFs.readdirPromise(this.mapToBase(p), opts); + } + readdirSync(p, opts) { + return this.baseFs.readdirSync(this.mapToBase(p), opts); + } + async readlinkPromise(p) { + return this.mapFromBase(await this.baseFs.readlinkPromise(this.mapToBase(p))); + } + readlinkSync(p) { + return this.mapFromBase(this.baseFs.readlinkSync(this.mapToBase(p))); + } + async truncatePromise(p, len) { + return this.baseFs.truncatePromise(this.mapToBase(p), len); + } + truncateSync(p, len) { + return this.baseFs.truncateSync(this.mapToBase(p), len); + } + async ftruncatePromise(fd, len) { + return this.baseFs.ftruncatePromise(fd, len); + } + ftruncateSync(fd, len) { + return this.baseFs.ftruncateSync(fd, len); + } + watch(p, a, b) { + return this.baseFs.watch( + this.mapToBase(p), + // @ts-expect-error - reason TBS + a, + b + ); + } + watchFile(p, a, b) { + return this.baseFs.watchFile( + this.mapToBase(p), + // @ts-expect-error - reason TBS + a, + b + ); + } + unwatchFile(p, cb) { + return this.baseFs.unwatchFile(this.mapToBase(p), cb); + } + fsMapToBase(p) { + if (typeof p === `number`) { + return p; + } else { + return this.mapToBase(p); + } + } +} + +function direntToPortable(dirent) { + const portableDirent = dirent; + if (typeof dirent.path === `string`) + portableDirent.path = npath.toPortablePath(dirent.path); + return portableDirent; +} +class NodeFS extends BasePortableFakeFS { + realFs; + constructor(realFs = fs) { + super(); + this.realFs = realFs; + } + getExtractHint() { + return false; + } + getRealPath() { + return PortablePath.root; + } + resolve(p) { + return ppath.resolve(p); + } + async openPromise(p, flags, mode) { + return await new Promise((resolve, reject) => { + this.realFs.open(npath.fromPortablePath(p), flags, mode, this.makeCallback(resolve, reject)); + }); + } + openSync(p, flags, mode) { + return this.realFs.openSync(npath.fromPortablePath(p), flags, mode); + } + async opendirPromise(p, opts) { + return await new Promise((resolve, reject) => { + if (typeof opts !== `undefined`) { + this.realFs.opendir(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); + } else { + this.realFs.opendir(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); + } + }).then((dir) => { + const dirWithFixedPath = dir; + Object.defineProperty(dirWithFixedPath, `path`, { + value: p, + configurable: true, + writable: true + }); + return dirWithFixedPath; + }); + } + opendirSync(p, opts) { + const dir = typeof opts !== `undefined` ? this.realFs.opendirSync(npath.fromPortablePath(p), opts) : this.realFs.opendirSync(npath.fromPortablePath(p)); + const dirWithFixedPath = dir; + Object.defineProperty(dirWithFixedPath, `path`, { + value: p, + configurable: true, + writable: true + }); + return dirWithFixedPath; + } + async readPromise(fd, buffer, offset = 0, length = 0, position = -1) { + return await new Promise((resolve, reject) => { + this.realFs.read(fd, buffer, offset, length, position, (error, bytesRead) => { + if (error) { + reject(error); + } else { + resolve(bytesRead); + } + }); + }); + } + readSync(fd, buffer, offset, length, position) { + return this.realFs.readSync(fd, buffer, offset, length, position); + } + async writePromise(fd, buffer, offset, length, position) { + return await new Promise((resolve, reject) => { + if (typeof buffer === `string`) { + return this.realFs.write(fd, buffer, offset, this.makeCallback(resolve, reject)); + } else { + return this.realFs.write(fd, buffer, offset, length, position, this.makeCallback(resolve, reject)); + } + }); + } + writeSync(fd, buffer, offset, length, position) { + if (typeof buffer === `string`) { + return this.realFs.writeSync(fd, buffer, offset); + } else { + return this.realFs.writeSync(fd, buffer, offset, length, position); + } + } + async closePromise(fd) { + await new Promise((resolve, reject) => { + this.realFs.close(fd, this.makeCallback(resolve, reject)); + }); + } + closeSync(fd) { + this.realFs.closeSync(fd); + } + createReadStream(p, opts) { + const realPath = p !== null ? npath.fromPortablePath(p) : p; + return this.realFs.createReadStream(realPath, opts); + } + createWriteStream(p, opts) { + const realPath = p !== null ? npath.fromPortablePath(p) : p; + return this.realFs.createWriteStream(realPath, opts); + } + async realpathPromise(p) { + return await new Promise((resolve, reject) => { + this.realFs.realpath(npath.fromPortablePath(p), {}, this.makeCallback(resolve, reject)); + }).then((path) => { + return npath.toPortablePath(path); + }); + } + realpathSync(p) { + return npath.toPortablePath(this.realFs.realpathSync(npath.fromPortablePath(p), {})); + } + async existsPromise(p) { + return await new Promise((resolve) => { + this.realFs.exists(npath.fromPortablePath(p), resolve); + }); + } + accessSync(p, mode) { + return this.realFs.accessSync(npath.fromPortablePath(p), mode); + } + async accessPromise(p, mode) { + return await new Promise((resolve, reject) => { + this.realFs.access(npath.fromPortablePath(p), mode, this.makeCallback(resolve, reject)); + }); + } + existsSync(p) { + return this.realFs.existsSync(npath.fromPortablePath(p)); + } + async statPromise(p, opts) { + return await new Promise((resolve, reject) => { + if (opts) { + this.realFs.stat(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); + } else { + this.realFs.stat(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); + } + }); + } + statSync(p, opts) { + if (opts) { + return this.realFs.statSync(npath.fromPortablePath(p), opts); + } else { + return this.realFs.statSync(npath.fromPortablePath(p)); + } + } + async fstatPromise(fd, opts) { + return await new Promise((resolve, reject) => { + if (opts) { + this.realFs.fstat(fd, opts, this.makeCallback(resolve, reject)); + } else { + this.realFs.fstat(fd, this.makeCallback(resolve, reject)); + } + }); + } + fstatSync(fd, opts) { + if (opts) { + return this.realFs.fstatSync(fd, opts); + } else { + return this.realFs.fstatSync(fd); + } + } + async lstatPromise(p, opts) { + return await new Promise((resolve, reject) => { + if (opts) { + this.realFs.lstat(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); + } else { + this.realFs.lstat(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); + } + }); + } + lstatSync(p, opts) { + if (opts) { + return this.realFs.lstatSync(npath.fromPortablePath(p), opts); + } else { + return this.realFs.lstatSync(npath.fromPortablePath(p)); + } + } + async fchmodPromise(fd, mask) { + return await new Promise((resolve, reject) => { + this.realFs.fchmod(fd, mask, this.makeCallback(resolve, reject)); + }); + } + fchmodSync(fd, mask) { + return this.realFs.fchmodSync(fd, mask); + } + async chmodPromise(p, mask) { + return await new Promise((resolve, reject) => { + this.realFs.chmod(npath.fromPortablePath(p), mask, this.makeCallback(resolve, reject)); + }); + } + chmodSync(p, mask) { + return this.realFs.chmodSync(npath.fromPortablePath(p), mask); + } + async fchownPromise(fd, uid, gid) { + return await new Promise((resolve, reject) => { + this.realFs.fchown(fd, uid, gid, this.makeCallback(resolve, reject)); + }); + } + fchownSync(fd, uid, gid) { + return this.realFs.fchownSync(fd, uid, gid); + } + async chownPromise(p, uid, gid) { + return await new Promise((resolve, reject) => { + this.realFs.chown(npath.fromPortablePath(p), uid, gid, this.makeCallback(resolve, reject)); + }); + } + chownSync(p, uid, gid) { + return this.realFs.chownSync(npath.fromPortablePath(p), uid, gid); + } + async renamePromise(oldP, newP) { + return await new Promise((resolve, reject) => { + this.realFs.rename(npath.fromPortablePath(oldP), npath.fromPortablePath(newP), this.makeCallback(resolve, reject)); + }); + } + renameSync(oldP, newP) { + return this.realFs.renameSync(npath.fromPortablePath(oldP), npath.fromPortablePath(newP)); + } + async copyFilePromise(sourceP, destP, flags = 0) { + return await new Promise((resolve, reject) => { + this.realFs.copyFile(npath.fromPortablePath(sourceP), npath.fromPortablePath(destP), flags, this.makeCallback(resolve, reject)); + }); + } + copyFileSync(sourceP, destP, flags = 0) { + return this.realFs.copyFileSync(npath.fromPortablePath(sourceP), npath.fromPortablePath(destP), flags); + } + async appendFilePromise(p, content, opts) { + return await new Promise((resolve, reject) => { + const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; + if (opts) { + this.realFs.appendFile(fsNativePath, content, opts, this.makeCallback(resolve, reject)); + } else { + this.realFs.appendFile(fsNativePath, content, this.makeCallback(resolve, reject)); + } + }); + } + appendFileSync(p, content, opts) { + const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; + if (opts) { + this.realFs.appendFileSync(fsNativePath, content, opts); + } else { + this.realFs.appendFileSync(fsNativePath, content); + } + } + async writeFilePromise(p, content, opts) { + return await new Promise((resolve, reject) => { + const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; + if (opts) { + this.realFs.writeFile(fsNativePath, content, opts, this.makeCallback(resolve, reject)); + } else { + this.realFs.writeFile(fsNativePath, content, this.makeCallback(resolve, reject)); + } + }); + } + writeFileSync(p, content, opts) { + const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; + if (opts) { + this.realFs.writeFileSync(fsNativePath, content, opts); + } else { + this.realFs.writeFileSync(fsNativePath, content); + } + } + async unlinkPromise(p) { + return await new Promise((resolve, reject) => { + this.realFs.unlink(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); + }); + } + unlinkSync(p) { + return this.realFs.unlinkSync(npath.fromPortablePath(p)); + } + async utimesPromise(p, atime, mtime) { + return await new Promise((resolve, reject) => { + this.realFs.utimes(npath.fromPortablePath(p), atime, mtime, this.makeCallback(resolve, reject)); + }); + } + utimesSync(p, atime, mtime) { + this.realFs.utimesSync(npath.fromPortablePath(p), atime, mtime); + } + async lutimesPromise(p, atime, mtime) { + return await new Promise((resolve, reject) => { + this.realFs.lutimes(npath.fromPortablePath(p), atime, mtime, this.makeCallback(resolve, reject)); + }); + } + lutimesSync(p, atime, mtime) { + this.realFs.lutimesSync(npath.fromPortablePath(p), atime, mtime); + } + async mkdirPromise(p, opts) { + return await new Promise((resolve, reject) => { + this.realFs.mkdir(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); + }); + } + mkdirSync(p, opts) { + return this.realFs.mkdirSync(npath.fromPortablePath(p), opts); + } + async rmdirPromise(p, opts) { + return await new Promise((resolve, reject) => { + if (opts) { + this.realFs.rmdir(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); + } else { + this.realFs.rmdir(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); + } + }); + } + rmdirSync(p, opts) { + return this.realFs.rmdirSync(npath.fromPortablePath(p), opts); + } + async rmPromise(p, opts) { + return await new Promise((resolve, reject) => { + if (opts) { + this.realFs.rm(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); + } else { + this.realFs.rm(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); + } + }); + } + rmSync(p, opts) { + return this.realFs.rmSync(npath.fromPortablePath(p), opts); + } + async linkPromise(existingP, newP) { + return await new Promise((resolve, reject) => { + this.realFs.link(npath.fromPortablePath(existingP), npath.fromPortablePath(newP), this.makeCallback(resolve, reject)); + }); + } + linkSync(existingP, newP) { + return this.realFs.linkSync(npath.fromPortablePath(existingP), npath.fromPortablePath(newP)); + } + async symlinkPromise(target, p, type) { + return await new Promise((resolve, reject) => { + this.realFs.symlink(npath.fromPortablePath(target.replace(/\/+$/, ``)), npath.fromPortablePath(p), type, this.makeCallback(resolve, reject)); + }); + } + symlinkSync(target, p, type) { + return this.realFs.symlinkSync(npath.fromPortablePath(target.replace(/\/+$/, ``)), npath.fromPortablePath(p), type); + } + async readFilePromise(p, encoding) { + return await new Promise((resolve, reject) => { + const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; + this.realFs.readFile(fsNativePath, encoding, this.makeCallback(resolve, reject)); + }); + } + readFileSync(p, encoding) { + const fsNativePath = typeof p === `string` ? npath.fromPortablePath(p) : p; + return this.realFs.readFileSync(fsNativePath, encoding); + } + async readdirPromise(p, opts) { + return await new Promise((resolve, reject) => { + if (opts) { + if (opts.recursive && process.platform === `win32`) { + if (opts.withFileTypes) { + this.realFs.readdir(npath.fromPortablePath(p), opts, this.makeCallback((results) => resolve(results.map(direntToPortable)), reject)); + } else { + this.realFs.readdir(npath.fromPortablePath(p), opts, this.makeCallback((results) => resolve(results.map(npath.toPortablePath)), reject)); + } + } else { + this.realFs.readdir(npath.fromPortablePath(p), opts, this.makeCallback(resolve, reject)); + } + } else { + this.realFs.readdir(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); + } + }); + } + readdirSync(p, opts) { + if (opts) { + if (opts.recursive && process.platform === `win32`) { + if (opts.withFileTypes) { + return this.realFs.readdirSync(npath.fromPortablePath(p), opts).map(direntToPortable); + } else { + return this.realFs.readdirSync(npath.fromPortablePath(p), opts).map(npath.toPortablePath); + } + } else { + return this.realFs.readdirSync(npath.fromPortablePath(p), opts); + } + } else { + return this.realFs.readdirSync(npath.fromPortablePath(p)); + } + } + async readlinkPromise(p) { + return await new Promise((resolve, reject) => { + this.realFs.readlink(npath.fromPortablePath(p), this.makeCallback(resolve, reject)); + }).then((path) => { + return npath.toPortablePath(path); + }); + } + readlinkSync(p) { + return npath.toPortablePath(this.realFs.readlinkSync(npath.fromPortablePath(p))); + } + async truncatePromise(p, len) { + return await new Promise((resolve, reject) => { + this.realFs.truncate(npath.fromPortablePath(p), len, this.makeCallback(resolve, reject)); + }); + } + truncateSync(p, len) { + return this.realFs.truncateSync(npath.fromPortablePath(p), len); + } + async ftruncatePromise(fd, len) { + return await new Promise((resolve, reject) => { + this.realFs.ftruncate(fd, len, this.makeCallback(resolve, reject)); + }); + } + ftruncateSync(fd, len) { + return this.realFs.ftruncateSync(fd, len); + } + watch(p, a, b) { + return this.realFs.watch( + npath.fromPortablePath(p), + // @ts-expect-error - reason TBS + a, + b + ); + } + watchFile(p, a, b) { + return this.realFs.watchFile( + npath.fromPortablePath(p), + // @ts-expect-error - reason TBS + a, + b + ); + } + unwatchFile(p, cb) { + return this.realFs.unwatchFile(npath.fromPortablePath(p), cb); + } + makeCallback(resolve, reject) { + return (err, result) => { + if (err) { + reject(err); + } else { + resolve(result); + } + }; + } +} + +const NUMBER_REGEXP = /^[0-9]+$/; +const VIRTUAL_REGEXP = /^(\/(?:[^/]+\/)*?(?:\$\$virtual|__virtual__))((?:\/((?:[^/]+-)?[a-f0-9]+)(?:\/([^/]+))?)?((?:\/.*)?))$/; +const VALID_COMPONENT = /^([^/]+-)?[a-f0-9]+$/; +class VirtualFS extends ProxiedFS { + baseFs; + static makeVirtualPath(base, component, to) { + if (ppath.basename(base) !== `__virtual__`) + throw new Error(`Assertion failed: Virtual folders must be named "__virtual__"`); + if (!ppath.basename(component).match(VALID_COMPONENT)) + throw new Error(`Assertion failed: Virtual components must be ended by an hexadecimal hash`); + const target = ppath.relative(ppath.dirname(base), to); + const segments = target.split(`/`); + let depth = 0; + while (depth < segments.length && segments[depth] === `..`) + depth += 1; + const finalSegments = segments.slice(depth); + const fullVirtualPath = ppath.join(base, component, String(depth), ...finalSegments); + return fullVirtualPath; + } + static resolveVirtual(p) { + const match = p.match(VIRTUAL_REGEXP); + if (!match || !match[3] && match[5]) + return p; + const target = ppath.dirname(match[1]); + if (!match[3] || !match[4]) + return target; + const isnum = NUMBER_REGEXP.test(match[4]); + if (!isnum) + return p; + const depth = Number(match[4]); + const backstep = `../`.repeat(depth); + const subpath = match[5] || `.`; + return VirtualFS.resolveVirtual(ppath.join(target, backstep, subpath)); + } + constructor({ baseFs = new NodeFS() } = {}) { + super(ppath); + this.baseFs = baseFs; + } + getExtractHint(hints) { + return this.baseFs.getExtractHint(hints); + } + getRealPath() { + return this.baseFs.getRealPath(); + } + realpathSync(p) { + const match = p.match(VIRTUAL_REGEXP); + if (!match) + return this.baseFs.realpathSync(p); + if (!match[5]) + return p; + const realpath = this.baseFs.realpathSync(this.mapToBase(p)); + return VirtualFS.makeVirtualPath(match[1], match[3], realpath); + } + async realpathPromise(p) { + const match = p.match(VIRTUAL_REGEXP); + if (!match) + return await this.baseFs.realpathPromise(p); + if (!match[5]) + return p; + const realpath = await this.baseFs.realpathPromise(this.mapToBase(p)); + return VirtualFS.makeVirtualPath(match[1], match[3], realpath); + } + mapToBase(p) { + if (p === ``) + return p; + if (this.pathUtils.isAbsolute(p)) + return VirtualFS.resolveVirtual(p); + const resolvedRoot = VirtualFS.resolveVirtual(this.baseFs.resolve(PortablePath.dot)); + const resolvedP = VirtualFS.resolveVirtual(this.baseFs.resolve(p)); + return ppath.relative(resolvedRoot, resolvedP) || PortablePath.dot; + } + mapFromBase(p) { + return p; + } +} + +const URL = Number(process.versions.node.split('.', 1)[0]) < 20 ? URL$1 : globalThis.URL; + +const [major, minor, patch] = process.versions.node.split(`.`).map((value) => parseInt(value, 10)); +const WATCH_MODE_MESSAGE_USES_ARRAYS = major > 19 || major === 19 && minor >= 2 || major === 18 && minor >= 13; +const HAS_LAZY_LOADED_TRANSLATORS = major === 20 && minor < 6 || major === 19 && minor >= 3; +const SUPPORTS_IMPORT_ATTRIBUTES = major >= 21 || major === 20 && minor >= 10 || major === 18 && minor >= 20; +const SUPPORTS_IMPORT_ATTRIBUTES_ONLY = major >= 22; +const HAS_BROKEN_FSTAT_FOR_ZIP_FDS = major === 26 && minor < 1 || major === 25 && minor >= 7 || major === 24 && minor === 15 || major === 22 && (minor > 22 || minor === 22 && patch >= 3); + +function readPackageScope(checkPath) { + const rootSeparatorIndex = checkPath.indexOf(npath.sep); + let separatorIndex; + do { + separatorIndex = checkPath.lastIndexOf(npath.sep); + checkPath = checkPath.slice(0, separatorIndex); + if (checkPath.endsWith(`${npath.sep}node_modules`)) + return false; + const pjson = readPackage(checkPath + npath.sep); + if (pjson) { + return { + data: pjson, + path: checkPath + }; + } + } while (separatorIndex > rootSeparatorIndex); + return false; +} +function readPackage(requestPath) { + const jsonPath = npath.resolve(requestPath, `package.json`); + if (!fs.existsSync(jsonPath)) + return null; + return JSON.parse(fs.readFileSync(jsonPath, `utf8`)); +} + +async function tryReadFile$1(path2) { + try { + return await fs.promises.readFile(path2, `utf8`); + } catch (error) { + if (error.code === `ENOENT`) + return null; + throw error; + } +} +function tryParseURL(str, base) { + try { + return new URL(str, base); + } catch { + return null; + } +} +let entrypointPath = null; +function setEntrypointPath(file) { + entrypointPath = file; +} +function getFileFormat(filepath) { + const ext = path.extname(filepath); + switch (ext) { + case `.mjs`: { + return `module`; + } + case `.cjs`: { + return `commonjs`; + } + case `.wasm`: { + throw new Error( + `Unknown file extension ".wasm" for ${filepath}` + ); + } + case `.json`: { + return `json`; + } + case `.js`: { + const pkg = readPackageScope(filepath); + if (!pkg) + return `commonjs`; + return pkg.data.type ?? `commonjs`; + } + default: { + if (entrypointPath !== filepath) + return null; + const pkg = readPackageScope(filepath); + if (!pkg) + return `commonjs`; + if (pkg.data.type === `module`) + return null; + return pkg.data.type ?? `commonjs`; + } + } +} + +async function load$1(urlString, context, nextLoad) { + const url = tryParseURL(urlString); + if (url?.protocol !== `file:`) + return nextLoad(urlString, context, nextLoad); + const filePath = fileURLToPath(url); + const format = getFileFormat(filePath); + if (!format) + return nextLoad(urlString, context, nextLoad); + if (format === `json`) { + if (SUPPORTS_IMPORT_ATTRIBUTES_ONLY) { + if (context.importAttributes?.type !== `json`) { + const err = new TypeError(`[ERR_IMPORT_ATTRIBUTE_MISSING]: Module "${urlString}" needs an import attribute of "type: json"`); + err.code = `ERR_IMPORT_ATTRIBUTE_MISSING`; + throw err; + } + } else { + const type = `importAttributes` in context ? context.importAttributes?.type : context.importAssertions?.type; + if (type !== `json`) { + const err = new TypeError(`[ERR_IMPORT_ASSERTION_TYPE_MISSING]: Module "${urlString}" needs an import ${SUPPORTS_IMPORT_ATTRIBUTES ? `attribute` : `assertion`} of type "json"`); + err.code = `ERR_IMPORT_ASSERTION_TYPE_MISSING`; + throw err; + } + } + } + if (process.env.WATCH_REPORT_DEPENDENCIES && process.send) { + const pathToSend = pathToFileURL( + npath.fromPortablePath( + VirtualFS.resolveVirtual(npath.toPortablePath(filePath)) + ) + ).href; + process.send({ + "watch:import": WATCH_MODE_MESSAGE_USES_ARRAYS ? [pathToSend] : pathToSend + }); + } + const shouldReadSource = format === `commonjs` && HAS_BROKEN_FSTAT_FOR_ZIP_FDS && filePath.includes(`.zip/`); + const source = format !== `commonjs` || shouldReadSource ? await fs.promises.readFile(filePath, `utf8`) : void 0; + return { + format, + source, + shortCircuit: true + }; +} + +const ArrayIsArray = Array.isArray; +const JSONStringify = JSON.stringify; +const ObjectGetOwnPropertyNames = Object.getOwnPropertyNames; +const ObjectPrototypeHasOwnProperty = (obj, prop) => Object.prototype.hasOwnProperty.call(obj, prop); +const RegExpPrototypeExec = (obj, string) => RegExp.prototype.exec.call(obj, string); +const RegExpPrototypeSymbolReplace = (obj, ...rest) => RegExp.prototype[Symbol.replace].apply(obj, rest); +const StringPrototypeEndsWith = (str, ...rest) => String.prototype.endsWith.apply(str, rest); +const StringPrototypeIncludes = (str, ...rest) => String.prototype.includes.apply(str, rest); +const StringPrototypeLastIndexOf = (str, ...rest) => String.prototype.lastIndexOf.apply(str, rest); +const StringPrototypeIndexOf = (str, ...rest) => String.prototype.indexOf.apply(str, rest); +const StringPrototypeReplace = (str, ...rest) => String.prototype.replace.apply(str, rest); +const StringPrototypeSlice = (str, ...rest) => String.prototype.slice.apply(str, rest); +const StringPrototypeStartsWith = (str, ...rest) => String.prototype.startsWith.apply(str, rest); +const SafeMap = Map; +const JSONParse = JSON.parse; + +function createErrorType(code, messageCreator, errorType) { + return class extends errorType { + constructor(...args) { + super(messageCreator(...args)); + this.code = code; + this.name = `${errorType.name} [${code}]`; + } + }; +} +const ERR_PACKAGE_IMPORT_NOT_DEFINED = createErrorType( + `ERR_PACKAGE_IMPORT_NOT_DEFINED`, + (specifier, packagePath, base) => { + return `Package import specifier "${specifier}" is not defined${packagePath ? ` in package ${packagePath}package.json` : ``} imported from ${base}`; + }, + TypeError +); +const ERR_INVALID_MODULE_SPECIFIER = createErrorType( + `ERR_INVALID_MODULE_SPECIFIER`, + (request, reason, base = void 0) => { + return `Invalid module "${request}" ${reason}${base ? ` imported from ${base}` : ``}`; + }, + TypeError +); +const ERR_INVALID_PACKAGE_TARGET = createErrorType( + `ERR_INVALID_PACKAGE_TARGET`, + (pkgPath, key, target, isImport = false, base = void 0) => { + const relError = typeof target === `string` && !isImport && target.length && !StringPrototypeStartsWith(target, `./`); + if (key === `.`) { + assert(isImport === false); + return `Invalid "exports" main target ${JSONStringify(target)} defined in the package config ${pkgPath}package.json${base ? ` imported from ${base}` : ``}${relError ? `; targets must start with "./"` : ``}`; + } + return `Invalid "${isImport ? `imports` : `exports`}" target ${JSONStringify( + target + )} defined for '${key}' in the package config ${pkgPath}package.json${base ? ` imported from ${base}` : ``}${relError ? `; targets must start with "./"` : ``}`; + }, + Error +); +const ERR_INVALID_PACKAGE_CONFIG = createErrorType( + `ERR_INVALID_PACKAGE_CONFIG`, + (path, base, message) => { + return `Invalid package config ${path}${base ? ` while importing ${base}` : ``}${message ? `. ${message}` : ``}`; + }, + Error +); + +function filterOwnProperties(source, keys) { + const filtered = /* @__PURE__ */ Object.create(null); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (ObjectPrototypeHasOwnProperty(source, key)) { + filtered[key] = source[key]; + } + } + return filtered; +} + +const packageJSONCache = new SafeMap(); +function getPackageConfig(path, specifier, base, readFileSyncFn) { + const existing = packageJSONCache.get(path); + if (existing !== void 0) { + return existing; + } + const source = readFileSyncFn(path); + if (source === void 0) { + const packageConfig2 = { + pjsonPath: path, + exists: false, + main: void 0, + name: void 0, + type: "none", + exports: void 0, + imports: void 0 + }; + packageJSONCache.set(path, packageConfig2); + return packageConfig2; + } + let packageJSON; + try { + packageJSON = JSONParse(source); + } catch (error) { + throw new ERR_INVALID_PACKAGE_CONFIG( + path, + (base ? `"${specifier}" from ` : "") + fileURLToPath(base || specifier), + error.message + ); + } + let { imports, main, name, type } = filterOwnProperties(packageJSON, [ + "imports", + "main", + "name", + "type" + ]); + const exports = ObjectPrototypeHasOwnProperty(packageJSON, "exports") ? packageJSON.exports : void 0; + if (typeof imports !== "object" || imports === null) { + imports = void 0; + } + if (typeof main !== "string") { + main = void 0; + } + if (typeof name !== "string") { + name = void 0; + } + if (type !== "module" && type !== "commonjs") { + type = "none"; + } + const packageConfig = { + pjsonPath: path, + exists: true, + main, + name, + type, + exports, + imports + }; + packageJSONCache.set(path, packageConfig); + return packageConfig; +} +function getPackageScopeConfig(resolved, readFileSyncFn) { + let packageJSONUrl = new URL("./package.json", resolved); + while (true) { + const packageJSONPath2 = packageJSONUrl.pathname; + if (StringPrototypeEndsWith(packageJSONPath2, "node_modules/package.json")) { + break; + } + const packageConfig2 = getPackageConfig( + fileURLToPath(packageJSONUrl), + resolved, + void 0, + readFileSyncFn + ); + if (packageConfig2.exists) { + return packageConfig2; + } + const lastPackageJSONUrl = packageJSONUrl; + packageJSONUrl = new URL("../package.json", packageJSONUrl); + if (packageJSONUrl.pathname === lastPackageJSONUrl.pathname) { + break; + } + } + const packageJSONPath = fileURLToPath(packageJSONUrl); + const packageConfig = { + pjsonPath: packageJSONPath, + exists: false, + main: void 0, + name: void 0, + type: "none", + exports: void 0, + imports: void 0 + }; + packageJSONCache.set(packageJSONPath, packageConfig); + return packageConfig; +} + +function throwImportNotDefined(specifier, packageJSONUrl, base) { + throw new ERR_PACKAGE_IMPORT_NOT_DEFINED( + specifier, + packageJSONUrl && fileURLToPath(new URL(".", packageJSONUrl)), + fileURLToPath(base) + ); +} +function throwInvalidSubpath(subpath, packageJSONUrl, internal, base) { + const reason = `request is not a valid subpath for the "${internal ? "imports" : "exports"}" resolution of ${fileURLToPath(packageJSONUrl)}`; + throw new ERR_INVALID_MODULE_SPECIFIER( + subpath, + reason, + base && fileURLToPath(base) + ); +} +function throwInvalidPackageTarget(subpath, target, packageJSONUrl, internal, base) { + if (typeof target === "object" && target !== null) { + target = JSONStringify(target, null, ""); + } else { + target = `${target}`; + } + throw new ERR_INVALID_PACKAGE_TARGET( + fileURLToPath(new URL(".", packageJSONUrl)), + subpath, + target, + internal, + base && fileURLToPath(base) + ); +} +const invalidSegmentRegEx = /(^|\\|\/)((\.|%2e)(\.|%2e)?|(n|%6e|%4e)(o|%6f|%4f)(d|%64|%44)(e|%65|%45)(_|%5f)(m|%6d|%4d)(o|%6f|%4f)(d|%64|%44)(u|%75|%55)(l|%6c|%4c)(e|%65|%45)(s|%73|%53))(\\|\/|$)/i; +const patternRegEx = /\*/g; +function resolvePackageTargetString(target, subpath, match, packageJSONUrl, base, pattern, internal, conditions) { + if (subpath !== "" && !pattern && target[target.length - 1] !== "/") + throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base); + if (!StringPrototypeStartsWith(target, "./")) { + if (internal && !StringPrototypeStartsWith(target, "../") && !StringPrototypeStartsWith(target, "/")) { + let isURL = false; + try { + new URL(target); + isURL = true; + } catch { + } + if (!isURL) { + const exportTarget = pattern ? RegExpPrototypeSymbolReplace(patternRegEx, target, () => subpath) : target + subpath; + return exportTarget; + } + } + throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base); + } + if (RegExpPrototypeExec( + invalidSegmentRegEx, + StringPrototypeSlice(target, 2) + ) !== null) + throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base); + const resolved = new URL(target, packageJSONUrl); + const resolvedPath = resolved.pathname; + const packagePath = new URL(".", packageJSONUrl).pathname; + if (!StringPrototypeStartsWith(resolvedPath, packagePath)) + throwInvalidPackageTarget(match, target, packageJSONUrl, internal, base); + if (subpath === "") return resolved; + if (RegExpPrototypeExec(invalidSegmentRegEx, subpath) !== null) { + const request = pattern ? StringPrototypeReplace(match, "*", () => subpath) : match + subpath; + throwInvalidSubpath(request, packageJSONUrl, internal, base); + } + if (pattern) { + return new URL( + RegExpPrototypeSymbolReplace(patternRegEx, resolved.href, () => subpath) + ); + } + return new URL(subpath, resolved); +} +function isArrayIndex(key) { + const keyNum = +key; + if (`${keyNum}` !== key) return false; + return keyNum >= 0 && keyNum < 4294967295; +} +function resolvePackageTarget(packageJSONUrl, target, subpath, packageSubpath, base, pattern, internal, conditions) { + if (typeof target === "string") { + return resolvePackageTargetString( + target, + subpath, + packageSubpath, + packageJSONUrl, + base, + pattern, + internal); + } else if (ArrayIsArray(target)) { + if (target.length === 0) { + return null; + } + let lastException; + for (let i = 0; i < target.length; i++) { + const targetItem = target[i]; + let resolveResult; + try { + resolveResult = resolvePackageTarget( + packageJSONUrl, + targetItem, + subpath, + packageSubpath, + base, + pattern, + internal, + conditions + ); + } catch (e) { + lastException = e; + if (e.code === "ERR_INVALID_PACKAGE_TARGET") { + continue; + } + throw e; + } + if (resolveResult === void 0) { + continue; + } + if (resolveResult === null) { + lastException = null; + continue; + } + return resolveResult; + } + if (lastException === void 0 || lastException === null) + return lastException; + throw lastException; + } else if (typeof target === "object" && target !== null) { + const keys = ObjectGetOwnPropertyNames(target); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (isArrayIndex(key)) { + throw new ERR_INVALID_PACKAGE_CONFIG( + fileURLToPath(packageJSONUrl), + base, + '"exports" cannot contain numeric property keys.' + ); + } + } + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + if (key === "default" || conditions.has(key)) { + const conditionalTarget = target[key]; + const resolveResult = resolvePackageTarget( + packageJSONUrl, + conditionalTarget, + subpath, + packageSubpath, + base, + pattern, + internal, + conditions + ); + if (resolveResult === void 0) continue; + return resolveResult; + } + } + return void 0; + } else if (target === null) { + return null; + } + throwInvalidPackageTarget( + packageSubpath, + target, + packageJSONUrl, + internal, + base + ); +} +function patternKeyCompare(a, b) { + const aPatternIndex = StringPrototypeIndexOf(a, "*"); + const bPatternIndex = StringPrototypeIndexOf(b, "*"); + const baseLenA = aPatternIndex === -1 ? a.length : aPatternIndex + 1; + const baseLenB = bPatternIndex === -1 ? b.length : bPatternIndex + 1; + if (baseLenA > baseLenB) return -1; + if (baseLenB > baseLenA) return 1; + if (aPatternIndex === -1) return 1; + if (bPatternIndex === -1) return -1; + if (a.length > b.length) return -1; + if (b.length > a.length) return 1; + return 0; +} +function packageImportsResolve({ name, base, conditions, readFileSyncFn }) { + if (name === "#" || StringPrototypeStartsWith(name, "#/") || StringPrototypeEndsWith(name, "/")) { + const reason = "is not a valid internal imports specifier name"; + throw new ERR_INVALID_MODULE_SPECIFIER(name, reason, fileURLToPath(base)); + } + let packageJSONUrl; + const packageConfig = getPackageScopeConfig(base, readFileSyncFn); + if (packageConfig.exists) { + packageJSONUrl = pathToFileURL(packageConfig.pjsonPath); + const imports = packageConfig.imports; + if (imports) { + if (ObjectPrototypeHasOwnProperty(imports, name) && !StringPrototypeIncludes(name, "*")) { + const resolveResult = resolvePackageTarget( + packageJSONUrl, + imports[name], + "", + name, + base, + false, + true, + conditions + ); + if (resolveResult != null) { + return resolveResult; + } + } else { + let bestMatch = ""; + let bestMatchSubpath; + const keys = ObjectGetOwnPropertyNames(imports); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]; + const patternIndex = StringPrototypeIndexOf(key, "*"); + if (patternIndex !== -1 && StringPrototypeStartsWith( + name, + StringPrototypeSlice(key, 0, patternIndex) + )) { + const patternTrailer = StringPrototypeSlice(key, patternIndex + 1); + if (name.length >= key.length && StringPrototypeEndsWith(name, patternTrailer) && patternKeyCompare(bestMatch, key) === 1 && StringPrototypeLastIndexOf(key, "*") === patternIndex) { + bestMatch = key; + bestMatchSubpath = StringPrototypeSlice( + name, + patternIndex, + name.length - patternTrailer.length + ); + } + } + } + if (bestMatch) { + const target = imports[bestMatch]; + const resolveResult = resolvePackageTarget( + packageJSONUrl, + target, + bestMatchSubpath, + bestMatch, + base, + true, + true, + conditions + ); + if (resolveResult != null) { + return resolveResult; + } + } + } + } + } + throwImportNotDefined(name, packageJSONUrl, base); +} + +let findPnpApi = esmModule.findPnpApi; +if (!findPnpApi) { + const require = createRequire(import.meta.url); + const pnpApi = require(structuredClone(`./.pnp.cjs`)); + pnpApi.setup(); + findPnpApi = esmModule.findPnpApi; +} +const pathRegExp = /^(?![a-zA-Z]:[\\/]|\\\\|\.{0,2}(?:\/|$))((?:node:)?(?:@[^/]+\/)?[^/]+)\/*(.*|)$/; +const isRelativeRegexp = /^\.{0,2}\//; +function tryReadFile(filePath) { + try { + return fs.readFileSync(filePath, `utf8`); + } catch (err) { + if (err.code === `ENOENT`) + return void 0; + throw err; + } +} +async function resolvePrivateRequest(specifier, issuer, context, nextResolve) { + const resolved = packageImportsResolve({ + name: specifier, + base: pathToFileURL(issuer), + conditions: new Set(context.conditions), + readFileSyncFn: tryReadFile + }); + if (resolved instanceof URL) { + return { url: resolved.href, shortCircuit: true }; + } else { + if (resolved.startsWith(`#`)) + throw new Error(`Mapping from one private import to another isn't allowed`); + return resolve$1(resolved, context, nextResolve); + } +} +async function resolve$1(originalSpecifier, context, nextResolve) { + if (!findPnpApi || isBuiltin(originalSpecifier)) + return nextResolve(originalSpecifier, context, nextResolve); + let specifier = originalSpecifier; + const url = tryParseURL(specifier, isRelativeRegexp.test(specifier) ? context.parentURL : void 0); + if (url) { + if (url.protocol !== `file:`) + return nextResolve(originalSpecifier, context, nextResolve); + specifier = fileURLToPath(url); + } + const { parentURL, conditions = [] } = context; + const issuer = parentURL && tryParseURL(parentURL)?.protocol === `file:` ? fileURLToPath(parentURL) : process.cwd(); + const pnpapi = findPnpApi(issuer) ?? (url ? findPnpApi(specifier) : null); + if (!pnpapi) + return nextResolve(originalSpecifier, context, nextResolve); + if (specifier.startsWith(`#`)) + return resolvePrivateRequest(specifier, issuer, context, nextResolve); + const dependencyNameMatch = specifier.match(pathRegExp); + let allowLegacyResolve = false; + if (dependencyNameMatch) { + const [, dependencyName, subPath] = dependencyNameMatch; + if (subPath === `` && dependencyName !== `pnpapi`) { + const resolved = pnpapi.resolveToUnqualified(`${dependencyName}/package.json`, issuer); + if (resolved) { + const content = await tryReadFile$1(resolved); + if (content) { + const pkg = JSON.parse(content); + allowLegacyResolve = pkg.exports == null; + } + } + } + } + let result; + try { + result = pnpapi.resolveRequest(specifier, issuer, { + conditions: new Set(conditions), + // TODO: Handle --experimental-specifier-resolution=node + extensions: allowLegacyResolve ? void 0 : [] + }); + } catch (err) { + if (err instanceof Error && `code` in err && err.code === `MODULE_NOT_FOUND`) + err.code = `ERR_MODULE_NOT_FOUND`; + throw err; + } + if (!result) + throw new Error(`Resolving '${specifier}' from '${issuer}' failed`); + const resultURL = pathToFileURL(result); + if (url) { + resultURL.search = url.search; + resultURL.hash = url.hash; + } + if (!parentURL) + setEntrypointPath(fileURLToPath(resultURL)); + return { + url: resultURL.href, + shortCircuit: true + }; +} + +if (!HAS_LAZY_LOADED_TRANSLATORS) { + const binding = process.binding(`fs`); + const originalReadFile = binding.readFileUtf8 || binding.readFileSync; + if (originalReadFile) { + binding[originalReadFile.name] = function(...args) { + try { + return fs.readFileSync(args[0], { + encoding: `utf8`, + // @ts-expect-error - The docs says it needs to be a string but + // links to https://nodejs.org/dist/latest-v20.x/docs/api/fs.html#file-system-flags + // which says it can be a number which matches the implementation. + flag: args[1] + }); + } catch { + } + return originalReadFile.apply(this, args); + }; + } else { + const binding2 = process.binding(`fs`); + const originalfstat = binding2.fstat; + const ZIP_MASK = 4278190080; + const ZIP_MAGIC = 704643072; + binding2.fstat = function(...args) { + const [fd, useBigint, req] = args; + if ((fd & ZIP_MASK) === ZIP_MAGIC && useBigint === false && req === void 0) { + try { + const stats = fs.fstatSync(fd); + return new Float64Array([ + stats.dev, + stats.mode, + stats.nlink, + stats.uid, + stats.gid, + stats.rdev, + stats.blksize, + stats.ino, + stats.size, + stats.blocks + // atime sec + // atime ns + // mtime sec + // mtime ns + // ctime sec + // ctime ns + // birthtime sec + // birthtime ns + ]); + } catch { + } + } + return originalfstat.apply(this, args); + }; + } +} + +const resolve = resolve$1; +const load = load$1; + +export { load, resolve }; diff --git a/.vsls.json b/.vsls.json new file mode 100644 index 0000000..3fff862 --- /dev/null +++ b/.vsls.json @@ -0,0 +1,4 @@ +{ + "$schema": "http://json.schemastore.org/vsls", + "gitignore": "exclude" +} diff --git a/.weblate b/.weblate new file mode 100644 index 0000000..3ecf97a --- /dev/null +++ b/.weblate @@ -0,0 +1,3 @@ +[weblate] +url = https://hosted.weblate.org/api/ +translation = iceshrimp/locales diff --git a/.yarnrc.yml b/.yarnrc.yml new file mode 100644 index 0000000..ee88426 --- /dev/null +++ b/.yarnrc.yml @@ -0,0 +1,52 @@ +approvedGitRepositories: + - "**" + +compressionLevel: mixed + +enableGlobalCache: false + +enableScripts: true + +enableTelemetry: false + +nodeLinker: pnp + +npmMinimalAgeGate: 1w + +npmScopes: + iceshrimp: + npmRegistryServer: "https://iceshrimp.dev/api/packages/iceshrimp/npm/" + +packageExtensions: + consolidate@^0.16.0: + dependencies: + ejs: ^3.1.7 + pug: 3.0.2 + debug@*: + dependencies: + supports-color: ^8.0.0 + fix-esm@1.0.1: + dependencies: + multer: 1.4.4-lts.1 + node-fetch@2.6.12: + dependencies: + encoding: ^0.1.13 + ws@8.13.0: + dependencies: + bufferutil: ^4.0.1 + +pnpMode: strict + +supportedArchitectures: + cpu: + - current + - x64 + - arm64 + libc: + - current + - glibc + - musl + os: + - current + - darwin + - linux diff --git a/APPS.md b/APPS.md new file mode 100644 index 0000000..3e778ba --- /dev/null +++ b/APPS.md @@ -0,0 +1,20 @@ +# We test our Mastodon-compatible API against the following clients: +## Web + - [Elk](https://elk.zone) + - [Phanpy](https://phanpy.social/) + - [Enafore](https://enafore.social/) + - [Masto-FE-standalone](https://iceshrimp.dev/iceshrimp/masto-fe-standalone) + +## iOS + - [Mona](https://apps.apple.com/us/app/mona-for-mastodon/id1659154653) + - [Toot!](https://apps.apple.com/us/app/toot-for-mastodon/id1229021451) + - [Ice Cubes](https://apps.apple.com/us/app/ice-cubes-for-mastodon/id6444915884) + - [Tusker](https://apps.apple.com/us/app/tusker/id1498334597) + - [Feditext](https://github.com/feditext/feditext) + - [Mastodon](https://apps.apple.com/us/app/mastodon-for-iphone-and-ipad/id1571998974) + +## Android + - [Tusky](https://tusky.app/) + - [Moshidon](https://lucasggamerm.github.io/moshidon/) + - [Megalodon](https://sk22.github.io/megalodon/) + - [Mastodon](https://play.google.com/store/apps/details?id=org.joinmastodon.android) diff --git a/BACKUP.md b/BACKUP.md new file mode 100644 index 0000000..18f7186 --- /dev/null +++ b/BACKUP.md @@ -0,0 +1,3 @@ +`yarn full:backup` +`yarn full:restore ` +既存互換で `yarn db:backup` / `yarn db:restore` も同じ処理 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3eff0aa --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,927 @@ +## v2026.5.1 +This release contains several critical security patches, as well as minor fixes and improvements. Upgrading is strongly recommended for all server operators. + +### Security +- Fixed signature bypass with certain keywords (reported by Mastodon) +- Fixed signature bypass with improper algorithm ordering +- Fixed XSS in emoji autocompleter +- Disabled hashtag channel + +### Miscellaneous +- Fixed service worker not properly loading + +### Attribution +This release was made possible by project contributors: mia + +Furthermore, I want to give special thanks to Mastodon for the vulnerability disclosure. + +## v2026.4.2 + +### Backend +- Fixed quote authorization stripping post metadata + +### Client +- Fixed broken navigation when "automatically reload when losing connection to the server" is enabled +- Hid voting options for logged out users + +### Miscellaneous +- Build system has been rewritten +- Dependencies have been updated + +### Attribution +This release was made possible by project contributors: mia + +## v2026.4.1 +This release contains multiple new features and bug fixes. Due to this release including database migrations, you will not be able to migrate from this version to Iceshrimp.NET until their migration script is updated. + +### Highlights +- Biting has been added + - You can bite users, notes, and bites + - You can control who is allowed to bite you +- Pronouns now have a dedicated field (only compatible with Iceshrimp.NET) +- Quotes now are compatible with Mastodon (FEP-044f) + - Users on Mastodon can quote your post and it will show up properly on their side + - You can quote users on Mastodon and it will show up properly on their side +- Unicode emojis up to version 16 are now supported + +### Miscellaneous +- Node.js v25 is now supported +- The offline page has been removed +- Authentication is now required to pull defederation data +- Usernames with periods in them are now pingable +- Poll refreshing should be more reliable + +### Attribution +This release was made possible by project contributors: AntoineÐ, banaanihillo, mia, nebby& & yukijoou + +## v2023.12.14 +This release contains a critical security patch, as well as several lower severity security patches. Upgrading is strongly recommended for all server operators. + +### Highlights +- A XSS vulnerability related to parsing of relative URLs has been fixed +- Media URLs are now always proxied, no matter the protocol in use +- AiScript endpoints are now validated more strictly +- Negative values for MFM scale nodes are now clamped +- Profile fields are now rendered as HTML for federation +- Summaly has been updated, resolving a SSRF vulnerability + +### Attribution +This release was made possible by project contributors: Laura Hausmann + +It also includes cherry-picked contributions from external contributors: dakkar (sharkey) + +Furthermore, I want to give special thanks to Hazel Koehler for the vulnerability disclosure. + +## v2023.12.13 +This release is identical to v2023.12.12, but no longer incorrectly identifies its version as v2023.12.11. + +## v2023.12.12 +This release contains a critical security patch, as well as minor fixes and improvements. Upgrading is strongly recommended for all server operators. + +### Highlights +- An unauthenticated SQL injection vulnerability inherited from calckey/firefish has been patched + +### Backend +- Negated searches now work with match:words +- The poll.votes option has been removed from the note edit service +- Edits that try to add or remove polls from existing notes now get rejected +- Emoji are now extracted from profile fields +- Blurhash failures are now handled gracefully + +### Miscellaneous +- Dependencies have been updated +- CI workflows have been updated +- Translations have been updated + +### Attribution +This release was made possible by project contributors: Fynh, Laura Hausmann, mia, sugar & zenja + +Furthermore, I want to give special thanks to Natty for the vulnerability disclosure. + +## v2023.12.11 +This release contains several critical security patches, as well as minor fixes and improvements. Upgrading is strongly recommended for all server operators. + +### Highlights +- Several DoS, impersonation, data leakage & click jacking vulnerabilities have been patched + +### Backend +- Various issues related to AP object validation have been resolved +- The ap/get API endpoint is now only available to administrators +- Blocks are now enforced in NoteRepository.isVisibleForMe +- Audience parsing no longer bypasses the AP recursion limit +- Edits of local-only notes are no longer federated out +- AP object URIs now get canonicalized before comparing them for consistency +- SSRF prevention now applies to all code paths + +### Attribution +This release was made possible by project contributors: Kopper & Laura Hausmann + +Furthermore, I want to give special thanks to Hazel Koehler for the vulnerability disclosure. + +## v2023.12.10 +This release contains a critical security patch, as well as minor fixes and improvements. Upgrading is strongly recommended for all server operators. + +### Highlights +- A DoS / DDoS / request amplification vulnerability has been patched +- Follower-only pinned notes are now fetched correctly +- Prometheus metrics support has been added + +### Backend +- Follower-only polls now update correctly +- The job queue was migrated to BullMQ (from the legacy Bull) +- Redis now uses the correct prefix for subscribe/notify +- HTTP 429 errors are now treated as retryable + +### Frontend +- The emoji picker now supports searching for emoji with uppercase letters +- Inline replies now render correctly on Chrome version >= 130 + +### Miscellaneous +- The Firefish migration guide was updated +- The README was updated +- Yarn telemetry has been disabled +- Various Dockerfile changes +- Various translation updates + +### Attribution +This release was made possible by project contributors: AverageDood, Laura Hausmann, Mae Dartmann, SWREI, ltlapy, mia, morotesovtannu & sugar + +## v2023.12.9 +This release contains a security patch, as well as minor fixes and improvements. Upgrading is strongly recommended for all server operators. + +### Highlights +- Several DoS vulnerabilities - allowing remote attackers to allocate arbitrary amounts of memory - were patched +- Corrupt jobs now get discarded instead of clogging up the failed queues + +### Backend +- Fetched JSON-LD contexts are now limited to 1MiB, resolving a DoS attack vector +- Fetched node-fetch responses are now limited to 1MiB/10MiB, resolving a DoS attack vector + +### Miscellaneous +- The docker images now use the bundled libvips version shipping with sharp instead of the system-wide one, reducing the image size by ~60MB +- The example docker-compose.yml file was updated +- The iceshrimp-js package was renamed to iceshrimp-sdk in order to prevent confusion should this repository be renamed to iceshrimp-js in the future (to distinguish it from Iceshrimp.NET) +- Various dependency updates +- Various translation updates + +### Attribution +This release was made possible by project contributors: AntoineÐ & Laura Hausmann + +## v2023.12.8 +This release contains minor fixes and improvements. Upgrading is recommended for all server operators. + +### Highlights +- AiScript has been updated to v0.17.0 +- Several new MFM functions have been added + +### Backend +- Pinned notes are rendered as links instead of objects, improving privacy +- Sporadic errors while scrolling through followers/following lists were fixed +- The link preview generator now sends a proper user agent instead of identifying itself as a generic bot +- The home timeline query heuristic now gets reset when follow lists are being imported, resolving a timeline performance edge case + +### Mastodon client API +- The verify_credentials response now includes the follow_requests_count property, improving compatibiltiy with certain Mastodon clients +- Attachments will now fall back to their full res version if they don't have a thumbnail, fixing a crash with the official Mastodon for Android app + +### Frontend +- The placeholder timetravel buttons have been removed +- The experiments page has been removed + +### Miscellaneous +- The helm chart was updated and now has more configuration options +- The yarn version was updated to v4.3.1 +- The README was updated to better reflect the current development situation in relation to the rewrite +- The project now builds against NodeJS 22 +- The dockerfile was updated & now builds against alpine 3.20 +- Backend dependencies have been updated +- The recommended key/value store was switched to valkey +- The CI configuration has been updated +- Various translation updates + +### Attribution +This release was made possible by project contributors: AverageDood, Gersonzao, Kopper, Laura Hausmann, Mae Dartmann, Pyrox, Tournesol, ari melody, limepotato, mia & zotan + +It also includes cherry-picked contributions from external contributors: naskya (firefish), ChaoticLeah (cutiekey) + +## v2023.12.7 +This is a security release. Upgrading is therefore strongly recommended. + +### Backend +- Incoming LD-signed activities are now compacted against a well-known context to defend against spoofing attacks +- The automatically followed account property no longer gets set to a random (possibly non-local) user on instance settings updates +- The TypeORM logger is now much more configurable +- The bull dashboard now has the correct cache-control headers set + +### Mastodon client API +- The quote_id property is now returned for note responses +- The note search query now sets the userId property correctly, solving the problem of mismatching search results between the web client and the Mastodon client API +- The user profile html cache now gets updated and queried using the correct timestamp for local users, resolving an issue of stale data being displayed in some circumstances + +### Miscellaneous +- The yarn version was updated to 4.1.1 +- The Dockerfile was updated to work better with some build systems that don't support cp -Tr +- The helm chart now has an option to set the number of worker threads + +### Attribution +This release was made possible by project contributors: Ezeani Emmanuel, Laura Hausmann, Mae Dartmann & mei23 + +Furthermore, I want to give special thanks to tesaguri for the security disclosure. + +## v2023.12.6 +This is a security release. Upgrading is therefore strongly recommended. + +### Backend +- When fetching activities, their identifiers are now validated much more strictly +- Drive files now have the `X-Content-Type-Options` header set to `nosniff` +- The queue dashboard path is now validated more strictly +- The AP object resolver logic was improved to better handle edge cases +- Poll notifications are no longer generated for muted notes + +### Frontend +- Remote (cross-origin) videos now plays properly +- Emoji reactions on the landing page timeline preview are now aligned properly + +### Mastodon client API +- The default reaction is now returned with /v1/instance + +### Miscellaneous +- The podman documentation was improved +- The example nginx config now has gzip enabled +- The Dockerfile now references the required dependencies for decoding AVIF images +- The installation requirements now mention postgresql-contrib +- Various translation updates + +### Attribution +This release was made possible by project contributors: CookiLover311, Crimekillz, Jegler, Laura Hausmann, Lilian, Norm, Salif Mehmed, jeder, konkonkon, naskya & 老周部落 + +Furthermore, I want to give special thanks to Oneric for the extraordinarily detailed security disclosure. + +## v2023.12.5 +This is a followup security release. Upgrading is recommended. + +### Backend +- When fetching activities, the JSON-LD profile is now enforced for responses with application/ld+json content type +- Incoming note edits with attachment and no alt text no longer get silently dropped + +### Attribution +This release was made possible by project contributors: Laura Hausmann + +## v2023.12.4 +This is a security release. Upgrading is therefore strongly recommended. + +### Backend +- The content type of fetched activities is now enforced +- Fetched activities' IDs must now match the hostname of the final request URL (after redirects) +- A typo in the activity audience parser was fixed, fixing federation of public posts with JSON-LD compliant remote instances + +### Mastodon client API +- The quote_id parameter is now supported when creating new posts +- The /v2/suggestions endpoint now requires the same scope as Mastodon (which differs from their API documentation) +- Full OAuth scopes (read/write/follow) are now also registered when expanding the authorized scopes list + +### Frontend +- Migrating from/to the same account twice no longer breaks the migration page + +### Miscellaneous +- The packaged yarn version (for NixOS) was updated to 4.1.0 +- Various translation updates + +### Attribution +This release was made possible by project contributors: Laura Hausmann, Pyrox & tournesol + +## v2023.12.3 +### Release notes +This is a security release. Upgrading is strongly recommended, as is adding an instance-wide announcement informing your users that if they previously imported posts from Mastodon, they should check their imported post history for DMs and follower-only posts that should not be public. + +### Vulnerability explanation +The Mastodon post import feature (that has been untouched since Iceshrimp was forked from Firefish last year) did not correctly validate/set post visibility on imported posts. Due to the nature of the vulnerability, it's impossible to reconstruct which posts have been imported, and therefore we cannot restrict access to them in an update. + +### Backend +- Post imports have been disabled +- Existing posts that have the "hidden" visibility are now only accessible to the author + +### Frontend +- The UI for post imports has been removed + +### Miscellaneous +- The yarn version was updated to 4.1.0 +- The helm chart was updated + +### Attribution +This release was made possible by project contributors: Laura Hausmann & corite + +## v2023.12.2 +### Release notes +This release contains minor fixes and improvements. Upgrading is recommended, especially if you have a lot of delayed jobs in your deliver queue. + +### Highlights +- Deliver jobs to dead/unresponsive instances will no longer get stuck in the deliver queue after 7 days of them being unresponsive + +### Backend +- Emojis that contain special characters now work properly + +### Miscellaneous +- Podman installation docs have been added +- The helm chart has been updated +- Locale files that were named incorrectly have been fixed +- Various translation updates + +### Attribution +This release was made possible by project contributors: Daks, Jeder, Laura Hausmann, Salif Mehmed, corite & jolupa + +It also includes cherry-picked contributions from external contributors: Johann150 + +## v2023.12.1 +### Release notes +This release contains performance improvements and minor bugfixes. Upgrading is recommended, especially if you are still experiencing performance issues with notifications and/or the home timeline. + +### Highlights +- Performance issues with the home timeline heuristics query as well as the notifications query have been resolved + +### UI/UX +- A bug in which an extra colon was shown at the end of some notifications has been resolved + +### Attribution +This release was made possible by project contributors: Laura Hausmann & mia + +## v2023.12 +### Release notes +This release contains only very minor changes if you're upgrading from `v2023.12-pre4`, but for users who skipped the prereleases, lots has changed. We primarily spent this release cycle on improving performance, we hope you enjoy a snappier experience! + +The information below is an *aggregate* of all release highlights since the last stable release. + +### Highlights +- Reworked full text search, retiring Meili/Sonic/Elastic in favor of Postgres gin_trgm with advanced search filter support +- Significantly improved backend & API performance across the board +- A HTML cache was added to the Mastodon client API, drastically improving performance (check the example config for more details & configuration options) +- Word mute filters were completely reworked for better performance, especially at scale +- A couple Mastodon OAuth regressions were fixed + +### Backend +- Unnecessary table joins were removed for the i/notifications API endpoint, improving performance + +### UI/UX +- The pages and gallery tab navigation was fixed +- The center and small MFM tags now autocomplete properly + +### Miscellaneous +- The documentation on creating a database during the install process was improved +- Dependencies were updated and deduplicated, saving disk space +- Migration docs for Firefish were added +- JetBrains AI was disabled globally in the monorepo +- Various translation updates + +### Attribution +This release was made possible by project contributors: AntoineÐ, AverageDood, Jeder, Laura Hausmann, Pyrox, Salif Mehmed & Tournesol + +## v2023.12-pre4 +This release preview primarily fixes bugs & regressions. Note: If you are upgrading from `-pre3` and had the HTML cache prewarm functionality enabled, you might want to clear it (`DELETE FROM "html_note_cache_entry";`), as quote URLs were not stored correctly due to an oversight. + +### Mastodon client API +- The html cache prewarm functionality now correctly includes quote URLs +- Follow status indicators now work properly in apps that rely on an undocumented Mastodon API behavior (e.g. toot!) + +### Backend +- Relative URLs are no longer proxied, fixing the local instance icon indicator in the default configuration + +### UI/UX +- The update check in the admin panel now works as expected +- Toggles now have outlines for better visibility +- The client error screen was improved with new colors and icons +- The Twitter integration was removed, as it hasn't been functional since their API changes +- The apps help button now links to a new page in the repository ([APPS.md](https://iceshrimp.dev/iceshrimp/iceshrimp/src/branch/dev/APPS.md)) + +### Miscellaneous +- The biome code formatter version and configuration were updated +- Various translation updates + +### Attribution +This release was made possible by project contributors: AntoineÐ, AverageDood, Froggo, Laura Hausmann, Minybol & Pyrox + +## v2023.12-pre3 +This release preview primarily contains performance optimizations and regression fixes. Upgrading is recommended especially if you're running a big instance or have more than a couple thousand entries in the `muted_note` table. + +### Highlights +- A HTML cache was added to the Mastodon client API, drastically improving performance (check the example config for more details & configuration options) +- Word mute filters were completely reworked for better performance, especially at scale +- A couple Mastodon OAuth regressions were fixed + +### Mastodon client API +- Notes that were filtered out due to hard word mutes are now returned to clients with the FilterResult property instead of being silently dropped +- Login with clients that leave a trailing `+` character in the scope parameter has been fixed +- Login with clients that depend on the `state` parameter in the OAuth process has been fixed + +### Backend +- updateUserProfileData now only triggers updateMentions once +- Word mute data is now stored in redis instead of the database, significantly improving timeline query performance for larger instances +- Database columns containing hostnames had their length increased to accomodate longer domain names + +### UI/UX +- Copy to clipboard now uses the modern async clipboard API and no longer applies weird formatting to copied text + +### Miscellaneous +- Various translation updates + +### Attribution +This release was made possible by project contributors: AverageDood, Laura Hausmann & Pyrox + +## v2023.12-pre2 +This release contains an important security fix. Upgrading is therefore strongly recommended. If you are on or want to upgrade to a stable release, please refer to the stable backport release [v2023.11.4](https://iceshrimp.dev/iceshrimp/iceshrimp/releases/tag/v2023.11.4) instead. + +### Added features +- A new setting was added that allows admins to specify an account that's automatically followed on user registration + +### Bug fixes +- HTTP signatures are now properly validated everywhere + +### UI/UX +- The gradient angles were adjusted to be in line with the design guidelines + +### Attribution +This release was made possible by project contributors: AntoineÐ, Latte macchiato & Laura Hausmann + +It also includes cherry-picked contributions from external contributors: perillamint, yunochi + +## v2023.12-pre1 +It's been a while, but it's time for another prerelease. This release cycle is going to primarily focus on performance, both in the backend and the frontend. + +Note: This release preview includes a lot of expensive migrations, which may take a while to run. We promise the performance benefits are worth the wait. + +### Highlights +- Reworked full text search, retiring Meili/Sonic/Elastic in favor of Postgres gin_trgm with advanced search filter support +- Significantly improved backend & API performance across the board + +### Backend +- Support for external search backends was removed +- Support for advanced search filters was added to the Postgres search backend +- The `search-by-username-and-host` API endpoint no longer excludes the local user making the request +- Renote status is now aggregated and returned with timeline responses instead of the client requesting it for each note individually +- Heuristics for which timeline query to use for each user were added, drastically improving worst case timeline performance +- Timeline queries were streamlined for improved performance, adding new multi-column indicies as appropriate +- User avatar and banner URL & blurhash were duplicated into the user table, drastically improving query performance by saving up to 6 joins per query +- The media proxy was reworked to not require a database query per requested file +- A per-request packed user cache was added to the web API to improve performance, mimicking the existing Mastodon client API implementation +- The web API now only fetches exactly as many notes as have been requested +- The `re2` dependency was updated, fixing builds on NixOS +- Environment variables that allow setting alternative locations for the config file, a second config file for secrets, the custom directory as well as the media directory were added +- The `followRequestAccepted` notification is no longer emitted for non-locked accounts +- The mfm-to-html renderer for outgoing ActivityPub messages was changed to happy-dom + +### Mastodon client API +- Search now also supports filters, using the same syntax as the web client +- NoteConverter and UserConverter now pre-aggregate applicable data in their respective `encodeMany` functions for improved performance +- The `user` column is now joined where applicable for improved performance +- The mfm-to-html renderer was changed to happy-dom, drastically improving timeline performance + +### UI/UX +- The search dialog was replaced with a proper search page, and now supports additional search filters +- A help page containing a list of all available search filters was added +- All references to post indexing were removed, as manual indexing is no longer required +- The search filter button is no longer visible in guest mode +- Inactive search tabs are no longer loaded +- Overscroll was disabled due to it causing graphical glitches and weird behavior, especially on desktop +- All images in timeline views now have the `loading="lazy"` and `decoding="async"` attributes set +- The URL card animation has been removed +- Additional posts are now loaded in before reaching the bottom of the timeline +- VueJS and Vite were updated to their respective latest versions + +### Infrastructure and governance +- Docker builds with populated BuildKit caches no longer break if the yarn cache changes + +### Miscellaneous +- References to external search backends were removed from the documentation & example configuration files +- The installation docs now contain information on the available environment variables +- The project readme was updated +- All project imports of the deprecated punycode node module were switched over to the punycode.js replacement +- Various translation updates + +### Attribution +This release was made possible by project contributors: AntoineÐ & Laura Hausmann + +## v2023.11.3 +This release contains yet more packaging and distribution-related changes, including some required for packaging the project for NixOS. + +### Backend +- The backslash character is now correctly escaped in `sqlLikeEscape`, fixing search queries containing backslashes + +### Infrastructure and governance +- The Dockerfile was streamlined and now builds the project with an immutable lockfile in the first stage + +### Miscellaneous +- The `focus-production` yarn script now also updates `.yarnrc.yml`, fixing builds in some packaging environments +- The default locale was changed to `en-US`, which should fix translation-related UI issues +- A new yarn script - `pack-yarn` - was added to assist with packaging the project on NixOS + +### Attribution +This release was made possible by project contributors: Jeder, Laura Hausmann & Pyrox + +## v2023.11.2 +This release primarily contains project maintenance changes. For the first time, we are also distributing binary packages! Currently we support Arch Linux, DEB & RPM based distributions will follow. + +### Highlights +- Lots of yarn script tweaks and additions, allowing for easier packaging and distribution +- Significantly reduced size of container images +- Binary packages for Arch Linux (DEB/RPM support to follow) + +### UI/UX +- The local-only icon is now consistent across different parts of the UI +- The `/about-iceshrimp` page was tweaked + +### Backend +- Running `yarn workspace backend run migration:revert` now exits properly instead of stalling +- Enabling 2FA when the instance is in private mode no longer locks users out of their account +- A typo in the name of the scope parameter for the `/oauth/token` endpoint was fixed +- The `/oauth/token` endpoint is now strictly compliant with the Mastodon API specification (note: their documentation does not match their implementation) + +### Infrastructure and governance +- Built Docker images now only contain runtime dependencies, decreasing image size significantly + +### Miscellaneous +- Yarn is now using the strict PnP mode, all peer dependencies that are broken upstream were patched +- A new yarn script, `focus-production`, was added. Running it will remove all dependencies that are not needed after building the project. Caution: only use for packaging, as this rewrites all `package.json` files in the project directory. +- A new yarn script, `regen-version`, was added. Running it will set the `version` attribute of the main `package.json` to `${tag}-dev-${git_revision}`. +- The installation documentation was updated +- Git LFS disclaimers were added to the documentation +- Yarn was updated to v4.0.2 +- Dependencies using `node-gyp` now build with all available threads +- The nix flake was updated to work properly with all recent changes +- The documentation no longer recommends git clones with `--depth=1` for most deployment types, as this is not really necessary anymore due to git-lfs +- Patches were merged into upstream `re2` and their `install-artifact-from-github` dependency, both fixing build on arm64-musl, and allowing for much faster prebuilt artifact installs +- The yarn script `dev` now only builds the project once +- The nix development documentation was updated +- The README badges were updated + +### Attribution +This release was made possible by project contributors: Alexis, AntoineÐ, Laura Hausmann & Pyrox + +## v2023.11.1 +### Release notes +This release primarily adds polish and fixes bugs and regressions introduced in the previous release cycle. If you are running `v2023.11` or earlier, upgrading is strongly recommended. + +### Highlights +- Builds on docker-arm64 (and on bare metal musl-arm64 distros) work as expected again +- Improved OAuth login page + +### Bug fixes +- The `node-re2` dependency was migrated to an in-house fork, fixing builds on musl-arm64 +- Tags in edited posts are now handled correctly +- Poll are now federating properly to non-\*key instances again +- Hovering over a link no longer renders a duplicate popover +- Various client settings that were previously missing from preference backups are now included +- Incoming poll edits are now processed correctly + +### UI/UX +- The "Centered" layout was removed +- The layout dropdown was replaced with a "toggle layout" button +- The "Modern" CW style now has the visual buttons match the clickable area +- The OAuth login page has been fully reworked to only show essential information +- Tooltips are no longer shown on touchscreen input +- The icon for "mark all notifications as read" was changed to `ph-checks` to better reflect the action +- A new client preferences category, "Wellness", was added, currently containing the option to hide certain UI elements like the new posts indicator, with more to come + +### Mastodon client API +- A regression in which remote posts with quotes attached had the quoteUri duplicated was fixed + +### Backend +- Local only notes are now not shown to guest users in timeline/non-detail views either +- Channels are no longer visible to guests +- User bios with MFM now federate properly with other \*key instances implementing the \_misskey_summary field +- The separate cache server was merged back into a unified (cache + queue processor) redis architecture, the respective config fields have been removed + +### Infrastructure and governance +- The CI workflows no longer reference cargo/rust +- Docker builds now use the yarn version specified in `package.json` instead of `yarn@stable` +- The README was updated to better reflect the project values + +### Miscellaneous +- The code formatter was changed from `rome` to `biome` +- The "Twitter (soon)" option for post imports has been removed +- The documentation now contains information on possible conflicts between the corepack and system yarn installations +- Various translation updates + +### Attribution +This release was made possible by project contributors: AntoineÐ, Aylam & Laura Hausmann + +It also includes cherry-picked contributions from external contributors: kakkokari-gtyih + +## v2023.11 +### Release notes +This release contains only very minor changes if you're upgrading from `v2023.11-pre5`, but for users who skipped the prereleases, lots has changed. Be sure to read the changelogs of all releases between the one you're upgrading from and this one, especially the sections on breaking changes. + +The information below is an *aggregate* of all breaking changes and release highlights since the last stable release. + +### Breaking changes +- Lists have been reworked, now only allowing followed users to be added, and support for proxy accounts has been removed. To allow users to follow any users they want to keep on their lists, the migration that removes all list members users are not following will only be activated in the release **after** the next stable release. It is therefore highly recommended to add an instance announcement informing your users of this change and advising them to follow any affected accounts and to use the new "hide from home timeline" list option if desired. +- The Mastodon client API now uses the same object identifiers as the Misskey API, as well as its own, separate OAuth backend. This means all existing sessions are now invalid. Please log out and back in again in your clients. + +### Highlights +- The Mastodon client API backend underwent a full rewrite, dropping megalodon as a dependency. Expect: + + Rich text formatting (mentions, links, hashtags, etc. are now properly formatted) + + Significantly improved API responsiveness - performance was improved by a factor of 2-5x (or more!) depending on the endpoint + + Better spec compliance & improved compatibility (we test against: Mona, toot!, Ice Cubes, Tusker, Feditext, Mastodon for iOS, Mastodon for Android/Megalodon/Moshidon, Tusky, Elk, Phanpy, Pinafore/Semaphore/Enafore and more) +- The Mastodon client API now supports the websocket streaming API +- Various bugs in the HTTP Link header pagination implementation were fixed +- The Mastodon client API now uses OAuth instead of MiAuth +- ActivityPub object lookups now respect redirects +- Significantly improved handling of mentions, both in outgoing AP messages and in the Mastodon client API +- Various Mastodon client API regressions are now fixed, improving client compatibility +- HTTP Signature validation error handling has been improved +- The project is now compatible with NodeJS >= 18.6 (tested against v21.1.0 at time of writing) + +### Miscellaneous +- The project is now compatible with NodeJS v21, tested against v21.1.0 at time of writing +- The nix dev environment was updated + +### Attribution +This release was made possible by project contributors: AntoineÐ, Aylam, Erin Shepherd, jeder, Laura Hausmann & Pyrox + +It also includes cherry-picked contributions from external contributors: Johann150 + +## v2023.11-pre5 +### Release notes +This release fixes a regression introduced in the last release preview. If you are running `v2023.11-pre3` or `v2023.11-pre4`, upgrading is strongly recommended. + +### Miscellaneous +- The commit that removed the `Mk` prefix from VueJS components has been reverted, as it caused various UI issues + +### Attribution +This release was made possible by project contributors: Laura Hausmann + +## v2023.11-pre4 +### Release notes +This release mostly fixes regressions introduced in the last release preview. If you are running `v2023.11-pre3`, upgrading is strongly recommended. + +### Mastodon client API +- The compatible version was bumped to 4.2.1, to indicate support for the "hide list members from home timeline" feature +- Remote users are now automatically refreshed in background + +### Backend +- Errors in refetchPublicKeyForApId can no longer cause strange inbox queue behavior +- Database transactions were refactored so no non-database code is run in transaction blocks, fixing a possible backend stall condition in which all database connections are blocked by transactions +- User profile mentions resolution no longer recurses infinitely, fixing a possible DoS attack vector + +### Miscellaneous +- The `Mk` prefix was removed from all custom VueJS components +- `.yarn/sdks` was updated to fix language server problems in VSCode +- Various translation updates + +### Attribution +This release was made possible by project contributors: AntoineÐ, Aylam, jeder & Laura Hausmann + +## v2023.11-pre3 +### Breaking changes +- Lists have been reworked, now only allowing followed users to be added, and support for proxy accounts has been removed. To allow users to follow any users they want to keep on their lists, the migration that removes all list members users are not following will only be activated in the release **after** the next stable release. It is therefore highly recommended to add an instance announcement informing your users of this change and advising them to follow any affected accounts and to use the new "hide from home timeline" list option if desired. + +### Highlights +- Significantly improved handling of mentions, both in outgoing AP messages and in the Mastodon client API +- Various Mastodon client API regressions are now fixed, improving client compatibility +- HTTP Signature validation error handling has been improved +- The project is now compatible with NodeJS >= 18.6 (tested against v20.8.1 at time of writing) + +### Mastodon client API +- Long redirect URIs are now handled correctly +- The `/v1/instance` endpoint now returns the correct streaming URL +- The `/v1/apps` response now returns all fields, including `vapid_key`, allowing for implementation of push notifications in the future +- Redirect URLs that contain double-urlencoded parts are now handled correctly +- The OAuth process now displays errors properly +- The hashtag timeline query is now case insensitive +- Statuses returned by all endpoints now have the `content_type` field populated +- The `/v1/instance` endpoint now correctly lists the supported mime types for statuses +- Hashtags now have the `class=hashtag` attribute set correctly +- Accounts returned by all endpoints now have the `fqn` field populated +- Inline quote URLs are now rendered properly by supported clients (e.g. Enafore) +- Mentions to accounts the instance was unable to resolve are now rendered as plain text +- Profile edits made using `/v1/update_credentials` are now federated properly +- An edge case where quotes were incorrectly detected as boosts was resolved +- Boosted quotes are now handled properly +- User profile data is now updated in the background when calling `/v1/accounts/:id` +- The `url` field in status objects now contains the url instead of the uri, whenever available +- Boosts by boost-muted users are now skipped in the Mastodon streaming API + +### Backend +- Migrations are finally in sync with the ORM, allowing for proper migrations handling in the future +- Mentions in user profiles are now resolved and stored in the database +- Invalid mentions in outgoing AP messages are now sent as plain text instead of an unreachable link pointing back at the origin instance +- When HTTP signature validation fails, an attempt to refresh the user's public key is now made, fixing federation with Mastodon instances who ran `tootctl accounts rotate` +- The error image override config is now loaded properly +- VAPID keys for WebPush are now properly generated when bootstrapping a new instance +- Capitalization of mentions is now corrected automatically, preventing federation issues where remote instances fail to render them +- Authorized fetch is now enabled by default for new instances +- NSFW detection & tensorflow have been removed +- HTTP signature validation now correctly verifies the hostname of the keyId against the hostname of the actor uri instead of the user's account domain, fixing an edge case where federation with split domain instances could fail +- Federation handshakes initiated by GoToSocial when the local instance has authorized fetch enabled are now handled correctly +- The `search-by-username-and-host` endpoint now doesn't filter out inactive users by default + +### UI/UX +- The default themes were tweaked +- The 'Explore' tab header now uses the correct icon + +### Miscellaneous +- Some unused files have been removed from the repository +- The code formatter now works properly for .vue files +- The discrepancy of different formatters using different tab widths was resolved +- The documentation now recommends using `git clone --depth=1` when cloning the repository to speed up the process +- The Dockerfile now doesn't run `yarn workspaces focus --production` because it doesn't actually save any space in the final image due to yarn zero installs +- A new yarn script, `start:debug`, was added to make attaching a debugger to the application easier +- Dependencies with critical vulnerabilities have been updated +- Various translation updates + +### Attribution +This release was made possible by project contributors: AntoineÐ, Aylam, Erin Shepherd & Laura Hausmann + +## v2023.11-pre2 +### Highlights +- An oversight in the OAuth helper that was preventing login to work in some Mastodon clients was fixed. + +## v2023.11-pre1 +### New versioning scheme +From now on we will use a JetBrains-like versioning scheme. Since our release candidates are more of a release preview, they can now be identified by the `-pre` suffix, followed by a number that increments with each following release preview. To maintain lexical sort order with previous releases from this year, we're starting the release counter at 11. That makes this release `v2023.11-pre1`. + +### Breaking changes +- The Mastodon client API now uses its own, separate OAuth backend. This means all existing sessions are now invalid. Please log out and back in again in your clients. + +### Highlights +- The Mastodon client API now uses OAuth instead of MiAuth +- ActivityPub object lookups now respect redirects + +### Mastodon client API +- Reactions with 0 reacts are no longer returned +- The 'next' part of the Link pagination header is no longer returned when there are less results than the set limit +- Remote mentions of local users are now rendered correctly +- Mentions now only display the handle, without the instance domain, mimicking Mastodon +- Code blocks are now rendered properly +- Mentions in user bios now work (most of the time) +- Quote URIs are now only appended to the post if the post doesn't already contain them +- Links are now rendered properly +- The streaming API now works for webclients running in Chrome and its derivatives +- /v1/instance now returns the field `max_toot_chars`, improving compatibility with some clients +- Edit history is now returned in the correct order +- Invalid remote mentions are now handled correctly +- User search autocomplete now works as one would expect +- The public:allow_local_only stream is now supported + +### UI/UX +- "NSFW content" was renamed to "sensitive content" +- The user mention picker now works correctly for remote users + +### Backend +- All migrations are now written in TypeScript +- Mentions in outgoing AP messages are now formatted correctly +- Trailing slashes for links in user profile fields are now only sent in AP messages if explicitly set +- Links in outgoing AP messages are now formatted correctly +- Mention parsing in incoming & outgoing AP messages now matches usernames case-insensitively +- The required VAPID keys for WebPush are now generated automatically + +### Miscellaneous +- A missing devDependency was added +- The unused check:connect script was removed + +### Attribution +This release was made possible by project contributors: aylamz & Laura Hausmann + +It also includes cherry-picked contributions from external contributors: Johann150 + +## v2023.10.11-rc1 +### Highlights +- The Mastodon client API now supports the websocket streaming API +- Various bugs in the HTTP Link header pagination implementation were fixed + +### Attribution +This release was made possible by project contributors: Laura Hausmann + +## v2023.10.08-rc1 +### Breaking changes +- The Mastodon client API now uses the standard alphanumeric ID format. This breaks pagination with existing Mastodon client sessions, if they cache user and/or post data. It is therefore strongly recommended that you either clear the client's cache (if it exposes such a button), its data (if your OS supports this), log out and in again, or in the worst case reinstall any clients with active sessions, especially if you notice strange timeline behavior or unexplained "Record not found" errors. + +### Highlights +- The Mastodon client API backend underwent a full rewrite, dropping megalodon as a dependency. Expect: + + Rich text formatting (mentions, links, hashtags, etc. are now properly formatted) + + Significantly improved API responsiveness - performance was improved by a factor of 2-5x (or more!) depending on the endpoint + + Better spec compliance & improved compatibility (we test against: Mona, toot!, Ice Cubes, Tusker, Feditext, Mastodon for iOS, Mastodon for Android/Megalodon/Moshidon, Tusky, Elk, Phanpy, Pinafore/Semaphore/Enafore and more) + +### Bug Fixes +- The update checker now works properly with the new versioning scheme +- The control panel indicator is now displayed correctly +- Countless Mastodon client API bugs have been resolved + +### Backend +- Note edits (of local users) have been completely reworked, now storing the correct history and no longer accepting nonsensical parameters (like changing the reply target) that don't federate properly if at all + +### UI/UX +- The calendar widget is now disabled by default +- The navigation buttons on mobile have been improved +- The default themes now have proper shadows +- Post headers no longer have text shadow + +### Miscellaneous +- The documentation now mentions PGTune +- Private mode descriptions now refer to 'allowlists' instead of an outdated term +- Various translation updates + +### Attribution +- This release was made possible by project contributors: Alexis, AntoineÐ, Aylam & Laura Hausmann + +## v2023.10.04 +### Highlights +- New logos, themes & brand colors +- All rust code has been removed (less jank, significantly faster build times) + +### Bug Fixes +- Post boost counts can no longer become negative + +### Performance +- User note lookups are now significantly faster + +### Miscellaneous +- Minor iconograpgy changes +- Translation updates + +### Infrastructure +- Docker builds are now versioned + +### Attribution +This release was made possible by project contributors: AntoineÐ, Aylam, Jeder, Laura Hausmann & moshibar + +## v2023.09.13-rc1 +### Highlights +- New branding & documentation +- Proper support for split domain deployments, both local and remote +- [Configurable](https://iceshrimp.dev/iceshrimp/iceshrimp/src/commit/f3c1e4efd30e660372a652a7b43fdb63e2817bae/.config/example.yml#L193-L198) automatic remote media pruning (disabled by default) +- Reworked content warnings (three different styles for CW'd posts, 'Expand all CWs in thread' button, 'Expand all CWs by default' client option) + +### Bug fixes +- CW-only quotes now function correctly +- Relative timestamps (*1m ago*) are now updated as time passes +- Replies to inaccessible posts are now displayed correctly instead of causing timeline errors +- Antenna pagination is now handled correctly, including for posts received out of order +- Inbox URLs are now checked in the deliver manager (a broken akkoma commit was briefly causing delivery queue crashes) +- The chats page title no longer occasionally displays *undefined* +- Fixed an edge case where account deletion could time out +- Antennas now also match on CW text +- Local only posts now correctly display on the timeline without having to reload +- The migration that moves antennas to the redis/dragonflydb cache server now works with password protected redis servers +- You can now no longer edit a post to include a quote of itself +- Post edits no longer support post visibility changes +- Full text search is now restricted to logged in users +- Local only posts are no longer accessible to guest users +- The web client now shows local users with the instance account domain instead of the web domain +- New replies in a thread are now displayed correctly +- User update no longer fails for users who don't have a `sharedInbox` +- Follow requests now paginate properly +- Fetching pinned posts from users on GoToSocial instances (or other AP implementations that return a collection of URIs instead of objects) now works properly + +### UI/UX +- Ads, donation nag prompts & the patreon integration have been removed +- The blinking notification indicator has been replaced with a static one +- Replies to inaccessible posts now have an indicator explainin this +- Protected posts now have a lock indicator instead of a disabled boost button +- The navbar editor now has a proper UI +- The instance ticker is now much more readable in light mode +- The post visibility picker is now mobile-optimized +- The search button in the guest view is now a button instead of a fake search bar +- Blur is now disabled by default +- When blur is disabled, UI elements are now properly opaque +- The antenna timeline now has a help text explaining why posts can be out of order +- Status images have been replaced with [configurable](https://iceshrimp.dev/iceshrimp/iceshrimp/src/commit/3afbaacc3773ac0772204d872126d37309302562/.config/example-docker.yml#L201-L205) status emoji +- The navbar layout has been tweaked +- Various inconsistencies as well as alignment & animation issues have been fixed + +### Mastodon client API +- /api/v1/instance is now more accurate +- Emoji reactions are now supported +- The 'pinned' parameter is now supported for individual profile timelines +- Improved handling for quotes +- Post edits are now supported +- Post deletion now returns the correct response +- OAuth registration now correctly supports multiple callback URIs + +### Backend +- `Cache` `.getAll` and `.delete` functions now work as expected +- Deleted users are now purged from user lookup and public key caches +- Proper support for host-meta style WebFinger +- Stricter compliance with the WebFinger spec +- Support for WebFinger remotes that don't handle queries for object URIs correctly + +### Performance +- The project is now built with yarn berry (with zero installs) instead of pnpm +- The docker build process now properly caches rust and yarn deps +- The migration rust crate now builds much faster + +### Miscellaneous +- The MFM search engine is now [configurable](https://iceshrimp.dev/iceshrimp/iceshrimp/src/commit/afd9ffb3c728b143c6d3d4d3dd8562ec6bde3a91/.config/example.yml#L206-L207) +- Various translation updates + +### Infrastructure and governance +- Commits are now tested with basic CI on push +- Docker builds are now automatic for amd64 and arm64 +- The [code of conduct](CODE_OF_CONDUCT.md) has been updated + +### Attribution +This release was made possible by project contributors: Anthial, AntoineÐ, April John, aylamz, Froggo, Jeder, Laura Hausmann, Luna, maikelthedev, moshibar, ShittyKopper & Vyr Cossont + +It also includes cherry-picked contributions from external contributors: Namekuji, Natty, ThatOneCalculator & Naskya + +--- + +This file lists all major changes made since the fork from Firefish on 2023-07-21. For changes prior to that date, please reference the [Firefish](https://firefish.dev/firefish/firefish/-/tree/76ad0a19b3a64eaecb4da31f20c229e423ee2095/) repository. diff --git a/CODEX_FEATURES.md b/CODEX_FEATURES.md new file mode 100644 index 0000000..eaa9612 --- /dev/null +++ b/CODEX_FEATURES.md @@ -0,0 +1,111 @@ +# Codexで追加・修正した機能一覧 + +このファイルは、この作業ツリー上でCodexが実装した変更を追跡するためのメモです。 + +## v267F.0 + +- 管理者がプラン種別を追加・更新・削除できる管理APIと管理画面を追加。 +- 管理者がユーザーを任意のプランへ加入・解除できる機能を追加。 +- 加入中プランのアイコンをユーザー名横に表示するように変更。 +- バージョン名を `v267F.0` に更新。 + +## v267E.3 + +- 添付ファイルの閲覧にEPUBビューアを追加。 + - EPUB3表示、EPUB内JavaScript、インタラクティブコンテンツ、外部CDN参照に対応。 + - EPUB実行環境は隔離iframe内に配置し、SNS本体の画面とは分離。 +- EPUBファイルをブラウザ表示可能なMIMEとして扱うように変更。 +- バージョン名を `v267E.3` に更新。 + +## v267E.2 + +- ゆめくじを左メニューの将棋の直後に移動。 +- 動画・音声・画像・カラオケの特設サイトから、必要メディアを添付したタグ付き投稿を開始できるように変更。 +- Lua4Frozenトップページから、Luaファイルを添付した `#Lua4Frozen` 投稿を開始できるように変更。 +- ハッシュタグ検索ページから、表示中のハッシュタグを入れた投稿を開始できるように変更。 +- 期限切れで削除される自分のMemorietを再投稿用アーカイブとして保存し、削除済み一覧から再投稿できるように変更。 +- Memorietのテキストレイヤーをプレビュー上で直接入力・ドラッグ移動できるように変更。 +- Lua4FrozenにCanvasの1つ上へ表示する `ffy.svg` SVGベクタレイヤーを追加。 +- バージョン名を `v267E.2` に更新。 + +## v267C.7.2 + +- バージョン名を `v267C.7.2` に更新。 + +## v267C.2 + +- クライアント起動時に汎用エラー画面へ落ちる問題を修正。 + - backend単体ビルドでも現在バージョンのロケールJSONを生成するように変更。 + - boot scriptが古い `localStorage.v` を優先して存在しないロケールJSONを取りに行かないように変更。 + - ViteのNode組み込みモジュール空スタブを削除し、クライアント実行時の依存解決を復元。 +- バージョン名を `v267C.2` に更新。 + +## v267C.7.1 + +- クライアントビルド時のVite警告を抑制。 + - ブラウザで不要なNode組み込みモジュール参照に空スタブを割り当て。 + - 実行に影響しない動的import/チャンクサイズ警告をビルドログに出さない設定に変更。 +- サーバ起動時の警告を抑制。 + - 通常起動を `NODE_ENV=production` に変更。 + - `DEP0060` の既知依存警告を抑制。 +- 起動ログのバージョン表示が `vv...` にならないよう修正。 +- バージョン名を `v267C.7.1` に更新。 + +## v267C.7 + +- Iceshrimp最新版の状況確認に基づき、既存JS版の方針に近い小さなQoL機能として「ゆめくじ」ページを追加。 + - `/yume-fortune` からアクセス可能。 + - サイドバー項目として追加。 + - Aboutページからもアクセス可能。 + - 今日の結果、引き直し、投稿共有に対応。 +- テーマ名を整理。 + - 既存の `FrozenFriendsYume Light` を `Iceshrimp Light` に戻した。 + - 既存の `FrozenFriendsYume Dark` を `Iceshrimp Dark` に戻した。 + - 新しい `FrozenFriendsYume Light` / `FrozenFriendsYume Dark` を追加。 +- PostgreSQLデータベースのバックアップ/リストアスクリプトを追加。 + - `yarn db:backup` + - `yarn db:restore ` +- バージョン名を `v267C.7` に更新。 + +## v267C.6 + +- 動画サービス・音声サービス・Lua4Frozen・カラオケの閲覧数カウント仕様を変更。 + - 動画/音声は再生開始時にカウント。 + - Lua4Frozenは実行ボタン押下時にカウント。 + - カラオケは歌唱開始時にカウント。 + - 同一ユーザーまたは同一IPは1分あたり1回までカウント。 + - カウント制限に当たっても再生・実行・歌唱自体は継続。 +- サービス投稿は通常のノート表示では自動閲覧数加算しないように変更。 +- スマホ表示時に将棋ページ右側が見切れないようにレイアウトを調整。 +- クライアント更新通知を「アップデートしました!バージョン{version}」形式に変更。 +- バージョン名を `v267C.6` に更新。 + +## 広告関連 + +- 広告クリック時に「問題が発生しました」画面になる問題を修正。 + - `adservice` 投稿を広告表示経由で取得できるように、表示権限判定へ広告用途の例外を追加。 +- 広告が一般ユーザーに表示されたときにクレジットを消費するように変更。 +- 広告主本人、管理者、モデレーター、botが広告を閲覧した場合はクレジットを消費しないように変更。 +- コントロールパネルの広告一覧に「失効クレジット」を追加。 + - 期限内に消費されず、期限切れになった残クレジットを表示。 + +## 接続・ログ安定化 + +- `write EPIPE`、`read ECONNRESET`、`ERR_STREAM_DESTROYED` など、切断済み接続への書き込みで発生するノイズを抑制。 +- ファイル配信・プロキシ・API・サーバー起動部で無害な接続切断エラーを無視。 +- `latest-version` APIの外部取得に短いタイムアウトを追加し、取得失敗時にログを荒らさず `tag_name: null` を返すように変更。 +- Node.jsの `punycode` 非推奨警告を既知ノイズとして抑制。 +- 接続が切れたとき、クライアント右下に「接続が切れました。」と「リロードする」ボタンを表示。 + - 右上の閉じるボタンで非表示にできる。 + +## IPアドレス処理 + +- IPハッシュ化処理で不正なIPv4アドレスにより落ちる問題を修正。 + - `X-Forwarded-For` のカンマ区切り値を正規化。 + - `IPv4:port` と角括弧付きIPv6を正規化。 + - 不正値は安全なフォールバックに流すように変更。 + +## 国際化 + +- 追加機能で不足していた日本語・英語の文言を追加。 +- 更新通知用の日本語・英語文言を追加。 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..b5d4c03 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,41 @@ + +# Iceshrimp Community Code of Conduct + +The Iceshrimp Code of Conduct is a set of guidelines that explains how our community behaves and what we value as members and project to others. This Code of Conduct is a living document and will be updated when and as deemed necessary. + +The Code of Conduct does not seek to restrict speech or penalize non-native speakers of English or any other language. Instead the Code of Conduct spells out the kinds of behaviors we, as a community, find to be acceptable and unacceptable. + +It is important to assume good faith and remember that many of our contributors may have different backgrounds which could color their approach in all things. + +## Conduct + +- We are committed to providing a friendly, safe and welcoming environment for all, regardless of level of experience, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, nationality, or other similar characteristic. +- Please avoid using overtly sexual aliases or other nicknames that might detract from a friendly, safe and welcoming environment for all. +- Please be kind and courteous. There's no need to be mean or rude. +- Respect that people have differences of opinion and that every design or implementation choice carries a trade-off and numerous costs. There is seldom a right answer. +- We will exclude you from interaction if you insult, demean or harass anyone. That is not welcome behavior. We interpret the term “harassment” as including the definition in the [Citizen Code of Conduct](https://github.com/stumpsyn/policies/blob/master/citizen_code_of_conduct.md#4-unacceptable-behavior); if you have any lack of clarity about what might be included in that concept, please read their definition. In particular, we don't tolerate behavior that excludes people in socially marginalized groups. +- Private harassment is also unacceptable. No matter who you are, if you feel you have been or are being harassed or made uncomfortable by a community member, please contact conduct@iceshrimp.dev. Whether you're a regular contributor or a newcomer, we care about making this community a safe place for you and we've got your back. +- Likewise any spamming, trolling, flaming, baiting or other attention-stealing behavior is not welcome + +Try to avoid flirting with offensive or sensitive issues, particularly if they're off-topic; this all too often leads to unnecessary fights, hurt feelings, and damaged trust; worse, it can drive people away from the community entirely. + +And if someone takes issue with something you said or did, resist the urge to be defensive. Just stop doing what it was they complained about and apologize. Even if you feel you were misinterpreted or unfairly accused, chances are good there was something you could've communicated better — remember that it's your responsibility to make your community members comfortable. Everyone wants to get along and we are all here first and foremost because we want to talk about and develop cool technology. You will find that people will be eager to assume good intent and forgive as long as you earn their trust. + +## Moderation + +**People who have an issue or questions about a potential Code of Conduct violation can raise it by writing an e-mail to [conduct@iceshrimp.dev](mailto:conduct@iceshrimp.dev)** + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Attribution + +This Code of Conduct is derived from openSUSE and Rust Code of Conduct documents. + +[Rust Code of Conduct](https://www.rust-lang.org/policies/code-of-conduct) + +[openSUSE Community Code of Conduct](https://code.opensuse.org/project/coc/blob/main/f/Code-of-Conduct.md) + +Some parts are also adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..7efdcc7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,274 @@ +# Contribution guide +We're glad you're interested in contributing Iceshrimp! In this document you will find the information you need to contribute to the project. + +## Translation (i18n) +Iceshrimp uses [Weblate](https://translate.iceshrimp.dev/) for translation and internationalization management. + +If your language is not listed in Weblate, please open an issue. + +You can contribute without knowing how to code by helping translate here: + +[![Translation status](https://translate.iceshrimp.dev/widgets/iceshrimp/-/287x66-grey.png)](https://translate.iceshrimp.dev/) + +[![Translation bars](https://translate.iceshrimp.dev/widgets/iceshrimp/-/multi-auto.svg)](https://translate.iceshrimp.dev/) + + +## Issues +Before creating an issue, please check the following: +- To avoid duplication, please search for similar issues before creating a new issue. +- Do not use Issues to ask questions or troubleshooting. + - Issues should only be used to feature requests, suggestions, and bug tracking. + - Please ask questions or troubleshooting in the [Matrix room](https://matrix.to/#/#iceshrimp-dev:161.rocks). + +> **Warning** +> Do not close issues that are about to be resolved. It should remain open until a commit that actually resolves it is merged. + +## Before implementation +When you want to add a feature or fix a bug, **first have the design and policy reviewed in an Issue** (if it is not there, please make one). Without this step, there is a high possibility that the PR will not be merged even if it is implemented. + +At this point, you also need to clarify the goals of the PR you will create, and make sure that the other members of the team are aware of them. +PRs that do not have a clear set of do's and don'ts tend to be bloated and difficult to review. + +Also, when you start implementation, assign yourself to the Issue (if you cannot do it yourself, ask another member to assign you). By expressing your intention to work the Issue, you can prevent conflicts in the work. + +## Well-known branches +- The **`main`** branch is tracking the latest release and used for production purposes. +- The **`develop`** branch is where we work for the next release. + - When you create a PR, basically target it to this branch. **But create a different branch** +- The **`l10n_develop`** branch is reserved for localization management. +- **`feature/*`** branches are reserved for the development of a specific feature + +## Creating a PR +Thank you for your PR! Before creating a PR, please check the following: +- If possible, prefix the title with a keyword that identifies the type of this PR, as shown below. + - `fix` / `refactor` / `feat` / `enhance` / `perf` / `chore` etc. You are also welcome to use gitmoji. This is important as we use these to A) easier read the git history and B) generate our changelog. Without propper prefixing it is possible that your PR is rejected. + - Also, make sure that the granularity of this PR is appropriate. Please do not include more than one type of change or interest in a single PR. +- If there is an Issue which will be resolved by this PR, please include a reference to the Issue in the text. Good examples include `Closing: #21` or `Resolves: #21` +- Check if there are any documents that need to be created or updated due to this change. +- If you have added a feature or fixed a bug, please add a test case if possible. +- Please make sure that formatting, tests and Lint are passed in advance. + - You can run it with `pnpm run format`, `pnpm run test` and `pnpm run lint`. [See more info](#testing) +- If this PR includes UI changes, please attach a screenshot in the text. + +Thanks for your cooperation 🤗 + +## Reviewers guide +Be willing to comment on the good points and not just the things you want fixed 💯 + +### Review perspective +- Scope + - Are the goals of the PR clear? + - Is the granularity of the PR appropriate? +- Security + - Does merging this PR create a vulnerability? +- Performance + - Will merging this PR cause unexpected performance degradation? + - Is there a more efficient way? +- Testing + - Does the test ensure the expected behavior? + - Are there any omissions or gaps? + - Does it check for anomalies? + +## Deploy (SOON) +The `/deploy` command by issue comment can be used to deploy the contents of a PR to the preview environment. +``` +/deploy sha= +``` +An actual domain will be assigned so you can test the federation. + +## Merge + +## Release +### Release Instructions +1. Commit version changes in the `develop` branch ([package.json](https://github.com/misskey-dev/misskey/blob/develop/package.json)) +2. Create a release PR. + - Into `master` from `develop` branch. + - The title must be in the format `Release: x.y.z`. + - `x.y.z` is the new version you are trying to release. +3. Deploy and perform a simple QA check. Also verify that the tests passed. +4. Merge it. +5. Create a [release of GitHub](https://github.com/misskey-dev/misskey/releases) + - The target branch must be `master` + - The tag name must be the version + +## Development +During development, it is useful to use the `yarn dev` command. +This command monitors the server-side and client-side source files and automatically builds them if they are modified. +In addition, it will also automatically start the Misskey server process. + + +# THE FOLLOWING IS OUTDATED: + +## Testing +- Test codes are located in [`/test`](/test). + +### Run test +Create a config file. +``` +cp test/test.yml .config/ +``` +Prepare DB/Redis for testing. +``` +docker-compose -f test/docker-compose.yml up +``` +Alternatively, prepare an empty (data can be erased) DB and edit `.config/test.yml`. + +Run all test. +``` +yarn test +``` + +#### Run specify test +``` +TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT="./test/tsconfig.json" yarn dlx mocha test/foo.ts --require ts-node/register +``` + +### e2e tests +TODO + +## Continuous integration +Misskey uses GitHub Actions for executing automated tests. +Configuration files are located in [`/.github/workflows`](/.github/workflows). + +## Vue +Misskey uses Vue(v3) as its front-end framework. +- Use TypeScript. +- **When creating a new component, please use the Composition API (with [setup sugar](https://v3.vuejs.org/api/sfc-script-setup.html) and [ref sugar](https://github.com/vuejs/rfcs/discussions/369)) instead of the Options API.** + - Some of the existing components are implemented in the Options API, but it is an old implementation. Refactors that migrate those components to the Composition API are also welcome. + +## nirax +niraxは、Misskeyで使用しているオリジナルのフロントエンドルーティングシステムです。 +**vue-routerから影響を多大に受けているので、まずはvue-routerについて学ぶことをお勧めします。** + +### ルート定義 +ルート定義は、以下の形式のオブジェクトの配列です。 + +``` ts +{ + name?: string; + path: string; + component: Component; + query?: Record; + loginRequired?: boolean; + hash?: string; + globalCacheKey?: string; + children?: RouteDef[]; +} +``` + +> **Warning** +> 現状、ルートは定義された順に評価されます。 +> たとえば、`/foo/:id`ルート定義の次に`/foo/bar`ルート定義がされていた場合、後者がマッチすることはありません。 + +### 複数のルーター +vue-routerとの最大の違いは、niraxは複数のルーターが存在することを許可している点です。 +これにより、アプリ内ウィンドウでブラウザとは個別にルーティングすることなどが可能になります。 + +## Notes +### How to resolve conflictions occurred at yarn.lock? + +Just execute `yarn` to fix it. + +### INSERTするときにはsaveではなくinsertを使用する +#6441 + +### placeholder +SQLをクエリビルダで組み立てる際、使用するプレースホルダは重複してはならない +例えば +``` ts +query.andWhere(new Brackets(qb => { + for (const type of ps.fileType) { + qb.orWhere(`:type = ANY(note.attachedFileTypes)`, { type: type }); + } +})); +``` +と書くと、ループ中で`type`というプレースホルダが複数回使われてしまいおかしくなる +だから次のようにする必要がある +```ts +query.andWhere(new Brackets(qb => { + for (const type of ps.fileType) { + const i = ps.fileType.indexOf(type); + qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { [`type${i}`]: type }); + } +})); +``` + +### Not `null` in TypeORM +```ts +const foo = await Foos.findOne({ + bar: Not(null) +}); +``` +のようなクエリ(`bar`が`null`ではない)は期待通りに動作しない。 +次のようにします: +```ts +const foo = await Foos.findOne({ + bar: Not(IsNull()) +}); +``` + +### `null` in SQL +SQLを発行する際、パラメータが`null`になる可能性のある場合はSQL文を出し分けなければならない +例えば +``` ts +query.where('file.folderId = :folderId', { folderId: ps.folderId }); +``` +という処理で、`ps.folderId`が`null`だと結果的に`file.folderId = null`のようなクエリが発行されてしまい、これは正しいSQLではないので期待した結果が得られない +だから次のようにする必要がある +``` ts +if (ps.folderId) { + query.where('file.folderId = :folderId', { folderId: ps.folderId }); +} else { + query.where('file.folderId IS NULL'); +} +``` + +### `[]` in SQL +SQLを発行する際、`IN`のパラメータが`[]`(空の配列)になる可能性のある場合はSQL文を出し分けなければならない +例えば +``` ts +const users = await Users.find({ + id: In(userIds) +}); +``` +という処理で、`userIds`が`[]`だと結果的に`user.id IN ()`のようなクエリが発行されてしまい、これは正しいSQLではないので期待した結果が得られない +だから次のようにする必要がある +``` ts +const users = userIds.length > 0 ? await Users.find({ + id: In(userIds) +}) : []; +``` + +### 配列のインデックス in SQL +SQLでは配列のインデックスは**1始まり**。 +`[a, b, c]`の `a`にアクセスしたいなら`[0]`ではなく`[1]`と書く + +### null IN +nullが含まれる可能性のあるカラムにINするときは、そのままだとおかしくなるのでORなどでnullのハンドリングをしよう。 + +### `undefined`にご用心 +MongoDBの時とは違い、findOneでレコードを取得する時に対象レコードが存在しない場合 **`undefined`** が返ってくるので注意。 +MongoDBは`null`で返してきてたので、その感覚で`if (x === null)`とか書くとバグる。代わりに`if (x == null)`と書いてください + +### Migration作成方法 +packages/backendで: +```sh +yarn workspace backend run generatemigration src/migration/ +``` + +- 生成後、ファイルをmigration下に移してください +- 作成されたスクリプトは不必要な変更を含むため除去してください + +### コネクションには`markRaw`せよ +**Vueのコンポーネントのdataオプションとして**misskey.jsのコネクションを設定するとき、必ず`markRaw`でラップしてください。インスタンスが不必要にリアクティブ化されることで、misskey.js内の処理で不具合が発生するとともに、パフォーマンス上の問題にも繋がる。なお、Composition APIを使う場合はこの限りではない(リアクティブ化はマニュアルなため)。 + +### JSONのimportに気を付けよう +TypeScriptでjsonをimportすると、tscでコンパイルするときにそのjsonファイルも一緒にdistディレクトリに吐き出されてしまう。この挙動により、意図せずファイルの書き換えが発生することがあるので、jsonをimportするときは書き換えられても良いものかどうか確認すること。書き換えされて欲しくない場合は、importで読み込むのではなく、`fs.readFileSync`などの関数を使って読み込むようにすればよい。 + +### コンポーネントのスタイル定義でmarginを持たせない +コンポーネント自身がmarginを設定するのは問題の元となることはよく知られている +marginはそのコンポーネントを使う側が設定する + +## その他 +### HTMLのクラス名で follow という単語は使わない +広告ブロッカーで誤ってブロックされる diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..783b54c --- /dev/null +++ b/COPYING @@ -0,0 +1,30 @@ +Unless specified otherwise, the entirety of this repository is subject to the following: +Copyright © 2014-2023 syuilo and contributors +Copyright © 2022-2023 Kainoa Kanter and contributors +Copyright © 2023-2023 The Iceshrimp contributors + +And is distributed under The GNU Affero General Public License Version 3, you should have received a copy of the license file as LICENSE. + +--- + +These specific configuration directories: + +- .config/ +- custom/assets/ + +and their contents are +Copyright © 2023 The Iceshrimp contributors + +And are distributed under The Apache License, Version 2.0, you should have received a copy of the license file as LICENSE in each specified directory. + +--- + +Iceshrimp includes several third-party open-source softwares and software libraries. + +RsaSignature2017 implementation by Transmute Industries Inc +License: MIT +https://github.com/transmute-industries/RsaSignature2017/blob/master/LICENSE + +Licenses for all softwares and software libraries installed via the Node Package Manager ("npm") can be found by running the following shell command in the root directory of this repository: + +`yarn -R info --manifest` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..259b0be --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# syntax = docker/dockerfile:1.2 +## Install dev and compilation dependencies, build files +FROM docker.io/library/alpine:edge as build +LABEL stage=build +WORKDIR /iceshrimp + +# Install compilation dependencies +RUN apk add --no-cache --no-progress git alpine-sdk python3 py3-setuptools nodejs npm linux-headers brotli + +# Copy in all files for the build +COPY . ./ + +# Prepare yarn cache +RUN --mount=type=cache,target=/iceshrimp/.yarncache cp -r .yarncache/. .yarn + +# Configure corepack and install dev mode dependencies for compilation +RUN npm install -g corepack && corepack enable && corepack prepare --activate && yarn --immutable + +# Save yarn cache +RUN --mount=type=cache,target=/iceshrimp/.yarncache rm -rf .yarncache/* && cp -r .yarn/. .yarncache + +# Build the thing +RUN env NODE_ENV=production yarn build + +# Optimize +RUN env NODE_ENV=production yarn build:optimize + +# Prepare focused yarn cache +RUN --mount=type=cache,target=/iceshrimp/.yarncache_focused cp -r .yarncache_focused/. .yarn + +# Remove dev deps +RUN yarn focus-production + +# Save focused yarn cache +RUN --mount=type=cache,target=/iceshrimp/.yarncache_focused rm -rf .yarncache/* && cp -r .yarn/. .yarncache_focused + +## Runtime container +FROM docker.io/library/alpine:edge +LABEL stage=runtime +WORKDIR /iceshrimp + +# Install runtime dependencies +RUN apk add --no-cache --no-progress tini ffmpeg zip unzip nodejs npm libheif-dev + +# Copy built files +COPY --from=build /iceshrimp /iceshrimp + +# Configure corepack +RUN npm install -g corepack && corepack enable && corepack prepare --activate + +# Remove unnecessary npm +RUN apk del npm + +ENV NODE_ENV=production +VOLUME "/iceshrimp/files" +ENTRYPOINT [ "/sbin/tini", "--" ] +CMD [ "yarn", "run", "migrateandstart" ] diff --git a/LICENSE b/LICENSE index dccda50..dba13ed 100644 --- a/LICENSE +++ b/LICENSE @@ -1,235 +1,661 @@ -GNU AFFERO GENERAL PUBLIC LICENSE -Version 3, 19 November 2007 + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 -Copyright (C) 2007 Free Software Foundation, Inc. - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. Preamble -The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. -The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. -When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. -Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. -A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. -The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. -An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. -The precise terms and conditions for copying, distribution and modification follow. + The precise terms and conditions for copying, distribution and +modification follow. TERMS AND CONDITIONS -0. Definitions. + 0. Definitions. -"This License" refers to version 3 of the GNU Affero General Public License. + "This License" refers to version 3 of the GNU Affero General Public License. -"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. -"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. -To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. -A "covered work" means either the unmodified Program or a work based on the Program. + A "covered work" means either the unmodified Program or a work based +on the Program. -To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. -To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. -An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. -1. Source Code. -The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. + 1. Source Code. -A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. -The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. -The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those subprograms and other parts of the work. -The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. - -The Corresponding Source for a work in source code form is that same work. - -2. Basic Permissions. -All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. - -You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. - -Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. - -3. Protecting Users' Legal Rights From Anti-Circumvention Law. -No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. - -When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. - -4. Conveying Verbatim Copies. -You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. - -You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. - -5. Conveying Modified Source Versions. -You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". - - c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. - -A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. - -6. Conveying Non-Source Forms. -You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: - - a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. - - d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. - -A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. - -A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. - -"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. - -If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). - -The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. - -Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. - -7. Additional Terms. -"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. - -When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. - -Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or authors of the material; or - - e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. - -All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. - -If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. - -Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. - -8. Termination. - -You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). - -However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. - -Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. - -Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. - -9. Acceptance Not Required for Having Copies. - -You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. - -10. Automatic Licensing of Downstream Recipients. - -Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. - -An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. - -You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. - -11. Patents. - -A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". - -A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. - -Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. - -In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. - -If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. - -If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. - -A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. - -Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. - -12. No Surrender of Others' Freedom. - -If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. - -13. Remote Network Interaction; Use with the GNU General Public License. - -Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. - -Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. - -14. Revised Versions of this License. - -The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. - -If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. - -Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. - -15. Disclaimer of Warranty. - -THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. Limitation of Liability. - -IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -17. Interpretation of Sections 15 and 16. - -If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. - -END OF TERMS AND CONDITIONS + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs -If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. -To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. - FrozenFriendsYume - Copyright (C) 2026 yumehaki + + Copyright (C) - This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. - This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. - You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. -If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. -You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/Lua4Frozen.spec.md b/Lua4Frozen.spec.md new file mode 100644 index 0000000..9a1b785 --- /dev/null +++ b/Lua4Frozen.spec.md @@ -0,0 +1,723 @@ +# Lua4Frozen 仕様 + +Lua4Frozen は、Iceshrimp/FrozenFriendsYume 上で Lua プログラムを投稿・検索・閲覧・実行するためのブラウザ内サンドボックス実行環境です。 +この仕様書は、現行実装に存在する Lua4Frozen 本体と関連機能をまとめます。 + +## 1. 対象範囲 + +この文書に含める範囲は次の通りです。 + +- Lua4Frozen 投稿の判定条件 +- Lua4Frozen 一覧・検索 +- ノート本文からの Lua4Frozen 導線 +- 投稿添付 `.lua` の実行画面 +- ブラウザ内テストランナー +- Lua 実行環境とサンドボックス +- `ffy` API +- 添付ファイル、仮想ファイル、Drive 連携 +- 仮想キーボードとキーボード配列 JSON +- エラー、ログ、セキュリティ制約 + +## 2. 画面とルート + +Lua4Frozen には次のルートがあります。 + +| ルート | 機能 | +| --- | --- | +| `/lua4frozen` | Lua4Frozen 投稿一覧・検索 | +| `/lua4frozen/:noteId` | 指定ノートの Lua プログラム実行 | +| `/lua4frozen/spec` | アプリ内仕様説明ページ | +| `/lua4frozen/test` | ブラウザ内テストランナー | + +ナビゲーションには `Lua4Frozen` 項目があり、トップ画面からテストランナーと仕様ページへ移動できます。 + +## 3. Lua4Frozen 投稿 + +ノートが Lua4Frozen 投稿として扱われる条件は次の通りです。 + +1. ノートの可視性が `public` または `home` +2. ノートのタグに `lua4frozen` が含まれる +3. 添付ファイルに `.lua` 拡張子のファイルが 1 つ以上ある + +タグ判定は小文字化されたタグ配列に対して行われます。 +本文中の `#Lua4Frozen` はタグ化され、内部的には `lua4frozen` として扱われます。 + +### タイトルと説明 + +Lua4Frozen 一覧と実行画面では、ノート本文を次のように扱います。 + +- 1 行目: タイトル +- 2 行目以降: 説明 +- 1 行目が空の場合: `(no title)` + +説明は実行画面では MFM として表示されます。 + +## 4. ノートからの関連導線 + +通常のノート表示でも、次の条件を満たすと Lua4Frozen への導線が出ます。 + +1. ノートにファイルが 1 つ以上ある +2. 可視性が `public` または `home` +3. タグに `lua4frozen` が含まれる +4. `.lua` 拡張子の添付ファイルがある + +この場合、メディアサービス用リンクのラベルは `Lua4Frozen` になり、遷移先は `/lua4frozen/:noteId` です。 + +## 5. 一覧・検索 API + +Lua4Frozen 一覧は `notes/lua4frozen-search` を使います。 + +### エンドポイント + +`notes/lua4frozen-search` + +### 認証 + +- 通常時: 認証不要 +- Private mode 時: 認証が必要 + +### パラメータ + +| 名前 | 型 | 既定値 | 制約 | +| --- | --- | --- | --- | +| `query` | string | `""` | 前後空白は除去 | +| `limit` | integer | `20` | `1` 以上 `50` 以下 | +| `offset` | integer | `0` | `0` 以上 | + +### 検索条件 + +検索対象は次の通りです。 + +- ノート本文の部分一致 +- 投稿者の `usernameLower` の部分一致 + +### 抽出条件 + +API は以下の条件でノートを抽出します。 + +- `visibility` が `public` または `home` +- `tags` に `lua4frozen` が含まれる +- `fileIds` が空でない +- pack 後に `.lua` ファイルを持つものだけ残す + +並び順は `createdAt DESC` です。 + +## 6. 投稿実行画面 + +`/lua4frozen/:noteId` は指定ノートを取得し、Lua4Frozen として実行します。 + +### 読み込み時の検証 + +実行画面は次を検証します。 + +- ノートが存在すること +- タグに `lua4frozen` が含まれること +- 添付ファイルに `.lua` ファイルが 1 つ以上あること + +条件を満たさない場合はエラー表示になります。 + +### Lua ファイル選択 + +`.lua` ファイルが複数添付されている場合、実行画面の `Lua file` セレクトで実行対象を選べます。 +選択された `.lua` の内容を取得して実行します。 + +### 添付ファイルの事前読み込み + +実行画面では、添付ファイルのうち次の条件を満たすものをテキストとして事前読み込みします。 + +- サイズが `512 KiB` 以下 +- 拡張子が `.lua`, `.txt`, `.json`, `.csv` + +読み込めない添付ファイルは無視されます。 + +## 7. テストランナー + +`/lua4frozen/test` は投稿なしで Lua4Frozen を実行する開発・確認用画面です。 + +### 主な機能 + +- Lua ソースの編集 +- サンプルコード読み込み +- 実行・停止 +- 実行ログ表示 +- Runtime error 表示 +- Canvas 表示 +- HTML 出力表示 +- 仮想ファイルの追加、編集、削除 +- 仮想キーボードの利用 +- ユーザー定義キーボードの保存、削除 +- プログラムからのキーボード配列登録 +- `.lua` としてダウンロード +- ブラウザ内下書き保存 + +### サンプル + +現在の実装では次のサンプルがあります。 + +- `Bouncing ball` +- `Keyboard and mouse` +- `Files and audio` + +### ローカル保存 + +テストランナーはブラウザの `localStorage` を使います。 + +| キー | 用途 | +| --- | --- | +| `lua4frozen:test:source` | テストランナーの下書き Lua ソース | +| `lua4frozen:userKeyboards` | ユーザー定義キーボード配列 | + +## 8. Lua 実行環境 + +Lua 実行には `fengari` を使います。 +ブラウザ内で Lua state を作成し、安全な標準ライブラリだけを開きます。 + +### 開かれる標準ライブラリ + +- `_G` +- `coroutine` +- `table` +- `string` +- `utf8` +- `math` + +### 削除されるグローバル + +安全性のため、次のグローバルは削除されます。 + +- `io` +- `os` +- `package` +- `require` +- `debug` +- `dofile` +- `loadfile` + +Lua から外部ネットワークへ直接通信する API は公開されません。 + +## 9. 実行ライフサイクル + +実行ボタンを押すと、次の順に処理します。 + +1. 既存の実行を停止 +2. Runtime error とログを初期化 +3. Drive 読み取り許可状態を `unknown` に戻す +4. Canvas を初期化 +5. Lua state を作成 +6. 安全な標準ライブラリを開く +7. 禁止グローバルを削除 +8. `ffy` API を登録 +9. Lua ソースを読み込み +10. 初期化コードを実行 +11. `on_update` と `on_draw` の登録済み関数を取得 +12. `requestAnimationFrame` でフレームループを開始 + +### フレーム処理 + +各フレームでは次を実行します。 + +1. 前フレームからの経過秒数 `dt` を計算 +2. `dt` は最大 `0.1` 秒に丸める +3. `on_update` 登録関数があれば `dt` を渡して呼ぶ +4. `on_draw` 登録関数があれば呼ぶ +5. エラーがあれば停止してログに出す + +### 命令数制限 + +Lua の実行には hook による命令数制限があります。 + +| 処理 | 制限 | +| --- | --- | +| 初期化コード | `250000` 命令相当 | +| `on_update` | `40000` 命令相当 | +| `on_draw` | `40000` 命令相当 | + +制限を超えると `Lua instruction limit exceeded` で停止します。 + +## 10. グローバル関数 + +### `print(...)` + +Lua の `print` は Lua4Frozen のログへ出力されます。 +複数引数はタブ区切りで結合されます。 + +同じ関数は `ffy.print(...)` としても利用できます。 + +## 11. `ffy` API + +Lua4Frozen はグローバルに `ffy` テーブルを公開します。 + +### `ffy.on_update(fn)` + +毎フレームの更新関数を登録します。 +関数でない値を渡した場合は無視されます。 + +```lua +function update(dt) + -- dt is seconds +end + +ffy.on_update(update) +``` + +### `ffy.on_draw(fn)` + +毎フレームの描画関数を登録します。 +関数でない値を渡した場合は無視されます。 + +```lua +function draw() + ffy.gfx.clear("#111827") +end + +ffy.on_draw(draw) +``` + +## 12. `ffy.gfx` + +Canvas 描画 API です。Canvas の内部サイズは `800 x 450` です。 + +### `ffy.gfx.size()` + +Canvas の幅と高さを返します。 + +```lua +w, h = ffy.gfx.size() +``` + +### `ffy.gfx.color(color)` + +現在の塗り色と線色を設定します。 +`color` は CanvasRenderingContext2D の色指定として解釈されます。 + +```lua +ffy.gfx.color("#7dd3fc") +``` + +### `ffy.gfx.clear(color?)` + +Canvas を消去します。 +`color` が指定されている場合は、消去後に全体をその色で塗ります。 + +```lua +ffy.gfx.clear("#111827") +ffy.gfx.clear() +``` + +### `ffy.gfx.rect(x, y, w, h, fill?)` + +矩形を描きます。 +`fill` が省略または真なら塗り、偽なら線だけです。 + +```lua +ffy.gfx.rect(40, 40, 160, 80) +ffy.gfx.rect(40, 40, 160, 80, false) +``` + +### `ffy.gfx.circle(x, y, r, fill?)` + +円を描きます。 +`fill` が省略または真なら塗り、偽なら線だけです。 + +```lua +ffy.gfx.circle(120, 90, 24) +ffy.gfx.circle(120, 90, 24, false) +``` + +### `ffy.gfx.line(x1, y1, x2, y2)` + +線を描きます。 + +```lua +ffy.gfx.line(10, 10, 300, 120) +``` + +### `ffy.gfx.text(text, x, y, size?)` + +テキストを描きます。 +`size` が未指定の場合は `18px` です。フォントは `sans-serif` です。 + +```lua +ffy.gfx.text("Hello", 40, 80, 24) +``` + +## 12.5. `ffy.svg` + +SVG ベクタ表示 API です。SVG レイヤーは Canvas の 1 つ上に重なります。Canvas 描画機能はそのまま利用でき、SVG レイヤーだけを差し替えまたは消去できます。 + +### `ffy.svg.set(markup)` + +SVG 全体、または SVG 要素断片を表示します。断片を渡した場合は `viewBox="0 0 800 450"` の `` に包まれます。 + +危険なタグ、イベント属性、外部 URL 参照、`javascript:` 参照は除去されます。 + +```lua +ffy.svg.set('') +``` + +### `ffy.svg.clear()` + +SVG レイヤーを消去します。 + +```lua +ffy.svg.clear() +``` + +## 13. `ffy.input` + +キーボードとマウスの状態を取得します。 +Canvas にフォーカスがある間、実キーボード入力が反映されます。テストランナーでは仮想キーボード入力も反映されます。 + +### `ffy.input.key(name)` + +指定キーが押されているかを返します。 +キー名は小文字化して比較されます。 + +```lua +if ffy.input.key("arrowleft") then + x = x - 2 +end +``` + +主なキー名例: + +- `arrowleft` +- `arrowright` +- `arrowup` +- `arrowdown` +- `enter` +- `escape` +- `shift` +- `control` +- `alt` +- `altgraph` +- `meta` +- `backspace` +- `tab` +- `delete` +- ` ` + +### `ffy.input.mouse()` + +マウス状態のテーブルを返します。 + +| フィールド | 型 | 内容 | +| --- | --- | --- | +| `x` | number | Canvas 内 X 座標 | +| `y` | number | Canvas 内 Y 座標 | +| `down` | boolean | マウスボタンが押されているか | +| `button` | number | MouseEvent の `button` 値 | + +座標は表示サイズではなく `800 x 450` の Canvas 座標へ変換されます。 + +## 14. `ffy.html` + +Canvas 下の HTML 出力領域を操作します。 + +### `ffy.html.set(html)` + +HTML 文字列を表示します。 + +```lua +ffy.html.set("Status Ready") +``` + +表示前にサニタイズされます。 +削除されるものは次の通りです。 + +- `script` +- `iframe` +- `object` +- `embed` +- `link` +- `meta` +- `form` +- `on...` イベント属性 +- `http://`, `https://`, `//` で始まる `src` または `href` + +## 15. `ffy.audio` + +### `ffy.audio.beep(frequency?, duration?)` + +ビープ音を鳴らします。 + +| 引数 | 既定値 | 内容 | +| --- | --- | --- | +| `frequency` | `440` | 周波数 Hz | +| `duration` | `0.12` | 秒数 | + +音量は固定で小さめに設定されます。 +`duration` は最大 `2` 秒に丸められます。 +ブラウザの自動再生制限により、ユーザー操作なしでは鳴らない場合があります。 + +## 16. `ffy.fs` + +Lua4Frozen のファイル API です。 + +### `ffy.fs.read(name)` + +指定名のファイル内容を文字列で返します。 +存在しない場合は空文字列を返します。 + +投稿実行画面では、添付ファイルと許可済み Drive ファイルが対象です。 +テストランナーでは、仮想ファイルと許可済み Drive ファイルが対象です。 + +### `ffy.fs.write(name, content)` + +Drive への保存を試みます。 +保存前に確認ダイアログが表示されます。未ログインの場合は保存されません。 + +保存ファイル名は次の処理を受けます。 + +- `\ / : * ? " < > |` を `_` に置換 +- 最大 120 文字に切り詰め +- 空の場合は `lua4frozen.txt` +- MIME type は `text/plain` + +`.jsondb`, `.ffdb`, `.db.json` は Lua4Frozen データベースファイルとして扱われます。 +データベースファイルは Drive ファイルとして保存されますが、Drive 容量ではなく Lua4Frozen データベース容量を使用します。 + +### `ffy.fs.list()` + +見えているファイル名を Lua 配列として返します。 + +投稿実行画面では添付ファイル名と Drive ファイル名です。 +テストランナーでは仮想ファイル名と Drive ファイル名です。 + +## 17. Drive 連携 + +Drive 読み取りは初回 `ffy.fs.read` 時に必要に応じて確認されます。 +実行 1 回ごとに許可状態は初期化されます。 + +### 読み取り対象 + +Drive 読み取り許可後、最大 100 件の Drive ファイル一覧を取得します。 +読み取り対象は次の条件を満たすものです。 + +- サイズが `512 KiB` 以下 +- 拡張子が `.lua`, `.txt`, `.json`, `.csv`, `.jsondb`, `.ffdb`, `.db.json` + +読み取りに失敗したファイルは無視されます。 + +### 書き込み対象 + +書き込みは Drive へアップロードされます。 +`ffy.fs.write` はメモリ上の Drive ファイル map も更新するため、同じ実行中に `ffy.fs.read` で読み返せます。 + +### データベース容量 + +Lua4Frozen データベースファイルは、管理者がコントロールパネルで設定した Lua4Frozen データベース容量を使用します。 +容量を超えた場合、新規書き込みは拒否されます。Drive 上の古いデータベースファイルを削除して空きを作ると、再び書き込めます。 + +## 18. `ffy.db` / `ffy.sql` + +Lua4Frozen データベースファイルは JSON 形式の単純な key-value データベースとして扱われます。 + +### `ffy.db.get(name, key)` + +指定した DB ファイルから値を取得します。存在しない場合は空文字列を返します。 + +### `ffy.db.set(name, key, value)` + +指定した DB ファイルへ値を書き込みます。`name` に DB 拡張子がない場合は `.jsondb` が補われます。 + +### `ffy.db.delete(name, key)` + +指定したキーを削除します。 + +### `ffy.db.list(name)` + +指定した DB ファイルのキー一覧を Lua 配列として返します。 + +### `ffy.sql.exec(name, sql)` + +簡易 SQL を実行します。対応する構文は次の通りです。 + +- `CREATE TABLE ...` +- `INSERT INTO table (key, value) VALUES ('key', 'value')` +- `SELECT value FROM table WHERE key = 'key'` +- `SELECT * FROM table` +- `DELETE FROM table WHERE key = 'key'` + +## 19. 仮想キーボード + +テストランナーには画面上の仮想キーボードがあります。 +仮想キーボードは `ffy.input.key` の入力状態にも反映されます。 + +### 標準配列 + +現在の標準配列は次の通りです。 + +- `jis` +- `us-intl` +- `uk` +- `azerty-fr` +- `custom-jis` +- `custom-ansi` +- `custom-iso` +- `custom-azerty` + +`custom-*` は編集用の空配列テンプレートです。 + +### 修飾キー + +次のキーは修飾キーとして扱われます。 + +- `shift` +- `control` +- `alt` +- `altgraph` +- `meta` + +修飾キーはタップ回数で状態を切り替えます。 + +| タップ回数 | 状態 | +| --- | --- | +| 0 | off | +| 1 | latched | +| 2 | locked | + +通常キーを押すと、latched 状態の修飾キーは消費されます。 + +### テキスト挿入 + +仮想キーに `text` があり、フォーカス中の要素がテストランナーの Lua エディタである場合、その文字列をエディタへ挿入します。 + +## 20. キーボード配列 JSON + +キーボード配列は JSON で定義します。 + +### レイアウト形式 + +```json +{ + "id": "my-layout", + "name": "My Layout", + "cols": 16, + "rows": [ + ["Esc", "1", "2", { "label": "Backspace", "w": 2 }], + [{ "label": "Tab", "w": 2 }, "Q", "W", "E"], + [{ "label": "Shift", "w": 2 }, "Z", "X", "C"] + ] +} +``` + +### レイアウトフィールド + +| フィールド | 型 | 内容 | +| --- | --- | --- | +| `id` | string | 配列 ID | +| `name` | string | 表示名 | +| `cols` | number | グリッド列数。`1` から `24` に丸められる | +| `rows` | array | キー行の配列 | +| `keys` | array | 旧形式。`rows` がない場合に `cols` ごとに行へ変換 | + +### キーフィールド + +キーは文字列またはオブジェクトで指定できます。 + +| フィールド | 型 | 内容 | +| --- | --- | --- | +| `label` | string | 表示ラベル | +| `key` | string | `ffy.input.key` 用のキー名 | +| `text` | string | エディタへ挿入する文字 | +| `w` | number | 横幅。`1` から `6` に丸められる | +| `h` | number | 高さ。`0.5` から `4` に丸められる | + +`key` が未指定の場合は、`label` からキー名が推定されます。 +`text` が未指定かつ `label` が 1 文字の場合は、その文字が `text` になります。 + +## 20. `ffy.keyboard` + +テストランナーでは、Lua プログラムから仮想キーボードを操作できます。 +投稿実行画面には現在この API はありません。 + +### `ffy.keyboard.set_layout_json(name, json)` + +プログラム由来のキーボード配列を登録し、選択します。 + +```lua +layout = [[ +{ + "id": "gamepad", + "name": "Gamepad", + "cols": 8, + "rows": [ + ["ArrowUp"], + ["ArrowLeft", "ArrowDown", "ArrowRight"] + ] +} +]] + +ffy.keyboard.set_layout_json("Gamepad", layout) +``` + +登録された配列の ID は内部的に `program-` が付与されます。 + +### `ffy.keyboard.select(id)` + +既存のキーボード配列 ID を選択します。 + +```lua +ffy.keyboard.select("jis") +``` + +存在しない ID を指定した場合は何もしません。 + +## 21. ログ + +ログは画面上の `Log` に表示されます。 +最大 200 行まで保持し、超えた分は古い行から削除されます。 + +ログに出る主な内容は次の通りです。 + +- `print` / `ffy.print` の出力 +- Runtime error +- Drive 読み取り許可・拒否 +- Drive 書き込み許可・拒否・失敗 +- 下書き保存 +- キーボード配列保存・読み込み結果 + +## 22. エラー処理 + +Lua の読み込み、初期実行、フレーム処理でエラーが起きると、Runtime error 表示とログ出力を行います。 +フレーム処理中のエラーでは実行を停止します。 + +## 23. セキュリティ仕様 + +Lua4Frozen はブラウザ内実行であり、以下の制約により危険な操作を抑制します。 + +- ファイルシステムの直接アクセス不可 +- 任意モジュール読み込み不可 +- `debug` API 不可 +- OS コマンド実行不可 +- Lua からの直接ネットワーク不可 +- Drive 読み取りはユーザー確認が必要 +- Drive 書き込みは保存ごとにユーザー確認が必要 +- HTML 出力はサニタイズされる +- Lua 命令数制限がある + +## 24. 制限事項 + +現行仕様上の制限は次の通りです。 + +- Canvas は 2D のみ +- Canvas 内部サイズは `800 x 450` +- 画像読み込み API はない +- 外部 URL 読み込み API はない +- 永続ストレージは Drive 書き込みまたはテストランナーの `localStorage` に限られる +- 投稿実行画面では仮想キーボード API は使えない +- 添付ファイルの事前読み込みはテキスト系拡張子かつ `512 KiB` 以下のみ +- Drive 読み取りは同期 XHR を使うため、実行時に一時的に UI が止まる可能性がある + +## 25. 実装ファイル + +この仕様は主に以下の実装に対応しています。 + +- [lua4frozen.vue](/home/mihkskhk/ドキュメント/iceshrimp/packages/client/src/pages/lua4frozen.vue) +- [lua4frozen-test.vue](/home/mihkskhk/ドキュメント/iceshrimp/packages/client/src/pages/lua4frozen-test.vue) +- [lua4frozen-home.vue](/home/mihkskhk/ドキュメント/iceshrimp/packages/client/src/pages/lua4frozen-home.vue) +- [lua4frozen-spec.vue](/home/mihkskhk/ドキュメント/iceshrimp/packages/client/src/pages/lua4frozen-spec.vue) +- [lua4frozen-keyboards.json](/home/mihkskhk/ドキュメント/iceshrimp/packages/client/src/pages/lua4frozen-keyboards.json) +- [lua4frozen-search.ts](/home/mihkskhk/ドキュメント/iceshrimp/packages/backend/src/server/api/endpoints/notes/lua4frozen-search.ts) +- [MkNote.vue](/home/mihkskhk/ドキュメント/iceshrimp/packages/client/src/components/MkNote.vue) diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..fdab9a4 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: NODE_ENV=production npm start diff --git a/README.md b/README.md index 8b0ecbc..ffdcc48 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,55 @@ +

Iceshrimp

+

Iceshrimp is a decentralized and federated social networking service, implementing the ActivityPub standard.
+It was forked from Calckey Firefish (itself a fork of Misskey) in mid-2023, to focus on stability, performance and usability instead of new features.

+ # FrozenFriendsYume -FrozenFriendsYume : Fork from Iceshrimp. \ No newline at end of file +FrozenFriendsYume : forked from Iceshrimp. + +--- + +> **Note** +> This project is **not** inactive. +> +> Most of our current development efforts are going into the Iceshrimp.NET [rewrite](https://iceshrimp.dev/iceshrimp/Iceshrimp.NET) to further our goal of increasing stability and performance.
+> This means that Iceshrimp.JS (this project) is only receiving security patches, bug fixes, and parity features. Support is of course still available on the usual channels. +> +> There is already an easy upgrade path available for existing Iceshrimp instances, though we don't recommend taking it just yet.
+> With Iceshrimp.NET getting ever-closer to a stable release, we hope you're just as excited as we are. Check out the [repository](/iceshrimp/Iceshrimp.NET) for more information. + +--- +- Highlighted changes: + - First-class Mastodon client API support + - Significantly improved database performance + - Options to prune cached remote media automatically + - Proper support for split domain deployments, both local and remote + - So much more - Read the [changelog](CHANGELOG.md) to get an overview of all changes +- Don't like the Web UI? We test our Mastodon-compatible API against the following clients: + - [Elk](https://elk.zone), [Phanpy](https://phanpy.social/), [Enafore](https://enafore.social/), [Masto-FE-standalone](https://iceshrimp.dev/iceshrimp/masto-fe-standalone) (Web) + - [Mona](https://apps.apple.com/us/app/mona-for-mastodon/id1659154653), [Toot!](https://apps.apple.com/us/app/toot-for-mastodon/id1229021451), [Ice Cubes](https://apps.apple.com/us/app/ice-cubes-for-mastodon/id6444915884), [Tusker](https://apps.apple.com/us/app/tusker/id1498334597), [Feditext](https://github.com/feditext/feditext), [Mastodon](https://apps.apple.com/us/app/mastodon-for-iphone-and-ipad/id1571998974) (iOS) + - [Tusky](https://tusky.app/), [Moshidon](https://lucasggamerm.github.io/moshidon/), [Megalodon](https://sk22.github.io/megalodon/), [Mastodon](https://play.google.com/store/apps/details?id=org.joinmastodon.android) (Android) +- Project goals: + - No-nonsense bug fixes + - QoL improvements + - Better performance + - Change of focus to actual community needs + - Prioritization of user choice and configurability +- Project anti-goals: + - Flashy marketing + - Commercialization of any kind +- Documentation on installing (and updating) Iceshrimp using: + - [Binary packages](https://iceshrimp.dev/iceshrimp/packaging) + - [Docker Compose](docs/docker-compose-install.md) + - [Manual installation](docs/install.md) +- Documentation on migrating from Firefish can be found [here](docs/migrate.md). +- Want to sign up at an existing instance? + - Check out [FediDB](https://fedidb.org/software/iceshrimp) or [Fediverse Observer](https://iceshrimp.fediverse.observer/list) to get an overview of the instances that are out there. + - Please note that we do not operate a "flagship instance", the only project-affiliated domain is `iceshrimp.dev`. +- Want to donate to the project? + - Our frontend dev (Lilian) needs help paying for healthcare costs. You can contribute [here](https://bunq.me/LilianHealthcare). Money from the fund will be used for co-pays, and medical expenses not covered by insurance. +- Need help or want to contribute? Join the [chat room](https://chat.iceshrimp.dev)! + +--- + +[![](https://hc.ztn.sh/badge/4fc73efa-2790-4146-86bf-8685c5d6b1f7/SDOthVyf-2/archlinux.svg)](https://iceshrimp.dev/iceshrimp/packaging/src/branch/dev/archlinux) +[![](https://hc.ztn.sh/badge/4fc73efa-2790-4146-86bf-8685c5d6b1f7/UIO1Q8q2-2/docker.svg)](https://iceshrimp.dev/iceshrimp/-/packages/container/iceshrimp/dev) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..01f0420 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +# Reporting Security Issues + +## High Security Issues + +If you discover a security issue, which is so high risk, that too much is affected by it, please dont send it over unencrypted communication. You can share your PGP keys with us using security@iceshrimp.dev and after we established a secure communication, send it over E-Mail, or message us using matrix' encrypted private messages at @zotan:161.rocks + + +This will allow us to assess the risk, and make a fix available before we add a +bug report to the Codeberg repository. + +Thanks for helping make Iceshrimp safe for everyone. diff --git a/assets/generate.sh b/assets/generate.sh new file mode 100644 index 0000000..473a0bd --- /dev/null +++ b/assets/generate.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# Dependencies: imagick/convert, inkscape +# Be sure to check out the submodule before running this +crop=branding/prepared/svg-crop +svg=branding/prepared/svg +png=branding/prepared/png +cd "${0%/*}" + +# General assets +convert -background "#3B364C" -resize 1600x "$svg/full-light.svg" "logo.png" + +# Client assets +cp "$crop/wordmark-white.svg" "../packages/client/assets/welcome-logo.svg" +convert -background "#E7EDFF" -resize 1024x "$svg/full-dark.svg" "../packages/client/assets/about-icon-dark.png" +convert -background "#3B364C" -resize 1024x "$svg/full-light.svg" "../packages/client/assets/about-icon-light.png" + +# Backend assets +convert -background "#E7EDFF" -resize 1024x "$svg/full-dark.svg" "../packages/backend/assets/api-doc.png" +convert -background "#E7EDFF" -resize 1024x "$svg/wordmark-dark.svg" "../packages/backend/assets/mail-wordmark.png" +convert -background "#3B364C" -resize x750 -gravity center -extent 1024x1024 "$crop/logo-light.svg" "../packages/backend/assets/apple-touch-icon.png" +convert -background "#3B364C" -resize x192 -gravity center -extent 192x192 "$svg/logo-light.svg" "../packages/backend/assets/icons/192.png" +convert -background "#3B364C" -resize x512 -gravity center -extent 512x512 "$svg/logo-light.svg" "../packages/backend/assets/icons/512.png" +convert -background "#3B364C" -resize x480 -gravity center -extent 512x512 "$svg/logo-light.svg" "../packages/backend/assets/icons/maskable.png" +convert -background none -resize x512 -gravity center -extent 512x512 "$crop/logo-black.svg" "../packages/backend/assets/icons/monochrome.png" +convert \( -background "#3B364C" -resize x750 -gravity center -extent 1024x1024 "$crop/logo-light.svg" \) \( -size 1024x1024 xc:black -fill white -draw "roundRectangle 0,0,1024,1024 128,128" \) -alpha Off -compose CopyOpacity -composite "../packages/backend/assets/splash.png" +convert \( -background "#3B364C" -resize x200 -gravity center -extent 256x256 "$crop/logo-light.svg" \) \( -size 256x256 xc:black -fill white -draw "roundRectangle 0,0,256,256 32,32" \) -alpha Off -compose CopyOpacity -composite "../packages/backend/assets/favicon.png" +convert \( -background "#3B364C" -resize x200 -gravity center -extent 256x256 "$crop/logo-light.svg" \) \( -size 256x256 xc:black -fill white -draw "roundRectangle 0,0,256,256 32,32" \) -alpha Off -compose CopyOpacity -composite "../packages/backend/assets/favicon.ico" diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000..ec2b111 --- /dev/null +++ b/assets/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:30413bc6a5284ad4bf68f973b5787511130a83cb53003c686ba88f41935df3b2 +size 55577 diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..5918758 --- /dev/null +++ b/biome.json @@ -0,0 +1,23 @@ +{ + "$schema": "./.yarn/unplugged/@biomejs-biome-npm-1.3.1-6f9e52cf26/node_modules/@biomejs/biome/configuration_schema.json", + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "formatter": { + "ignore": [ + "packages/iceshrimp-sdk/api-extractor.json", + "packages/*/tsconfig.json", + "packages/*/built", + "packages/*/package-lock.json", + "packages/backend/src/server/web/manifest.ts", + "packages/backend/built/", + "*/model.json", + "*.md", + "**/tsconfig.json", + "*/.yml" + ] + } +} diff --git a/chart/.helmignore b/chart/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/chart/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/chart/Chart.yaml b/chart/Chart.yaml new file mode 100644 index 0000000..29b6d9c --- /dev/null +++ b/chart/Chart.yaml @@ -0,0 +1,38 @@ +apiVersion: v2 +name: iceshrimp +description: A fun, new, open way to experience social media https://iceshrimp.dev + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.4 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "rc" + +dependencies: + - name: elasticsearch + version: 19.14.1 + repository: https://raw.githubusercontent.com/bitnami/charts/archive-full-index/bitnami + condition: elasticsearch.enabled + - name: postgresql + version: 13.2.29 + repository: https://raw.githubusercontent.com/bitnami/charts/archive-full-index/bitnami + condition: postgresql.enabled + - name: redis + version: 18.6.3 + repository: https://raw.githubusercontent.com/bitnami/charts/archive-full-index/bitnami + condition: redis.enabled diff --git a/chart/README.md b/chart/README.md new file mode 100644 index 0000000..f41df6a --- /dev/null +++ b/chart/README.md @@ -0,0 +1,92 @@ +# iceshrimp + +![Version: 0.1.2](https://img.shields.io/badge/Version-0.1.2-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: rc](https://img.shields.io/badge/AppVersion-rc-informational?style=flat-square) + +A fun, new, open way to experience social media https://iceshrimp.dev + +## Requirements + +| Repository | Name | Version | +|------------|------|---------| +| https://raw.githubusercontent.com/bitnami/charts/archive-full-index/bitnami | elasticsearch | 19.0.1 | +| https://raw.githubusercontent.com/bitnami/charts/archive-full-index/bitnami | postgresql | 11.1.3 | +| https://raw.githubusercontent.com/bitnami/charts/archive-full-index/bitnami | redis | 16.13.2 | + +## Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| affinity | object | `{}` | | +| autoscaling.enabled | bool | `false` | | +| autoscaling.maxReplicas | int | `100` | | +| autoscaling.minReplicas | int | `1` | | +| autoscaling.targetCPUUtilizationPercentage | int | `80` | | +| iceshrimp.allowedPrivateNetworks | list | `[]` | If you want to allow iceshrimp to connect to private ips, enter the cidrs here. | +| iceshrimp.clusterLimit | integer | `1` | Number of worker processes per replica | +| iceshrimp.deepl.authKey | string | `""` | | +| iceshrimp.deepl.isPro | bool | `false` | | +| iceshrimp.deepl.managed | bool | `false` | | +| iceshrimp.domain | string | `"iceshrimp.local"` | | +| iceshrimp.isManagedHosting | bool | `true` | | +| iceshrimp.libreTranslate.apiKey | string | `""` | | +| iceshrimp.libreTranslate.apiUrl | string | `""` | | +| iceshrimp.libreTranslate.managed | bool | `false` | | +| iceshrimp.maxNoteLength | integer | `3000` | Max note length | +| iceshrimp.objectStorage.access_key | string | `""` | | +| iceshrimp.objectStorage.access_secret | string | `""` | | +| iceshrimp.objectStorage.baseUrl | string | `""` | | +| iceshrimp.objectStorage.bucket | string | `""` | | +| iceshrimp.objectStorage.endpoint | string | `""` | | +| iceshrimp.objectStorage.managed | bool | `true` | | +| iceshrimp.objectStorage.prefix | string | `"files"` | | +| iceshrimp.objectStorage.region | string | `""` | | +| iceshrimp.reservedUsernames[0] | string | `"root"` | | +| iceshrimp.reservedUsernames[1] | string | `"admin"` | | +| iceshrimp.reservedUsernames[2] | string | `"administrator"` | | +| iceshrimp.reservedUsernames[3] | string | `"me"` | | +| iceshrimp.reservedUsernames[4] | string | `"system"` | | +| iceshrimp.smtp.from_address | string | `"notifications@example.com"` | | +| iceshrimp.smtp.login | string | `""` | | +| iceshrimp.smtp.managed | bool | `true` | | +| iceshrimp.smtp.password | string | `""` | | +| iceshrimp.smtp.port | int | `587` | | +| iceshrimp.smtp.server | string | `"smtp.mailgun.org"` | | +| iceshrimp.smtp.useImplicitSslTls | bool | `false` | | +| iceshrimp.strategy | object | `{}` | Override DeploymentStrategy for Iceshrimp | +| elasticsearch | object | `{"auth":{},"enabled":false,"hostname":"","port":9200,"ssl":false}` | https://github.com/bitnami/charts/tree/master/bitnami/elasticsearch#parameters | +| fullnameOverride | string | `""` | | +| image.pullPolicy | string | `"IfNotPresent"` | | +| image.repository | string | `"iceshrimp.dev/iceshrimp/iceshrimp"` | | +| image.tag | string | `""` | | +| imagePullSecrets | list | `[]` | | +| ingress.annotations | object | `{}` | | +| ingress.className | string | `""` | | +| ingress.enabled | bool | `false` | | +| ingress.hosts[0].host | string | `"chart-example.local"` | | +| ingress.hosts[0].paths[0].path | string | `"/"` | | +| ingress.hosts[0].paths[0].pathType | string | `"ImplementationSpecific"` | | +| ingress.tls | list | `[]` | | +| nameOverride | string | `""` | | +| nodeSelector | object | `{}` | | +| podAnnotations | object | `{}` | | +| podSecurityContext | object | `{}` | | +| postgresql.auth.database | string | `"iceshrimp_production"` | | +| postgresql.auth.password | string | `""` | | +| postgresql.auth.username | string | `"iceshrimp"` | | +| postgresql.enabled | bool | `true` | disable if you want to use an existing db; in which case the values below must match those of that external postgres instance | +| redis.auth.password | string | `""` | you must set a password; the password generated by the redis chart will be rotated on each upgrade: | +| redis.enabled | bool | `true` | | +| redis.hostname | string | `""` | | +| redis.port | int | `6379` | | +| replicaCount | int | `1` | | +| resources | object | `{}` | | +| securityContext | object | `{}` | | +| service.port | int | `80` | | +| service.type | string | `"ClusterIP"` | | +| serviceAccount.annotations | object | `{}` | | +| serviceAccount.create | bool | `true` | | +| serviceAccount.name | string | `""` | | +| tolerations | list | `[]` | | + +---------------------------------------------- +Autogenerated from chart metadata using [helm-docs v1.11.0](https://github.com/norwoodj/helm-docs/releases/v1.11.0) diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt new file mode 100644 index 0000000..3fc45a6 --- /dev/null +++ b/chart/templates/NOTES.txt @@ -0,0 +1,22 @@ +1. Get the application URL by running these commands: +{{- if .Values.ingress.enabled }} +{{- range $host := .Values.ingress.hosts }} + {{- range .paths }} + http{{ if $.Values.ingress.tls }}s{{ end }}://{{ $host.host }}{{ .path }} + {{- end }} +{{- end }} +{{- else if contains "NodePort" .Values.service.type }} + export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "iceshrimp.fullname" . }}) + export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}") + echo http://$NODE_IP:$NODE_PORT +{{- else if contains "LoadBalancer" .Values.service.type }} + NOTE: It may take a few minutes for the LoadBalancer IP to be available. + You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "iceshrimp.fullname" . }}' + export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "iceshrimp.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}") + echo http://$SERVICE_IP:{{ .Values.service.port }} +{{- else if contains "ClusterIP" .Values.service.type }} + export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "iceshrimp.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}") + export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}") + echo "Visit http://127.0.0.1:8080 to use your application" + kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT +{{- end }} diff --git a/chart/templates/_helpers.tpl b/chart/templates/_helpers.tpl new file mode 100644 index 0000000..699d1e9 --- /dev/null +++ b/chart/templates/_helpers.tpl @@ -0,0 +1,332 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "iceshrimp.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "iceshrimp.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "iceshrimp.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "iceshrimp.labels" -}} +helm.sh/chart: {{ include "iceshrimp.chart" . }} +{{ include "iceshrimp.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "iceshrimp.selectorLabels" -}} +app.kubernetes.io/name: {{ include "iceshrimp.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "iceshrimp.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "iceshrimp.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Create a default fully qualified name for dependent services. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +*/}} +{{- define "iceshrimp.elasticsearch.fullname" -}} +{{- printf "%s-%s" .Release.Name "elasticsearch" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "iceshrimp.redis.fullname" -}} +{{- printf "%s-%s" .Release.Name "redis" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "iceshrimp.postgresql.fullname" -}} +{{- printf "%s-%s" .Release.Name "postgresql" | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +config/default.yml content +*/}} +{{- define "iceshrimp.configDir.default.yml" -}} +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# iceshrimp configuration +#━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +# ┌─────┐ +#───┘ URL └───────────────────────────────────────────────────── + +# Final accessible URL seen by a user. +url: "https://{{ .Values.iceshrimp.domain }}/" + +# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE +# URL SETTINGS AFTER THAT! + +# ┌───────────────────────┐ +#───┘ Port and TLS settings └─────────────────────────────────── + +# +# Misskey requires a reverse proxy to support HTTPS connections. +# +# +----- https://example.tld/ ------------+ +# +------+ |+-------------+ +----------------+| +# | User | ---> || Proxy (443) | ---> | Misskey (3000) || +# +------+ |+-------------+ +----------------+| +# +---------------------------------------+ +# +# You need to set up a reverse proxy. (e.g. nginx) +# An encrypted connection with HTTPS is highly recommended +# because tokens may be transferred in GET requests. + +# The port that your Misskey server should listen on. +port: 3000 + +# ┌──────────────────────────┐ +#───┘ PostgreSQL configuration └──────────────────────────────── + +db: + {{- if .Values.postgresql.enabled }} + host: {{ template "iceshrimp.postgresql.fullname" . }} + port: 5432 + {{- else }} + host: {{ .Values.postgresql.postgresqlHostname }} + port: {{ .Values.postgresql.postgresqlPort | default 5432 }} + {{- end }} + + # Database name + db: {{ .Values.postgresql.auth.database }} + + # Auth + user: {{ .Values.postgresql.auth.username }} + pass: {{ .Values.postgresql.auth.password | quote }} + + # Whether disable Caching queries + #disableCache: true + + # Extra Connection options + #extra: + # ssl: + # host: localhost + # rejectUnauthorized: false + +# ┌─────────────────────┐ +#───┘ Redis configuration └───────────────────────────────────── + +redis: + {{- if .Values.redis.enabled }} + host: {{ template "iceshrimp.redis.fullname" . }}-master + {{- else }} + host: {{ required "When the redis chart is disabled .Values.redis.hostname is required" .Values.redis.hostname }} + {{- end }} + port: {{ .Values.redis.port | default 6379 }} + #family: 0 # 0=Both, 4=IPv4, 6=IPv6 + pass: {{ .Values.redis.auth.password | quote }} + #prefix: example-prefix + #db: 1 + #user: default + #tls: + # host: localhost + # rejectUnauthorized: false + +# ┌─────────────────────┐ +#───┘ Sonic configuration └───────────────────────────────────── + +#sonic: +# host: localhost +# port: 1491 +# auth: SecretPassword +# collection: notes +# bucket: default + +# ┌─────────────────────────────┐ +#───┘ Elasticsearch configuration └───────────────────────────── + +{{- if .Values.elasticsearch.enabled }} +elasticsearch: + host: {{ template "mastodon.elasticsearch.fullname" . }}-master-hl + port: 9200 + ssl: false +{{- else if .Values.elasticsearch.hostname }} +elasticsearch: + host: {{ .Values.elasticsearch.hostname | quote }} + port: {{ .Values.elasticsearch.port }} + ssl: {{ .Values.elasticsearch.ssl }} + {{- if .Values.elasticsearch.auth }} + user: {{ .Values.elasticsearch.auth.username | quote }} + pass: {{ .Values.elasticsearch.auth.password | quote }} + {{- end }} +{{- end }} + +# ┌───────────────┐ +#───┘ ID generation └─────────────────────────────────────────── + +# You can select the ID generation method. +# You don't usually need to change this setting, but you can +# change it according to your preferences. + +# Available methods: +# aid ... Short, Millisecond accuracy +# meid ... Similar to ObjectID, Millisecond accuracy +# ulid ... Millisecond accuracy +# objectid ... This is left for backward compatibility + +# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE +# ID SETTINGS AFTER THAT! + +id: 'aid' + +# ┌─────────────────────┐ +#───┘ Other configuration └───────────────────────────────────── + +# Max note length, should be < 8000. +maxNoteLength: {{ .Values.iceshrimp.maxNoteLength | default 3000 }} + +# Maximum lenght of an image caption or file comment (default 1500, max 8192) +#maxCaptionLength: 1500 + +# Reserved usernames that only the administrator can register with +reservedUsernames: {{ .Values.iceshrimp.reservedUsernames | toJson }} + +# Whether disable HSTS +#disableHsts: true + +# Number of worker processes +clusterLimit: {{ .Values.iceshrimp.clusterLimit | default 1 }} + +# Job concurrency per worker +# deliverJobConcurrency: 128 +# inboxJobConcurrency: 16 + +# Job rate limiter +# deliverJobPerSec: 128 +# inboxJobPerSec: 16 + +# Job attempts +# deliverJobMaxAttempts: 12 +# inboxJobMaxAttempts: 8 + +# IP address family used for outgoing request (ipv4, ipv6 or dual) +#outgoingAddressFamily: ipv4 + +# Syslog option +#syslog: +# host: localhost +# port: 514 + +# Proxy for HTTP/HTTPS +#proxy: http://127.0.0.1:3128 + +#proxyBypassHosts: [ +# 'example.com', +# '192.0.2.8' +#] + +# Proxy for SMTP/SMTPS +#proxySmtp: http://127.0.0.1:3128 # use HTTP/1.1 CONNECT +#proxySmtp: socks4://127.0.0.1:1080 # use SOCKS4 +#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 + +# Media Proxy +#mediaProxy: https://example.com/proxy + +# Proxy remote files (default: false) +#proxyRemoteFiles: true + +allowedPrivateNetworks: {{ .Values.iceshrimp.allowedPrivateNetworks | toJson }} + +# TWA +#twa: +# nameSpace: android_app +# packageName: tld.domain.twa +# sha256CertFingerprints: ['AB:CD:EF'] + +# Upload or download file size limits (bytes) +#maxFileSize: 262144000 + +# Managed hosting settings +# !!!!!!!!!! +# >>>>>> NORMAL SELF-HOSTERS, STAY AWAY! <<<<<< +# >>>>>> YOU DON'T NEED THIS! <<<<<< +# !!!!!!!!!! +# Each category is optional, but if each item in each category is mandatory! +# If you mess this up, that's on you, you've been warned... + +#maxUserSignups: 100 +isManagedHosting: {{ .Values.iceshrimp.isManagedHosting }} +deepl: + managed: {{ .Values.iceshrimp.deepl.managed }} + authKey: {{ .Values.iceshrimp.deepl.authKey | quote}} + isPro: {{ .Values.iceshrimp.deepl.isPro }} + +libreTranslate: + managed: {{ .Values.iceshrimp.libreTranslate.managed }} + apiUrl: {{ .Values.iceshrimp.libreTranslate.apiUrl | quote }} + apiKey: {{ .Values.iceshrimp.libreTranslate.apiKey | quote }} + +email: + managed: {{ .Values.iceshrimp.smtp.managed }} + address: {{ .Values.iceshrimp.smtp.from_address | quote }} + host: {{ .Values.iceshrimp.smtp.server | quote }} + port: {{ .Values.iceshrimp.smtp.port }} + user: {{ .Values.iceshrimp.smtp.login | quote }} + pass: {{ .Values.iceshrimp.smtp.password | quote }} + useImplicitSslTls: {{ .Values.iceshrimp.smtp.useImplicitSslTls }} +objectStorage: + managed: {{ .Values.iceshrimp.objectStorage.managed }} + baseUrl: {{ .Values.iceshrimp.objectStorage.baseUrl | quote }} + bucket: {{ .Values.iceshrimp.objectStorage.bucket | quote }} + prefix: {{ .Values.iceshrimp.objectStorage.prefix | quote }} + endpoint: {{ .Values.iceshrimp.objectStorage.endpoint | quote }} + region: {{ .Values.iceshrimp.objectStorage.region | quote }} + accessKey: {{ .Values.iceshrimp.objectStorage.access_key | quote }} + secretKey: {{ .Values.iceshrimp.objectStorage.access_secret | quote }} + useSsl: true + connnectOverProxy: false + setPublicReadOnUpload: true + s3ForcePathStyle: true + +# !!!!!!!!!! +# >>>>>> AGAIN, NORMAL SELF-HOSTERS, STAY AWAY! <<<<<< +# >>>>>> YOU DON'T NEED THIS, ABOVE SETTINGS ARE FOR MANAGED HOSTING ONLY! <<<<<< +# !!!!!!!!!! + +# Seriously. Do NOT fill out the above settings if you're self-hosting. +# They're much better off being set from the control panel. +{{- end }} + + +{{- define "iceshrimp.datapvc" -}} +{{- default (printf "%s-data-pvc" (include "iceshrimp.fullname" .) ) .Values.iceshrimp.localStorage.claimName }} +{{- end }} diff --git a/chart/templates/deployment.yaml b/chart/templates/deployment.yaml new file mode 100644 index 0000000..114f13d --- /dev/null +++ b/chart/templates/deployment.yaml @@ -0,0 +1,97 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "iceshrimp.fullname" . }} + labels: + {{- include "iceshrimp.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + + {{- if .Values.iceshrimp.deploymentStrategy }} + strategy: + {{- toYaml .Values.iceshrimp.deploymentStrategy | nindent 4 }} + {{- end }} + + selector: + matchLabels: + {{- include "iceshrimp.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + checksum/secret-config: {{ include ( print $.Template.BasePath "/secret-config.yaml" ) . | sha256sum | quote }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "iceshrimp.selectorLabels" . | nindent 8 }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "iceshrimp.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + volumes: + - name: config-volume + secret: + secretName: {{ template "iceshrimp.fullname" . }}-config + - name: data-volume + {{- if .Values.iceshrimp.localStorage.enabled }} + persistentVolumeClaim: + claimName: {{ include "iceshrimp.datapvc" . }} + {{- else }} + emptyDir: + {{- end }} + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - yarn + - run + - start + env: + - name: "NODE_ENV" + value: "production" + volumeMounts: + - name: config-volume + mountPath: /iceshrimp/.config + - name: data-volume + mountPath: /iceshrimp/files + ports: + - name: http + containerPort: 3000 + protocol: TCP + startupProbe: + httpGet: + path: / + port: http + failureThreshold: 30 + periodSeconds: 10 + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http + resources: + {{- toYaml .Values.resources | nindent 12 }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/chart/templates/hpa.yaml b/chart/templates/hpa.yaml new file mode 100644 index 0000000..6fe0f89 --- /dev/null +++ b/chart/templates/hpa.yaml @@ -0,0 +1,28 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2beta1 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "iceshrimp.fullname" . }} + labels: + {{- include "iceshrimp.labels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "iceshrimp.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + targetAverageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} + {{- end }} + {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + targetAverageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} + {{- end }} +{{- end }} diff --git a/chart/templates/ingress.yaml b/chart/templates/ingress.yaml new file mode 100644 index 0000000..74d87b7 --- /dev/null +++ b/chart/templates/ingress.yaml @@ -0,0 +1,61 @@ +{{- if .Values.ingress.enabled -}} +{{- $fullName := include "iceshrimp.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }} + {{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }} + {{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}} + {{- end }} +{{- end }} +{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1 +{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} +apiVersion: networking.k8s.io/v1beta1 +{{- else -}} +apiVersion: extensions/v1beta1 +{{- end }} +kind: Ingress +metadata: + name: {{ $fullName }} + labels: + {{- include "iceshrimp.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }} + ingressClassName: {{ .Values.ingress.className }} + {{- end }} + {{- if .Values.ingress.tls }} + tls: + {{- range .Values.ingress.tls }} + - hosts: + {{- range .hosts }} + - {{ . | quote }} + {{- end }} + secretName: {{ .secretName }} + {{- end }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + {{- if and .pathType (semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion) }} + pathType: {{ .pathType }} + {{- end }} + backend: + {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} + service: + name: {{ $fullName }} + port: + number: {{ $svcPort }} + {{- else }} + serviceName: {{ $fullName }} + servicePort: {{ $svcPort }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/chart/templates/job-db-migrate.yaml b/chart/templates/job-db-migrate.yaml new file mode 100644 index 0000000..ac980a2 --- /dev/null +++ b/chart/templates/job-db-migrate.yaml @@ -0,0 +1,59 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "iceshrimp.fullname" . }}-db-migrate + labels: + {{- include "iceshrimp.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": post-install,pre-upgrade + "helm.sh/hook-delete-policy": before-hook-creation,hook-succeeded + "helm.sh/hook-weight": "-2" +spec: + template: + metadata: + name: {{ include "iceshrimp.fullname" . }}-db-migrate + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + restartPolicy: Never + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "iceshrimp.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + volumes: + - name: config-volume + secret: + secretName: {{ template "iceshrimp.fullname" . }}-config + containers: + - name: {{ .Chart.Name }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - yarn + - run + - migrate + env: + - name: "NODE_ENV" + value: "production" + volumeMounts: + - name: config-volume + mountPath: /iceshrimp/.config + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/chart/templates/pvc.yaml b/chart/templates/pvc.yaml new file mode 100644 index 0000000..0e44164 --- /dev/null +++ b/chart/templates/pvc.yaml @@ -0,0 +1,23 @@ +{{- if .Values.iceshrimp.localStorage.enabled }} +kind: PersistentVolumeClaim +apiVersion: v1 +metadata: + name: {{ include "iceshrimp.datapvc" . }} + labels: + {{- include "iceshrimp.labels" . | nindent 4 }} + annotations: + {{- .Values.iceshrimp.localStorage.annotations | toYaml | nindent 4}} +spec: + accessModes: + {{- .Values.iceshrimp.localStorage.accessModes | toYaml | nindent 4 }} + volumeMode: Filesystem + {{- if .Values.iceshrimp.localStorage.class }} + storageClassName: {{ .Values.iceshrimp.localStorage.class }} + {{- end }} + {{- with .Values.iceshrimp.localStorage.volumeName }} + volumeName: {{ . }} + {{- end }} + resources: + requests: + storage: {{ .Values.iceshrimp.localStorage.size }} +{{- end }} diff --git a/chart/templates/secret-config.yaml b/chart/templates/secret-config.yaml new file mode 100644 index 0000000..b85942e --- /dev/null +++ b/chart/templates/secret-config.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ template "iceshrimp.fullname" . }}-config + labels: + {{- include "iceshrimp.labels" . | nindent 4 }} +type: Opaque +data: + default.yml: {{ include "iceshrimp.configDir.default.yml" . | b64enc }} diff --git a/chart/templates/service.yaml b/chart/templates/service.yaml new file mode 100644 index 0000000..fb7317c --- /dev/null +++ b/chart/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "iceshrimp.fullname" . }} + labels: + {{- include "iceshrimp.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + name: http + selector: + {{- include "iceshrimp.selectorLabels" . | nindent 4 }} diff --git a/chart/templates/serviceaccount.yaml b/chart/templates/serviceaccount.yaml new file mode 100644 index 0000000..b2458a2 --- /dev/null +++ b/chart/templates/serviceaccount.yaml @@ -0,0 +1,12 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "iceshrimp.serviceAccountName" . }} + labels: + {{- include "iceshrimp.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/chart/templates/tests/test-connection.yaml b/chart/templates/tests/test-connection.yaml new file mode 100644 index 0000000..baf0a01 --- /dev/null +++ b/chart/templates/tests/test-connection.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Pod +metadata: + name: "{{ include "iceshrimp.fullname" . }}-test-connection" + labels: + {{- include "iceshrimp.labels" . | nindent 4 }} + annotations: + "helm.sh/hook": test +spec: + containers: + - name: wget + image: busybox + command: ['wget'] + args: ['{{ include "iceshrimp.fullname" . }}:{{ .Values.service.port }}'] + restartPolicy: Never diff --git a/chart/values.yaml b/chart/values.yaml new file mode 100644 index 0000000..1046931 --- /dev/null +++ b/chart/values.yaml @@ -0,0 +1,192 @@ +# Default values for iceshrimp. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +replicaCount: 1 + +image: + repository: iceshrimp.dev/iceshrimp/iceshrimp + pullPolicy: IfNotPresent + # Overrides the image tag whose default is the chart appVersion. + tag: latest + +iceshrimp: + isManagedHosting: true + domain: iceshrimp.local + + deepl: + managed: false + authKey: "" + isPro: false + + libreTranslate: + managed: false + apiUrl: "" + apiKey: "" + + smtp: + managed: true + from_address: notifications@example.com + port: 587 + server: smtp.mailgun.org + useImplicitSslTls: false + login: "" + password: "" + + objectStorage: + managed: true + access_key: "" + access_secret: "" + baseUrl: "" # e.g. "https://my-bucket.nyc3.cdn.digitaloceanspaces.com" + bucket: "" # e.g. "my-bucket" + prefix: files + endpoint: "" # e.g. "nyc3.digitaloceanspaces.com:443" + region: "" # e.g. "nyc3" + + localStorage: + enabled: true + claimName: null + accessModes: + - ReadWriteMany + labels: {} + class: + annotations: + helm.sh/resource-policy: keep + size: 10Gi + + # Deployment strategy (optional), see https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy + # deploymentStrategy: + # type: RollingUpdate + # rollingUpdate: + # maxUnavailable: 0 + # maxSurge: 1 + + # -- If you want to allow iceshrimp to connect to private ips, enter the cidrs here. + allowedPrivateNetworks: [] + # - "10.0.0.0/8" + + reservedUsernames: + - root + - admin + - administrator + - me + - system + + # Number of worker processes per replica + clusterLimit: 1 + + # Max note length + maxNoteLength: 3000 + +# https://github.com/bitnami/charts/tree/master/bitnami/postgresql#parameters +postgresql: + # -- disable if you want to use an existing db; in which case the values below + # must match those of that external postgres instance + enabled: true + # postgresqlHostname: preexisting-postgresql + # postgresqlPort: 5432 + auth: + database: iceshrimp_production + username: iceshrimp + # you must set a password; the password generated by the postgresql chart will + # be rotated on each upgrade: + # https://github.com/bitnami/charts/tree/master/bitnami/postgresql#upgrade + password: "" + +# https://github.com/bitnami/charts/tree/master/bitnami/redis#parameters +redis: + # disable if you want to use an existing redis instance; in which case the + # values below must match those of that external redis instance + enabled: true + hostname: "" + port: 6379 + auth: + # -- you must set a password; the password generated by the redis chart will be + # rotated on each upgrade: + password: "" + +# -- https://github.com/bitnami/charts/tree/master/bitnami/elasticsearch#parameters +elasticsearch: + # disable if you want to use an existing redis instance; in which case the + # values below must match those of that external elasticsearch instance + enabled: false + hostname: "" + port: 9200 + ssl: false + auth: {} + # username: "" + # password: "" + # @ignored + image: + tag: 7 + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + # Specifies whether a service account should be created + create: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +podAnnotations: {} + +podSecurityContext: {} + # fsGroup: 2000 + +securityContext: {} + # capabilities: + # drop: + # - ALL + # readOnlyRootFilesystem: true + # runAsNonRoot: true + # runAsUser: 1000 + +service: + type: ClusterIP + port: 80 + +ingress: + enabled: false + className: "" + annotations: {} + # kubernetes.io/ingress.class: nginx + # kubernetes.io/tls-acme: "true" + hosts: + - host: chart-example.local + paths: + - path: / + pathType: ImplementationSpecific + tls: [] + # - secretName: chart-example-tls + # hosts: + # - chart-example.local + +resources: {} + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +autoscaling: + enabled: false + minReplicas: 1 + maxReplicas: 100 + targetCPUUtilizationPercentage: 80 + # targetMemoryUtilizationPercentage: 80 + +nodeSelector: {} + +tolerations: [] + +affinity: {} diff --git a/custom/assets/LICENSE b/custom/assets/LICENSE new file mode 100644 index 0000000..79f5e95 --- /dev/null +++ b/custom/assets/LICENSE @@ -0,0 +1,13 @@ +Copyright 2023 The Iceshrimp contributors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/custom/assets/instance.css b/custom/assets/instance.css new file mode 100644 index 0000000..9a70e66 --- /dev/null +++ b/custom/assets/instance.css @@ -0,0 +1,7 @@ +/* +* !!! WARNING !!! +* Editing this file may cause your instance to break for EVERYONE. +* Please know what you're doing and test it out with regular user custom CSS. +* With that said, GLHF! +* This may eventuallly be replaced with a function in the admin panel. + */ diff --git a/custom/locales/.gitkeep b/custom/locales/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/dev/docker-compose.yml.example b/dev/docker-compose.yml.example new file mode 100644 index 0000000..fc5e2bd --- /dev/null +++ b/dev/docker-compose.yml.example @@ -0,0 +1,57 @@ +version: "3" + +services: + web: + image: iceshrimp.dev/iceshrimp/iceshrimp:dev + build: .. + container_name: iceshrimp_web + restart: always + depends_on: + - db + - redis +# - es + ports: + - "3000:3000" + networks: + - network +# - web + volumes: + - ../files:/iceshrimp/files + - ../.config:/iceshrimp/.config:ro + + redis: + restart: always + container_name: iceshrimp_redis + image: docker.io/valkey/valkey:7-alpine + networks: + - network + volumes: + - ../redis:/data + + db: + restart: always + image: docker.io/postgres:12.2-alpine + container_name: iceshrimp_db + networks: + - network + env_file: + - ../.config/docker.env + volumes: + - ../db:/var/lib/postgresql/data + +# es: +# restart: always +# image: docker.elastic.co/elasticsearch/elasticsearch-oss:6.4.2 +# environment: +# - "ES_JAVA_OPTS=-Xms512m -Xmx512m" +# - "TAKE_FILE_OWNERSHIP=111" +# networks: +# - network +# volumes: +# - ./elasticsearch:/usr/share/elasticsearch/data + +networks: + network: +# web: +# external: +# name: web diff --git a/docs/api-doc.md b/docs/api-doc.md new file mode 100644 index 0000000..7540f0c --- /dev/null +++ b/docs/api-doc.md @@ -0,0 +1,5 @@ +# API Documentation + +You can find interactive API documentation at any Iceshrimp instance. https://iceshrimp.social/api-doc + +You can also find auto-generated documentation for iceshrimp-sdk [here](../packages/iceshrimp-sdk/markdown/iceshrimp-sdk.md). diff --git a/docs/docker-compose-install.md b/docs/docker-compose-install.md new file mode 100644 index 0000000..d37ac89 --- /dev/null +++ b/docs/docker-compose-install.md @@ -0,0 +1,81 @@ +# Installing Iceshrimp with Docker + +This guide is based on `docker compose`/Docker Compose v2, but `docker-compose`/Docker Compose v1 should work as well. Docker 20.10+ is required for building your own images because of BuildKit usage, and Docker 20.10 users need to [enable BuildKit first](https://docs.docker.com/build/buildkit/#getting-started), or [upgrade to latest Docker](https://docs.docker.com/engine/install/#server). + +## Preparations + +### Getting needed files + +If you want to use the prebuilt images: +```sh +GIT_LFS_SKIP_SMUDGE=1 git clone https://iceshrimp.dev/iceshrimp/iceshrimp.git --depth=1 +``` + +If you want to build your own images (make sure to install `git-lfs` and to run `git lfs install` before running the command): +```sh +git clone https://iceshrimp.dev/iceshrimp/iceshrimp.git +``` + +### docker-compose.yml + +First, run `cp docs/examples/docker-compose.yml docker-compose.yml`, and edit `docker-compose.yml` if you want to build the image yourself or choose a [different tag](https://iceshrimp.dev/iceshrimp/-/packages/container/iceshrimp/versions) + +### .config + +Run `cp .config/docker_example.env .config/docker.env`, and edit `.config/docker.env` and fill it with the database credentials you want. +Run `cp .config/example-docker.yml .config/default.yml`, and edit `.config/default.yml` +- Replace example database credentials with the ones you entered in `.config/docker.env` +- Change other configuration + +If you are running Iceshrimp on a system with more than one CPU thread, you might want to set the `clusterLimit` config option to about half of your thread count, depending on your system configuration. Please note that each worker requires around 10 PostgreSQL connections, so be sure to set `max_connections` appropriately. To do this with docker-compose, add `args: ["-c", "max_connections=n"]` to the `db:` section of `docker-compose.yml`, with `n` being `(10 * no_workers) + 10`. + +## Installation and first start + +Choose a method, whether you chose to build the image yourself or not. +Note: Ctrl-C will shut down Iceshrimp gracefully. + +### Pulling the image + +```sh +docker compose pull +docker compose up +``` + +### Building the image + +Depending on your machine specs, this can take well over 30 minutes + +```sh +docker compose build +docker compose up +``` + +## Starting Iceshrimp automatically + +Run `docker compose up -d` and Iceshrimp will start automatically on boot. + +## Updating Iceshrimp + +### Pulling the image + +```sh +docker compose pull +docker compose down +docker compose up -d +``` + +### Building the image + +```sh +## Run git stash commands only if you have uncommitted changes +git stash +git pull +git stash pop +docker compose build +docker compose down +docker compose up -d +``` + +## Post-install + +See [post-install](post-install.md). diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 0000000..179c60a --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,48 @@ +# 🐳 Running a Iceshrimp server with Docker + +## Pre-built docker container +[iceshrimp/iceshrimp](iceshrimp.dev/iceshrimp/iceshrimp) + +## `docker-compose` + +There is a `docker-compose.yml` in the root of the project that you can use to build the container from source + +- .config/docker.env (**db config settings**) +- .config/default.yml (**Iceshrimp server settings**) + +## Configuring + +Rename the files: + +`cp .config/example.yml .config/default.yml` + +`cp .config/example.env .config/docker.env` + +then edit them according to your environment. +You can configure `docker.env` with anything you like, but you will have to pay attention to the `default.yml` file: +- `url` should be set to the URL you will be hosting the web interface for the server at. +- `host`, `db`, `user`, `pass` will have to be configured in the `PostgreSQL configuration` section - `host` is the name of the postgres container (eg: *iceshrimp_db_1*), and the others should match your `docker.env`. +- `host`will need to be configured in the *Redis configuration* section - it is the name of the redis container (eg: *iceshrimp_redis_1*) +- `auth` will need to be configured in the *Sonic* section - cannot be the default `SecretPassword` + +Everything else can be left as-is. + +## Running docker-compose + +The [prebuilt container for iceshrimp](https://iceshrimp.dev/iceshrimp/-/packages/container/iceshrimp/latest) is fairly large, and may take a few minutes to download and extract using docker. + +Copy `docker-compose.yml` and the `config/` to a directory, then run the **docker-compose** command: +`docker-compose up -d`. + +NOTE: This will take some time to come fully online, even after download and extracting the container images, and it may emit some error messages before completing successfully. Specifically, the `db` container needs to initialize and so isn't available to the `web` container right away. Only once the `db` container comes online does the `web` container start building and initializing the Iceshrimp tables. + +Once the server is up you can use a web browser to access the web interface at `http://serverip:3000` (where `serverip` is the IP of the server you are running the Iceshrimp server on). + +## Docker for development + +```sh +cd dev/ +docker-compose build +docker-compose run --rm web pnpm run init +docker-compose up -d +``` diff --git a/docs/examples/Podman (quadlet)/iceshrimp-db.container b/docs/examples/Podman (quadlet)/iceshrimp-db.container new file mode 100644 index 0000000..b5a3515 --- /dev/null +++ b/docs/examples/Podman (quadlet)/iceshrimp-db.container @@ -0,0 +1,14 @@ +[Unit] +Description=Iceshrimp PostgreSQL container +[Container] +Image=docker.io/postgres:15-alpine +ContainerName=iceshrimp_db +HostName=db +Network=iceshrimp.network +EnvironmentFile=%h/services/iceshrimp/.config/docker.env +Volume=%h/services/iceshrimp/db:/var/lib/postgresql/data:Z +[Service] +Restart=on-failure +TimeoutStartSec=900 +[Install] +WantedBy=iceshrimp-web.service diff --git a/docs/examples/Podman (quadlet)/iceshrimp-redis.container b/docs/examples/Podman (quadlet)/iceshrimp-redis.container new file mode 100644 index 0000000..38e5015 --- /dev/null +++ b/docs/examples/Podman (quadlet)/iceshrimp-redis.container @@ -0,0 +1,14 @@ +[Unit] +Description=Iceshrimp Redis container +[Container] +Image=docker.io/redis:7.0-alpine +ContainerName=iceshrimp_redis +HostName=redis +Network=iceshrimp.network +Volume=%h/services/iceshrimp/redis:/data:Z +[Service] +Restart=on-failure +TimeoutStartSec=900 +[Install] +WantedBy=iceshrimp-web.service + diff --git a/docs/examples/Podman (quadlet)/iceshrimp-web.container b/docs/examples/Podman (quadlet)/iceshrimp-web.container new file mode 100644 index 0000000..091c4a1 --- /dev/null +++ b/docs/examples/Podman (quadlet)/iceshrimp-web.container @@ -0,0 +1,16 @@ +[Unit] +Description=Iceshrimp container +[Container] +Image=iceshrimp.dev/iceshrimp/iceshrimp:latest +ContainerName=iceshrimp_web +HostName=web +PublishPort=3000:3000 +Network=iceshrimp.network +Environment=NODE_ENV=production +Volume=%h/services/iceshrimp/files:/iceshrimp/files:z +Volume=%h/services/iceshrimp/.config:/iceshrimp/.config:ro,z +[Service] +Restart=on-failure +TimeoutStartSec=900 +[Install] +WantedBy=multi-user.target default.target diff --git a/docs/examples/Podman (quadlet)/iceshrimp.network b/docs/examples/Podman (quadlet)/iceshrimp.network new file mode 100644 index 0000000..264f70a --- /dev/null +++ b/docs/examples/Podman (quadlet)/iceshrimp.network @@ -0,0 +1 @@ +[Network] diff --git a/docs/examples/Podman (quadlet)/volume-dir-creation.sh b/docs/examples/Podman (quadlet)/volume-dir-creation.sh new file mode 100644 index 0000000..d0877f9 --- /dev/null +++ b/docs/examples/Podman (quadlet)/volume-dir-creation.sh @@ -0,0 +1,24 @@ +#!/bin/bash +if [ -d $HOME/.config/containers/systemd ]; then + mkdir -pv $(grep -F "Volume=" $HOME/.config/containers/systemd/iceshrimp-*.container | sed "s|%h|$HOME|g" | cut -d= -f2 | cut -d: -f1); + + db_env=$(grep -F "EnvironmentFile=" $HOME/.config/containers/systemd/iceshrimp-db.container | sed "s|%h|$HOME|g" | cut -d= -f2) + config_dir=$(grep -F ":/iceshrimp/.config" $HOME/.config/containers/systemd/iceshrimp-web.container | sed "s|%h|$HOME|g" | cut -d= -f2 | cut -d: -f1) + + if [ ! -f $config_dir/docker_example.env ]; then + wget -O $db_env \ + https://iceshrimp.dev/iceshrimp/iceshrimp/raw/branch/dev/.config/docker_example.env; + else + cp -v $config_dir/docker_example.env $db_env; + fi + + if [ ! -f $config_dir/example-docker.yml ]; then + wget -O $config_dir/default.yml \ + https://iceshrimp.dev/iceshrimp/iceshrimp/raw/branch/dev/.config/example-docker.yml; + else + cp $config_dir/example-docker.yml $config_dir/default.yml + fi +else + echo "No $HOME/.config/containers/systemd found" + exit 1 +fi diff --git a/docs/examples/docker-compose.yml b/docs/examples/docker-compose.yml new file mode 100644 index 0000000..cae9880 --- /dev/null +++ b/docs/examples/docker-compose.yml @@ -0,0 +1,53 @@ +version: "3" + +services: + web: + image: iceshrimp.dev/iceshrimp/iceshrimp:dev +### If you want to build the image locally +# build: . +### If you want to build the image locally AND use Docker 20.10 +# build: +# context: . +# args: +# DOCKER_BUILDKIT: 1 + container_name: iceshrimp_web + restart: unless-stopped + depends_on: + - db + - redis + ports: + - "3000:3000" + networks: + - ishnet +# - web + environment: + NODE_ENV: production + volumes: + - ./files:/iceshrimp/files + - ./.config:/iceshrimp/.config:ro + + redis: + restart: unless-stopped + image: docker.io/valkey/valkey:7-alpine + container_name: iceshrimp_redis + networks: + - ishnet + volumes: + - ./redis:/data + + db: + restart: unless-stopped + image: docker.io/postgres:16-alpine + container_name: iceshrimp_db + networks: + - ishnet + env_file: + - .config/docker.env + volumes: + - ./db:/var/lib/postgresql/data + +networks: + ishnet: + # web: + # external: + # name: web diff --git a/docs/examples/iceshrimp.apache.conf b/docs/examples/iceshrimp.apache.conf new file mode 100644 index 0000000..c0b901d --- /dev/null +++ b/docs/examples/iceshrimp.apache.conf @@ -0,0 +1,13 @@ +# Replace example.com with your domain + + + ServerName example.com + # For WebSocket + ProxyPass "/streaming" "ws://127.0.0.1:3000/streaming/" + # Proxy to Node + ProxyPass "/" "http://127.0.0.1:3000/" + ProxyPassReverse "/" "http://127.0.0.1:3000/" + ProxyPreserveHost On + # For files proxy + AllowEncodedSlashes On + diff --git a/docs/examples/iceshrimp.nginx.conf b/docs/examples/iceshrimp.nginx.conf new file mode 100644 index 0000000..edf15fa --- /dev/null +++ b/docs/examples/iceshrimp.nginx.conf @@ -0,0 +1,78 @@ +# Replace example.com with your domain + +# For WebSocket +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + +proxy_cache_path /tmp/nginx_cache levels=1:2 keys_zone=cache1:16m max_size=1g inactive=720m use_temp_path=off; + +server { + listen 80; + listen [::]:80; + server_name example.com; + + # For SSL domain validation + root /var/www/html; + location /.well-known/acme-challenge/ { allow all; } + location /.well-known/pki-validation/ { allow all; } + location / { return 301 https://$server_name$request_uri; } +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name example.com; + + ssl_session_timeout 1d; + ssl_session_cache shared:ssl_session_cache:10m; + ssl_session_tickets off; + + # To use Let's Encrypt certificate + ssl_certificate /etc/letsencrypt/live/example.tld/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/example.tld/privkey.pem; + + # To use Debian/Ubuntu's self-signed certificate (For testing or before issuing a certificate) + #ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem; + #ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key; + + # SSL protocol settings + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_stapling on; + ssl_stapling_verify on; + + # Change to your upload limit + client_max_body_size 80m; + + # Gzip compression + gzip on; + gzip_proxied any; + gzip_comp_level 6; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript application/activity+json application/atom+xml; + + # Proxy to Node + location / { + proxy_pass http://127.0.0.1:3000; + proxy_set_header Host $host; + proxy_http_version 1.1; + proxy_redirect off; + + # If it's behind another reverse proxy or CDN, remove the following. + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + + # For WebSocket + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + # Cache settings + proxy_cache cache1; + proxy_cache_lock on; + proxy_cache_use_stale updating; + add_header X-Cache $upstream_cache_status; + } +} diff --git a/docs/examples/iceshrimp.service b/docs/examples/iceshrimp.service new file mode 100644 index 0000000..00ea358 --- /dev/null +++ b/docs/examples/iceshrimp.service @@ -0,0 +1,15 @@ +[Unit] +Description=Iceshrimp daemon + +[Service] +Type=simple +User=iceshrimp +ExecStart=/usr/bin/yarn start +WorkingDirectory=/home/iceshrimp/iceshrimp +Environment="NODE_ENV=production" +TimeoutSec=60 +SyslogIdentifier=iceshrimp +Restart=always + +[Install] +WantedBy=multi-user.target diff --git a/docs/firefish-redis.patch b/docs/firefish-redis.patch new file mode 100644 index 0000000..dc2f461 --- /dev/null +++ b/docs/firefish-redis.patch @@ -0,0 +1,41 @@ +diff --git a/packages/backend/built/services/chart/core.js b/packages/backend/built/services/chart/core.js +index 000b2f7..33d4031 100644 +--- a/packages/backend/built/services/chart/core.js ++++ b/packages/backend/built/services/chart/core.js +@@ -3,7 +3,6 @@ + * + * Tests located in test/chart + */ import { db } from "../../db/postgre.js"; +-import { getChartInsertLock } from "../../misc/app-lock.js"; + import { addTime, dateUTC, isTimeBefore, isTimeSame, subtractTime } from "../../prelude/time.js"; + import * as nestedProperty from "nested-property"; + import promiseLimit from "promise-limit"; +@@ -224,6 +223,7 @@ export function getJsonSchema(schema) { + } + const date = Chart.dateToTimestamp(current); + const lockKey = group ? `${this.name}:${date}:${span}:${group}` : `${this.name}:${date}:${span}`; ++ const { getChartInsertLock } = await import("../../misc/app-lock.js"); + const lock = await getChartInsertLock(lockKey); + try { + // ロック内でもう1回チェックする +diff --git a/packages/backend/built/db/postgre.js b/packages/backend/built/db/postgre.js +index 81d6238..a133c02 100644 +--- a/packages/backend/built/db/postgre.js ++++ b/packages/backend/built/db/postgre.js +@@ -71,7 +71,6 @@ import { User } from "../models/entities/user.js"; + import { Webhook } from "../models/entities/webhook.js"; + import { entities as charts } from "../services/chart/entities.js"; + import { dbLogger } from "./logger.js"; +-import { redisClient } from "./redis.js"; + // TODO?: should we avoid importing things from built directory? + import { nativeInitDatabase } from "native-utils/built/index.js"; + const sqlLogger = dbLogger.createSubLogger("sql", "gray", false); +@@ -221,6 +220,8 @@ export async function initDb(force = false) { + } + export async function resetDb() { + const reset = async ()=>{ ++ const { redisClient } = await import("./redis.js"); ++ + await redisClient.flushdb(); + const tables = await db.query(`SELECT relname AS "table" + FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) diff --git a/docs/fk.patch b/docs/fk.patch new file mode 100644 index 0000000..2d51512 --- /dev/null +++ b/docs/fk.patch @@ -0,0 +1,41 @@ +diff --git a/packages/backend/migration/1661376843000-remove-mentioned-remote-users-column.js b/packages/backend/migration/1661376843000-remove-mentioned-remote-users-column.js +index 42d79b5b5..1fd5e0f10 100644 +--- a/packages/backend/migration/1661376843000-remove-mentioned-remote-users-column.js ++++ b/packages/backend/migration/1661376843000-remove-mentioned-remote-users-column.js +@@ -7,6 +7,22 @@ export class removeMentionedRemoteUsersColumn1661376843000 { + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "note" ADD "mentionedRemoteUsers" TEXT NOT NULL DEFAULT '[]'::text`); +- await queryRunner.query(`UPDATE "note" SET "mentionedRemoteUsers" = (SELECT COALESCE(json_agg(row_to_json("data"))::text, '[]') FROM (SELECT "url", "uri", "username", "host" FROM "user" JOIN "user_profile" ON "user"."id" = "user_profile". "userId" WHERE "user"."host" IS NOT NULL AND "user"."id" = ANY("note"."mentions")) AS "data")`); ++ await queryRunner.query(` ++ CREATE TEMP TABLE IF NOT EXISTS "temp_mentions" AS ++ SELECT "id", "url", "uri", "username", "host" ++ FROM "user" ++ JOIN "user_profile" ON "user"."id" = "user_profile"."userId" WHERE "user"."host" IS NOT NULL ++ `); ++ ++ await queryRunner.query(` ++ CREATE UNIQUE INDEX "temp_mentions_id" ON "temp_mentions"("id") ++ `); ++ ++ await queryRunner.query(` ++ UPDATE "note" SET "mentionedRemoteUsers" = ( ++ SELECT COALESCE(json_agg(row_to_json("data")::jsonb - 'id')::text, '[]') FROM "temp_mentions" AS "data" ++ WHERE "data"."id" = ANY("note"."mentions") ++ ) ++ `); + } + } +diff --git a/packages/backend/migration/1663399074403-resize-comments-drive-file.js b/packages/backend/migration/1663399074403-resize-comments-drive-file.js +index a037f1655..0873aec9b 100644 +--- a/packages/backend/migration/1663399074403-resize-comments-drive-file.js ++++ b/packages/backend/migration/1663399074403-resize-comments-drive-file.js +@@ -9,6 +9,6 @@ export class resizeCommentsDriveFile1663399074403 { + } + + async down(queryRunner) { +- await queryRunner.query(`ALTER TABLE "drive_file" ALTER COLUMN "comment" TYPE character varying(512)`); +- } ++ console.log('This migration cannot be reverted, skipping...'); ++ } + } diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..f4272bc --- /dev/null +++ b/docs/install.md @@ -0,0 +1,175 @@ +# Installing Iceshrimp + +This document will guide you through manual installation of Iceshrimp. We also provide prebuilt [packages](/iceshrimp/packaging) for various platforms, should you prefer those over a manual install. + +## Dependencies + +### Build + +- C/C++ compiler like **GCC** or **Clang** +- Build tools like **make** +- **Python 3** + +### Required + +- [**Node.js**](https://nodejs.org) v18.16.0+ (v20 recommended) +- [**PostgreSQL**](https://www.postgresql.org/) 12+ (including modules, usually packaged as postgresql-contrib) +- [**Valkey**](https://valkey.io/) (or any other Redis 6 compatible fork) +- [**libvips**](https://www.libvips.org/) +- **Web proxy** + - nginx + - Caddy + +### Optional + +- [**FFmpeg**](https://ffmpeg.org/) for video transcoding + +## Preparations + +### Download repository + +Make sure you have `git-lfs` installed and have run `git lfs install` before cloning the repo, as we are using Git LFS for efficient storage of binary blobs. + +```sh +git clone https://iceshrimp.dev/iceshrimp/iceshrimp.git +``` + +If you don't want to run the latest development version, pick a version from [here](https://iceshrimp.dev/iceshrimp/iceshrimp/releases) and run `git checkout ` before continuing. + +### Creating a new user + +In case you want to run Iceshrimp as a different user, run `adduser --disabled-password --disabled-login iceshrimp` +Following steps will require you to run them as the user you have made, so use `su - iceshrimp`, or `sudo -iu iceshrimp`, or whatever else method in order to temporarily log in as that user. + +### Configuration + +- Copy `.config/example.yml` to `.config/default.yml` +- Edit `.config/default.yml` with text editor + - Make sure to set PostgreSQL and Redis section correctly + +## Installing project dependencies + +This project uses corepack to manage yarn versions, please make sure you don't have a globally installed non-corepack yarn binary (e.g. by having run `npm install -g yarn` in the past, or via your operating system's package manager) + +```sh +corepack enable +corepack prepare --activate +yarn +``` + +Note: If you get a lot of `The remote archive doesn't match the expected checksum` errors, please make sure you installed `git-lfs` and ran `git lfs install && git lfs pull`. + +## Building Iceshrimp + +```sh +yarn build +``` +## Database + +### Creating database + +This will create a postgres user with your password and database, while also granting that user all privileges on database. +Using `psql` prompt: +```sh +sudo -u postgres psql +``` +```postgresql +create database iceshrimp with encoding = 'UTF8'; +create user iceshrimp with encrypted password '{YOUR_PASSWORD}'; +grant all privileges on database iceshrimp to iceshrimp; +alter database iceshrimp owner to iceshrimp; +\q +``` + +### First migration + +In order for Iceshrimp to work properly, you need to initialise the database using +```bash +yarn run init +``` + +### Optimizing performance + +If you are running Iceshrimp on a system with more than one CPU thread, you might want to set the `clusterLimit` config option to about half of your thread count, depending on your system configuration. Please note that each worker requires around 10 PostgreSQL connections, so be sure to set `max_connections` appropriately (aim for `(10 * no_workers) + 10`, if you have no other applications accessing the PostgreSQL database). + +For optimal database performance, it's highly recommended to configure PostgreSQL with [PGTune](https://pgtune.leopard.in.ua/) using the "Mixed type of application" profile. This is especially important should your database server use HDD instead of SATA or NVMe SSD storage. + +## Setting up Webproxy + +### Nginx + +- Run `sudo cp docs/examples/iceshrimp.nginx.conf /etc/nginx/sites-available/ && cd /etc/nginx/sites-available/` +- Edit `iceshrimp.nginx.conf` to reflect your server properly +- Run `sudo ln -s ./iceshrimp.nginx.conf ../sites-enabled/iceshrimp.nginx.conf` +- Run `sudo nginx -t` to check that the config is valid, then restart the nginx service. + +### Caddy + +- Add the following to your Caddyfile, and replace `example.com` with your domain +``` +example.com { + reverse_proxy localhost:3000 +} +``` + +## Running Iceshrimp + +### Running manually + +- Start Iceshrimp by running `NODE_ENV=production yarn run start`. +If this is your first run, after Iceshrimp has started successfully, you'll be able to go to the URL you have specified in `.config/default.yml` and create first user. +- To stop the server, use `Ctrl-C`. + +### Running using systemd + +- Run `sudo cp docs/examples/iceshrimp.service /etc/systemd/system/` +- Edit `/etc/systemd/system/iceshrimp.service` with text editor, and change `User`, `WorkingDir`, `ExecStart` if necessary. +- Run `sudo systemctl daemon-reload` +- Run `sudo systemctl enable --now iceshrimp` in order to enable and start Iceshrimp. +- (Optional) Check if instance is running using `sudo systemctl status iceshrimp` + +### Environment variables +- `ICESHRIMP_CONFIG` (default: `.config/default.yml`) to change where the the config file is located +- `ICESHRIMP_SECRETS` (default: unset) if you want to keep your secrets in a separate config file +- `ICESHRIMP_MEDIA_DIR` (default: `files`) to change where internally stored files are located +- `ICESHRIMP_CUSTOM_DIR` (default: `custom`) to change where custom assets and locales are located (caution: assets are copied at build time or when running `yarn gulp`, not during startup!) + +Make sure you are specifying absolute paths when setting environment variables. + +### Updating Iceshrimp + +Before you start, if you cloned the iceshrimp repository before the Git LFS migration, please follow [these instructions](https://iceshrimp.dev/iceshrimp/iceshrimp/wiki/Git-LFS#fixing-up-a-preexisting-cloned-repo) to get your repository back in sync. + +First, stop the Iceshrimp service and then run the following commands: + +```sh +## Run git stash commands only if you have uncommitted changes +git stash +``` + +If you were previously running a tagged release and/or want to upgrade to one, run: +```sh +git fetch --tags +git checkout +``` + +If you were previously running a development version, and want to continue doing so or switch to the latest commit, run: +```sh +git switch dev +git pull +``` + +Regardless of which of the above you picked, run: +```sh +git stash pop +yarn +yarn build && yarn migrate +``` + +Note: If you get a lot of `The remote archive doesn't match the expected checksum` errors, please make sure you installed `git-lfs` and ran `git lfs install && git lfs pull`. + +Now restart the Iceshrimp service and everything should be up to date. + +## Post-install + +See [post-install](post-install.md). diff --git a/docs/kubernetes.md b/docs/kubernetes.md new file mode 100644 index 0000000..2a0e2e0 --- /dev/null +++ b/docs/kubernetes.md @@ -0,0 +1,45 @@ +# Running a iceshrimp server with Kubernetes and Helm + +This is a [Helm](https://helm.sh/) chart directory in the root of the project +that you can use to deploy iceshrimp to a Kubernetes cluster + +## Deployment + +1. Copy the example helm values and make your changes: +```shell +cp .config/helm_values_example.yml .config/helm_values.yml +``` + +2. Update helm dependencies: +```shell +cd chart +helm dependency list $dir 2> /dev/null | tail +2 | head -n -1 | awk '{ print "helm repo add " $1 " " $3 }' | while read cmd; do $cmd; done; +cd ../ +``` + +3. Create the iceshrimp helm release (also used to update existing deployment): +```shell +helm upgrade \ + --install \ + --namespace iceshrimp \ + --create-namespace \ + iceshrimp chart/ \ + -f .config/helm_values.yml +``` + +4. Watch your iceshrimp server spin up: +```shell +kubectl -n iceshrimp get po -w +``` + +5. Initial the admin user and managed config: +```shell +export iceshrimp_USERNAME="my_desired_admin_handle" && \ +export iceshrimp_PASSWORD="myDesiredInitialPassword" && \ +export iceshrimp_HOST="iceshrimp.example.com" && \ +export iceshrimp_TOKEN=$(curl -X POST https://$iceshrimp_HOST/api/admin/accounts/create -H "Content-Type: application/json" -d "{ \"username\":\"$iceshrimp_USERNAME\", \"password\":\"$iceshrimp_PASSWORD\" }" | jq -r '.token') && \ +echo "Save this token: ${iceshrimp_TOKEN}" && \ +curl -X POST -H "Authorization: Bearer $iceshrimp_TOKEN" https://$iceshrimp_HOST/api/admin/accounts/hosted +``` + +6. Enjoy! diff --git a/docs/migrate.md b/docs/migrate.md new file mode 100644 index 0000000..e112f45 --- /dev/null +++ b/docs/migrate.md @@ -0,0 +1,66 @@ +# 🚚 Migrating from Firefish to Iceshrimp + +> **Warning** +> Before proceeding, please **ensure you have an *up-to-date* backup of the database.** + +## Preparations +First, follow Firefish's [downgrade guide](https://codeberg.org/firefish/firefish/src/branch/develop/docs/downgrade.md) to get back to v1.0.5-rc. When prompted to switch the docker image/git tag, make sure to pick `v1.0.5-rc`, and not `v20240206`. This is to make sure that the migration patch applies correctly. + +### Docker +First, stop the container by running `docker compose down`. + +Now, run `docker-compose run --rm --entrypoint '/bin/bash' web` to get a shell in the main container. + +### Bare metal +First, stop the service. If using systemd, run `sudo systemctl stop firefish.service`. + +Now, `cd` into the root of your firefish repository. + +## Applying the migrations patch +To make sure migrations revert correctly, run `curl -s https://iceshrimp.dev/iceshrimp/iceshrimp/raw/branch/dev/docs/firefish-redis.patch | git apply --ignore-whitespace`. This will patch two built JS files related to redis. The patch is ephemeral, once you complete the migration process it will no longer apply. Iceshrimp-JS has the patch built in. + +## Reverting the migrations +To begin, run `cd packages/backend` to switch to the backend workspace. + +Now, revert all of the typeorm migrations. reverted. To do this, run the command `pnpm run revertmigration:typeorm` until the output confirms that the migration `FirefishRepo1689957674000` has been reverted successfully. + +If migration `IncreaseHostCharLimit1692374635734` failed to revert, please run `DELETE FROM "migrations" WHERE "name" = 'IncreaseHostCharLimit1692374635734';` in the database shell. + +If you get any other errors here please ask for support in the [chat room](https://chat.iceshrimp.dev). + +Finally, revert all the cargo migrations, by running `pnpm run revertmigration:cargo` until `m20230806_170616_fix_antenna_stream_ids` has been reverted. Again, if you get any errors, please ask for support in the [chat room](https://chat.iceshrimp.dev). + +## Switching to Iceshrimp +### Docker +First, run `docker compose down` to shut down firefish. + +Now, switch out image for the `web` container with `iceshrimp.dev/iceshrimp/iceshrimp:latest`. +Furthermore, for every volume/mount that's mapped to /firefish, switch it out for /iceshrimp (leaving any trailing text intact). + +Finally, run `docker compose up`, and make sure that it starts up correctly. If everything works, press CTRL+C and run `docker compoe up -d` to start it in the background. + +If you get any errors on startup, please ask for support in the [chat room](https://chat.iceshrimp.dev). + +### Bare metal +Before you begin, make sure `git-lfs` is installed on the system, and that the firefish service is stopped. + +Then, switch back to the repository root directory and run `git remote set-url origin https://iceshrimp.dev/iceshrimp/iceshrimp.git`, as well as `git lfs install`. + +Now, run `git fetch --all` to fetch the new commits. + +If you get an error like `couldn't find remote ref` here, run `git config remote.origin.fetch "+refs/heads/*:refs/remotes/origin/*"`, followed by `git remote prune origin` and `git fetch --all`. If you still get any errors, please ask for support in the [chat room](https://chat.iceshrimp.dev). + +Then, run `git checkout dev` to switch to the `dev` branch, or `git checkout ` to switch to a versioned tag. Make sure to run `git lfs pull` as well, to get all the dependencies. + +Now, run `yarn && yarn build && yarn migrate` to install dependencies, build the project & run all pending migrations. + +Finally, to clean up now-unnecessary files, run `rm -rf packages/backend/native-utils packages/megalodon`. + +You should now be able to start the service back up. + +If you get any errors during this process, please ask for support in the [chat room](https://chat.iceshrimp.dev). + +## Closing notes +Please check out the [example configuration file](https://iceshrimp.dev/iceshrimp/iceshrimp/src/branch/dev/.config/example.yml), as it's changed quite a bit since Firefish and you may want to make use of the new features. + +If you need further assistance for any reason, please ask for help in the [chat room](https://chat.iceshrimp.dev), we will assist you with the migration. diff --git a/docs/mkv13.patch b/docs/mkv13.patch new file mode 100644 index 0000000..e6106b1 --- /dev/null +++ b/docs/mkv13.patch @@ -0,0 +1,45 @@ +diff --git a/packages/backend/migration/1672704017999-remove-lastCommunicatedAt.js b/packages/backend/migration/1672704017999-remove-lastCommunicatedAt.js +index 38a676985..c4ae690e0 100644 +--- a/packages/backend/migration/1672704017999-remove-lastCommunicatedAt.js ++++ b/packages/backend/migration/1672704017999-remove-lastCommunicatedAt.js +@@ -6,6 +6,8 @@ export class removeLastCommunicatedAt1672704017999 { + } + + async down(queryRunner) { +- await queryRunner.query(`ALTER TABLE "instance" ADD "lastCommunicatedAt" TIMESTAMP WITH TIME ZONE NOT NULL`); ++ await queryRunner.query(`ALTER TABLE "instance" ADD "lastCommunicatedAt" TIMESTAMP WITH TIME ZONE`); ++ await queryRunner.query(`UPDATE "instance" SET "lastCommunicatedAt" = COALESCE("infoUpdatedAt", "caughtAt")`); ++ await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "lastCommunicatedAt" SET NOT NULL`); + } + } +diff --git a/packages/backend/migration/1673336077243-PollChoiceLength.js b/packages/backend/migration/1673336077243-PollChoiceLength.js +index 810c626e0..5809528cb 100644 +--- a/packages/backend/migration/1673336077243-PollChoiceLength.js ++++ b/packages/backend/migration/1673336077243-PollChoiceLength.js +@@ -6,6 +6,6 @@ export class PollChoiceLength1673336077243 { + } + + async down(queryRunner) { +- await queryRunner.query(`ALTER TABLE "poll" ALTER COLUMN "choices" TYPE character varying(128) array`); ++ //await queryRunner.query(`ALTER TABLE "poll" ALTER COLUMN "choices" TYPE character varying(128) array`); + } + } +diff --git a/packages/backend/migration/1674118260469-achievement.js b/packages/backend/migration/1674118260469-achievement.js +index 131ab96f8..57a922f83 100644 +--- a/packages/backend/migration/1674118260469-achievement.js ++++ b/packages/backend/migration/1674118260469-achievement.js +@@ -18,12 +18,13 @@ export class achievement1674118260469 { + + async down(queryRunner) { + await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'pollEnded')`); ++ await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum_old"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum_old"[]`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`); + await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum"`); + await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum_old" RENAME TO "user_profile_mutingnotificationtypes_enum"`); +- await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`); ++ await queryRunner.query(`DELETE FROM "public"."notification" WHERE "type" = 'achievementEarned'`); + await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum_old" USING "type"::"text"::"public"."notification_type_enum_old"`); + await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`); + await queryRunner.query(`ALTER TYPE "public"."notification_type_enum_old" RENAME TO "notification_type_enum"`); diff --git a/docs/mkv13_restore.patch b/docs/mkv13_restore.patch new file mode 100644 index 0000000..9ef9934 --- /dev/null +++ b/docs/mkv13_restore.patch @@ -0,0 +1,127 @@ +diff --git a/packages/backend/migration/1680491187535-cleanup.js b/packages/backend/migration/1680491187535-cleanup.js +index 1e609ca06..0e6accf3e 100644 +--- a/packages/backend/migration/1680491187535-cleanup.js ++++ b/packages/backend/migration/1680491187535-cleanup.js +@@ -1,10 +1,40 @@ + export class cleanup1680491187535 { +- name = 'cleanup1680491187535' ++ name = "cleanup1680491187535"; + +- async up(queryRunner) { +- await queryRunner.query(`DROP TABLE "antenna_note" `); +- } ++ async up(queryRunner) { ++ await queryRunner.query(`DROP TABLE "antenna_note" `); ++ } + +- async down(queryRunner) { +- } ++ async down(queryRunner) { ++ await queryRunner.query( ++ `CREATE TABLE antenna_note ( id character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, "antennaId" character varying(32) NOT NULL, read boolean DEFAULT false NOT NULL)`, ++ ); ++ await queryRunner.query( ++ `COMMENT ON COLUMN antenna_note."noteId" IS 'The note ID.'`, ++ ); ++ await queryRunner.query( ++ `COMMENT ON COLUMN antenna_note."antennaId" IS 'The antenna ID.'`, ++ ); ++ await queryRunner.query( ++ `ALTER TABLE ONLY antenna_note ADD CONSTRAINT "PK_fb28d94d0989a3872df19fd6ef8" PRIMARY KEY (id)`, ++ ); ++ await queryRunner.query( ++ `CREATE INDEX "IDX_0d775946662d2575dfd2068a5f" ON antenna_note USING btree ("antennaId")`, ++ ); ++ await queryRunner.query( ++ `CREATE UNIQUE INDEX "IDX_335a0bf3f904406f9ef3dd51c2" ON antenna_note USING btree ("noteId", "antennaId")`, ++ ); ++ await queryRunner.query( ++ `CREATE INDEX "IDX_9937ea48d7ae97ffb4f3f063a4" ON antenna_note USING btree (read)`, ++ ); ++ await queryRunner.query( ++ `CREATE INDEX "IDX_bd0397be22147e17210940e125" ON antenna_note USING btree ("noteId")`, ++ ); ++ await queryRunner.query( ++ `ALTER TABLE ONLY antenna_note ADD CONSTRAINT "FK_0d775946662d2575dfd2068a5f5" FOREIGN KEY ("antennaId") REFERENCES antenna(id) ON DELETE CASCADE`, ++ ); ++ await queryRunner.query( ++ `ALTER TABLE ONLY antenna_note ADD CONSTRAINT "FK_bd0397be22147e17210940e125b" FOREIGN KEY ("noteId") REFERENCES note(id) ON DELETE CASCADE`, ++ ); ++ } + } +diff --git a/packages/backend/migration/1680582195041-cleanup.js b/packages/backend/migration/1680582195041-cleanup.js +index c587e456a..a91d6ff3c 100644 +--- a/packages/backend/migration/1680582195041-cleanup.js ++++ b/packages/backend/migration/1680582195041-cleanup.js +@@ -1,11 +1,64 @@ + export class cleanup1680582195041 { +- name = 'cleanup1680582195041' ++ name = "cleanup1680582195041"; + +- async up(queryRunner) { +- await queryRunner.query(`DROP TABLE "notification" `); +- } ++ async up(queryRunner) { ++ await queryRunner.query(`DROP TABLE "notification"`); ++ } + +- async down(queryRunner) { +- +- } ++ async down(queryRunner) { ++ await queryRunner.query( ++ `CREATE TABLE notification ( id character varying(32) NOT NULL, "createdAt" timestamp with time zone NOT NULL, "notifieeId" character varying(32) NOT NULL, "notifierId" character varying(32), "isRead" boolean DEFAULT false NOT NULL, "noteId" character varying(32), reaction character varying(128), choice integer, "followRequestId" character varying(32), type notification_type_enum NOT NULL, "customBody" character varying(2048), "customHeader" character varying(256), "customIcon" character varying(1024), "appAccessTokenId" character varying(32), achievement character varying(128))`, ++ ); ++ await queryRunner.query( ++ `COMMENT ON COLUMN notification."createdAt" IS 'The created date of the Notification.'`, ++ ); ++ await queryRunner.query( ++ `COMMENT ON COLUMN notification."notifieeId" IS 'The ID of recipient user of the Notification.'`, ++ ); ++ await queryRunner.query( ++ `COMMENT ON COLUMN notification."notifierId" IS 'The ID of sender user of the Notification.'`, ++ ); ++ await queryRunner.query( ++ `COMMENT ON COLUMN notification."isRead" IS 'Whether the Notification is read.'`, ++ ); ++ await queryRunner.query( ++ `COMMENT ON COLUMN notification.type IS 'The type of the Notification.'`, ++ ); ++ await queryRunner.query( ++ `ALTER TABLE ONLY notification ADD CONSTRAINT "PK_705b6c7cdf9b2c2ff7ac7872cb7" PRIMARY KEY (id)`, ++ ); ++ await queryRunner.query( ++ `CREATE INDEX "IDX_080ab397c379af09b9d2169e5b" ON notification USING btree ("isRead")`, ++ ); ++ await queryRunner.query( ++ `CREATE INDEX "IDX_33f33cc8ef29d805a97ff4628b" ON notification USING btree (type)`, ++ ); ++ await queryRunner.query( ++ `CREATE INDEX "IDX_3b4e96eec8d36a8bbb9d02aa71" ON notification USING btree ("notifierId")`, ++ ); ++ await queryRunner.query( ++ `CREATE INDEX "IDX_3c601b70a1066d2c8b517094cb" ON notification USING btree ("notifieeId")`, ++ ); ++ await queryRunner.query( ++ `CREATE INDEX "IDX_b11a5e627c41d4dc3170f1d370" ON notification USING btree ("createdAt")`, ++ ); ++ await queryRunner.query( ++ `CREATE INDEX "IDX_e22bf6bda77b6adc1fd9e75c8c" ON notification USING btree ("appAccessTokenId")`, ++ ); ++ await queryRunner.query( ++ `ALTER TABLE ONLY notification ADD CONSTRAINT "FK_3b4e96eec8d36a8bbb9d02aa710" FOREIGN KEY ("notifierId") REFERENCES "user"(id) ON DELETE CASCADE`, ++ ); ++ await queryRunner.query( ++ `ALTER TABLE ONLY notification ADD CONSTRAINT "FK_3c601b70a1066d2c8b517094cb9" FOREIGN KEY ("notifieeId") REFERENCES "user"(id) ON DELETE CASCADE`, ++ ); ++ await queryRunner.query( ++ `ALTER TABLE ONLY notification ADD CONSTRAINT "FK_769cb6b73a1efe22ddf733ac453" FOREIGN KEY ("noteId") REFERENCES note(id) ON DELETE CASCADE`, ++ ); ++ await queryRunner.query( ++ `ALTER TABLE ONLY notification ADD CONSTRAINT "FK_bd7fab507621e635b32cd31892c" FOREIGN KEY ("followRequestId") REFERENCES follow_request(id) ON DELETE CASCADE`, ++ ); ++ await queryRunner.query( ++ `ALTER TABLE ONLY notification ADD CONSTRAINT "FK_e22bf6bda77b6adc1fd9e75c8c9" FOREIGN KEY ("appAccessTokenId") REFERENCES access_token(id) ON DELETE CASCADE`, ++ ); ++ } + } diff --git a/docs/nix-development.md b/docs/nix-development.md new file mode 100644 index 0000000..e957643 --- /dev/null +++ b/docs/nix-development.md @@ -0,0 +1,42 @@ +# 🌎 Iceshrimp Developer Docs + +## Nix Dev Environment +The Iceshrimp repo comes with a Nix-based shell environment to help make development as easy as possible! + +Please note, however, that this environment will not work on Windows outside of a WSL2 environment. + +### Prerequisites + +- Installed the [Nix Package Manager](https://nixos.org/download.html) (use the comman on their website) +- Installed [direnv](https://direnv.net/docs/installation.html) and added its hook to your shell. (package manager) +- Ensured all dependencies are pulled with `git-lfs`, which also needs to be installed. + +Once the repo is cloned to your computer, follow these next few steps inside the Iceshrimp folder: + +- Run `direnv allow`. This will build the environment and install all needed tools. +- Run `install-deps`, then `prepare-config`, to install the node dependencies and prepare the needed config files. +- In a second terminal, run `devenv up`. This will spawn a **Redis** server, a **Postgres** server, and the **Iceshrimp** server in dev mode. +- Once you see the Iceshrimp banner printed in your second terminal, run `migrate` in the first. +- Once migrations finish, open http://localhost:3000 in your web browser. +- You should now see the admin user creation screen! + +Note: When you want to restart a dev server, all you need to do is run `devenv up`, no other steps are necessary. + +### Windows Subsystem for Linux +if `devenv up` terminates because of wrong folder permissions, + +create the file `/etc/wsl.conf` in your distro and add +```shell +[automount] +options = "metadata" +``` + +this allows `chmod` calls to actually have an effect. +the build scripts DO actually set the permissions, it just needs to work in wsl. + +### Problems with the environment + +We don't anticipate any problems with the environment, as it is kept stable and does not require much maintainence. + +Nevertheless, if you do encounter nix-specific problems and are unable to solve these problems yourself, please join the [Matrix support Channel](https://matrix.to/#/%23iceshrimp-dev:161.rocks) +and ping @Pyrox with the specific error message you encounter. diff --git a/docs/podman-install.md b/docs/podman-install.md new file mode 100644 index 0000000..97dc289 --- /dev/null +++ b/docs/podman-install.md @@ -0,0 +1,92 @@ +# Installing Iceshrimp using Podman and Quadlet +Quadlet is a feature of Podman that is kind of like Docker Compose, but is better integrated with systemd, just like whole Podman. + +## Requirements +- Podman 4.4+ with aardvark +- Git with LFS installed (if building your own images) + +## Preparations + +### Getting needed files + +If you want to use prebuilt images: + +```sh +GIT_LFS_SKIP_SMUDGE=1 git clone https://iceshrimp.dev/iceshrimp/iceshrimp.git --depth=1 +mkdir -p $HOME/.config/containers/systemd +cp "iceshrimp/docs/examples/Podman (quadlet)"/* $HOME/.config/containers/systemd +``` + +Tweak quadlet files and change the image tag in `$HOME/.config/containers/systemd/iceshrimp-web.container` from `latest` to `dev` or `pre` if desired, and run `docs/examples/Podman\ \(quadlet\)/volume-dir-creation.sh`. + +If you want to build your own images: + +```sh +git lfs install +git clone https://iceshrimp.dev/iceshrimp/iceshrimp.git +mkdir -p $HOME/.config/containers/systemd +cp "iceshrimp/docs/examples/Podman (quadlet)"/* $HOME/.config/containers/systemd + +``` + +Tweak quadlet files if needed, change content of `Image:` line in `$HOME/.config/containers/systemd/iceshrimp-web.container` to `Image: localhost/iceshrimp/iceshrimp:latest`, and run `docs/examples/Podman\ \(quadlet\)/volume-dir-creation.sh`. + +### .config + +Edit `.config/docker.env` and fill it with the database credentials you want. +Edit `.config/default.yml` and: + +- Replace example database credentials with the ones you entered in `.config/docker.env` +- Change other configuration + +## Installation and first start + +Choose a method, whether you chose to build the image yourself or not. + +### Pulling the image + +```sh +podman pull $(grep -F "Image=" $HOME/.config/containers/systemd/iceshrimp-web.container | cut -d= -f2) +systemctl --user daemon-reload +systemctl --user start iceshrimp-web.service +``` + +### Building the image + +Enter Iceshrimp repo and run: + +```sh +podman build . -t $(grep -F "Image=" $HOME/.config/containers/systemd/iceshrimp-web.container | cut -d= -f2) --ulimit nofile=16384:16384 +systemctl --user daemon-reload +systemctl --user start iceshrimp-web.service +``` + +## Starting Iceshrimp automatically + +Run `sudo loginctl enable-linger [user]` and Iceshrimp will start automatically on boot. You don't need to, and in fact [cannot enable Podman-generated systemd services](https://man.archlinux.org/man/extra/podman/podman-systemd.unit.5.en#Enabling_unit_files). + +## Updating Iceshrimp + +### Pulling the image + +```sh +podman pull $(grep -F "Image=" $HOME/.config/containers/systemd/iceshrimp-web.container | cut -d= -f2) +systemctl --user restart iceshrimp-web.service +``` + +### Building the image + +```sh +## Run git stash commands only if you have uncommitted changes +git stash +git pull +git stash pop +podman build . -t $(grep -F "Image=" $HOME/.config/containers/systemd/iceshrimp-web.container | cut -d= -f2) --ulimit nofile=16384:16384 +systemctl --user restart iceshrimp-web.service +``` + +## Post-install + +If you are running Iceshrimp on a system with more than one CPU thread, you might want to set the `clusterLimit` config option to about half of your thread count, depending on your system configuration. Please note that each worker requires around 10 PostgreSQL connections, so be sure to set `max_connections` appropriately. To do this, change `max_connections=n` line in `db/postgresql.conf`, with `n` being `(10 * no_workers) + 10`, and run `systemctl --user restart iceshrimp-db iceshrimp-web`. + +See also [post-install](post-install.md). diff --git a/docs/post-install.md b/docs/post-install.md new file mode 100644 index 0000000..c735341 --- /dev/null +++ b/docs/post-install.md @@ -0,0 +1,48 @@ +# Post-install + +This document describes things you can do after successfully installing Iceshrimp. + +## Automatic translation + +### DeepL + +- Create a Free or Pro API account on [DeepL's website](https://www.deepl.com/pro#developer) +- Copy the API key to Control Panel > General > DeepL Translation + - Check the "Pro account" switch if you registered for paid account + +### LibreTranslate + +- Install [LibreTranslate](https://libretranslate.com/) +- Get an API URL and API key, copy and paste them into Control Panel > General > Libre Translate + +## Object Storage (S3) + +Recommended if using Docker +- Set up a bucket on provider's website (for example: AWS, Backblaze B2, Wasabi, minio or Google Cloud) +- Go to Control Panel > Object Storage and follow instructions + +## Customising assets, locale + +- To add custom CSS for all users, edit `custom/assets/instance.css`. +- To add static assets (such as images for the splash screen), place them in the `custom/assets/` directory. They'll then be available on https://example.com/static-assets/filename.ext. +- To add custom locales, place them in the `custom/locales/` directory. If you name your custom locale the same as an existing locale, it will overwrite it. If you give it a unique name, it will be added to the list. Also make sure that the first part of the filename matches the locale you're basing it on. (Example: en-FOO.yml) +- To add custom error images, place them in the `custom/assets/badges` directory, replacing the files already there. +- To add custom sounds, place only mp3 files in the `custom/assets/sounds` directory. +- To update custom assets without rebuilding, just run `yarn run gulp`. + +## Another admin account + +- Go to desired user's page, click 3 dots in upper right corner > About > Moderation, turn on "Moderator" +- Go back to Overview and copy their ID +- Run `psql -d iceshrimp`, replace `iceshrimp` with a name of your database if needed + - If instance is ran by a different system user: Prepend that command with `sudo -U iceshrimp`, replace `iceshrimp` with a name of that user if needed + - Docker Compose users: `docker compose exec db psql -d iceshrimp -U iceshrimp`, replace both `iceshrimp` with name of your db, and username owning that db respectively, if needed +- Run `UPDATE "user" SET "isAdmin" = true WHERE id='999999';`, where `999999` is the copied ID of that user +- Restart your Iceshrimp server + +### Removing admin privileges +- Get ID of the user +- Run `psql` the same way when adding admin +- Run `UPDATE "user" SET "isAdmin" = false WHERE id='999999';`, where `999999` is the copied ID of that user +- Restart your Iceshrimp server +- Remove moderator privileges of the user diff --git a/docs/renote_muting.patch b/docs/renote_muting.patch new file mode 100644 index 0000000..c5bd281 --- /dev/null +++ b/docs/renote_muting.patch @@ -0,0 +1,23 @@ +diff --git a/packages/backend/migration/1665091090561-add-renote-muting.js b/packages/backend/migration/1665091090561-add-renote-muting.js +index 2c76aaff5..f8541c818 100644 +--- a/packages/backend/migration/1665091090561-add-renote-muting.js ++++ b/packages/backend/migration/1665091090561-add-renote-muting.js +@@ -4,18 +4,6 @@ export class addRenoteMuting1665091090561 { + } + + async up(queryRunner) { +- await queryRunner.query( +- `CREATE TABLE "renote_muting" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "muteeId" character varying(32) NOT NULL, "muterId" character varying(32) NOT NULL, CONSTRAINT "PK_renoteMuting_id" PRIMARY KEY ("id"))`, +- ); +- await queryRunner.query( +- `CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt") `, +- ); +- await queryRunner.query( +- `CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId") `, +- ); +- await queryRunner.query( +- `CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId") `, +- ); + } + + async down(queryRunner) {} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..ebc1948 --- /dev/null +++ b/flake.lock @@ -0,0 +1,273 @@ +{ + "nodes": { + "devenv": { + "inputs": { + "flake-compat": "flake-compat", + "nix": "nix", + "nixpkgs": "nixpkgs", + "pre-commit-hooks": "pre-commit-hooks" + }, + "locked": { + "lastModified": 1698243190, + "narHash": "sha256-n+SbyNQRhUcaZoU00d+7wi17HJpw/kAUrXOL4zRcqE8=", + "owner": "cachix", + "repo": "devenv", + "rev": "86f476f7edb86159fd20764489ab4e4df6edb4b6", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1673956053, + "narHash": "sha256-4gtG9iQuiKITOjNQQeQIpoIB6b16fm+504Ch3sNKLd8=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "35bb57c0c8d8b62bbfd284272c928ceb64ddbde9", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1696343447, + "narHash": "sha256-B2xAZKLkkeRFG5XcHHSXXcP7To9Xzr59KXeZiRf4vdQ=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "c9afaba3dfa4085dbd2ccb38dfade5141e33d9d4", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1685518550, + "narHash": "sha256-o2d0KcvaXzTrPRIo0kOLV0/QXHhDQ5DTi+OxcjO8xqY=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "a1720a10a6cfe8234c0e93907ffe81be440f4cef", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "gitignore": { + "inputs": { + "nixpkgs": [ + "devenv", + "pre-commit-hooks", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1660459072, + "narHash": "sha256-8DFJjXG8zqoONA1vXtgeKXy68KdJL5UaXR8NtVMUbx8=", + "owner": "hercules-ci", + "repo": "gitignore.nix", + "rev": "a20de23b925fd8264fd7fad6454652e142fd7f73", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "gitignore.nix", + "type": "github" + } + }, + "lowdown-src": { + "flake": false, + "locked": { + "lastModified": 1633514407, + "narHash": "sha256-Dw32tiMjdK9t3ETl5fzGrutQTzh2rufgZV4A/BbxuD4=", + "owner": "kristapsdz", + "repo": "lowdown", + "rev": "d2c2b44ff6c27b936ec27358a2653caaef8f73b8", + "type": "github" + }, + "original": { + "owner": "kristapsdz", + "repo": "lowdown", + "type": "github" + } + }, + "nix": { + "inputs": { + "lowdown-src": "lowdown-src", + "nixpkgs": [ + "devenv", + "nixpkgs" + ], + "nixpkgs-regression": "nixpkgs-regression" + }, + "locked": { + "lastModified": 1676545802, + "narHash": "sha256-EK4rZ+Hd5hsvXnzSzk2ikhStJnD63odF7SzsQ8CuSPU=", + "owner": "domenkozar", + "repo": "nix", + "rev": "7c91803598ffbcfe4a55c44ac6d49b2cf07a527f", + "type": "github" + }, + "original": { + "owner": "domenkozar", + "ref": "relaxed-flakes", + "repo": "nix", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1678875422, + "narHash": "sha256-T3o6NcQPwXjxJMn2shz86Chch4ljXgZn746c2caGxd8=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "126f49a01de5b7e35a43fd43f891ecf6d3a51459", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "dir": "lib", + "lastModified": 1696019113, + "narHash": "sha256-X3+DKYWJm93DRSdC5M6K5hLqzSya9BjibtBsuARoPco=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "f5892ddac112a1e9b3612c39af1b72987ee5783a", + "type": "github" + }, + "original": { + "dir": "lib", + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-regression": { + "locked": { + "lastModified": 1643052045, + "narHash": "sha256-uGJ0VXIhWKGXxkeNnq4TvV3CIOkUJ3PAoLZ3HMzNVMw=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "215d4d0fd80ca5163643b03a33fde804a29cc1e2", + "type": "github" + } + }, + "nixpkgs-stable": { + "locked": { + "lastModified": 1685801374, + "narHash": "sha256-otaSUoFEMM+LjBI1XL/xGB5ao6IwnZOXc47qhIgJe8U=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "c37ca420157f4abc31e26f436c1145f8951ff373", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-23.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1698266953, + "narHash": "sha256-jf72t7pC8+8h8fUslUYbWTX5rKsRwOzRMX8jJsGqDXA=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "75a52265bda7fd25e06e3a67dee3f0354e73243c", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "pre-commit-hooks": { + "inputs": { + "flake-compat": [ + "devenv", + "flake-compat" + ], + "flake-utils": "flake-utils", + "gitignore": "gitignore", + "nixpkgs": [ + "devenv", + "nixpkgs" + ], + "nixpkgs-stable": "nixpkgs-stable" + }, + "locked": { + "lastModified": 1688056373, + "narHash": "sha256-2+SDlNRTKsgo3LBRiMUcoEUb6sDViRNQhzJquZ4koOI=", + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "rev": "5843cf069272d92b60c3ed9e55b7a8989c01d4c7", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "pre-commit-hooks.nix", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs_2" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..a1cffd3 --- /dev/null +++ b/flake.nix @@ -0,0 +1,80 @@ +{ + description = "Iceshrimp development flake"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable"; + # Flake Parts framework(https://flake.parts) + flake-parts.url = "github:hercules-ci/flake-parts"; + # Devenv for better devShells(https://devenv.sh) + devenv.url = "github:cachix/devenv"; + }; + outputs = inputs@{ flake-parts, ... }: + flake-parts.lib.mkFlake { inherit inputs; } { + imports = [ + inputs.devenv.flakeModule + ]; + + # Define the systems that this works on. Only tested with x86_64-linux, add more if you test and it works. + systems = [ + "x86_64-linux" + "aarch64-linux" + ]; + # Expose these attributes for every system defined above. + perSystem = { config, pkgs, ... }: { + # Devenv shells + devenv = { + shells = { + # The default shell, used by nix-direnv + default = { + name = "iceshrimp-dev-shell"; + # Add additional packages to our environment + packages = [ + pkgs.python3 + pkgs.corepack_20 + ]; + # No need to warn on a new version, we'll update as needed. + devenv.warnOnNewVersion = false; + # Enable typescript support + languages.typescript.enable = true; + # Enable javascript for NPM and Yarn + languages.javascript.enable = true; + languages.javascript.package = pkgs.nodejs_20; + processes = { + dev-server.exec = "yarn run dev"; + }; + scripts = { + build.exec = "yarn run build"; + clean.exec = "yarn run clean"; + clear-state.exec = "rm -rf .devenv/state/redis .devenv/state/postgres"; + format.exec = "yarn run format"; + install-deps.exec = "yarn install"; + migrate.exec = "yarn run migrate"; + prepare-config.exec = "cp .config/devenv.yml .config/default.yml"; + }; + services = { + postgres = { + enable = true; + package = pkgs.postgresql_12; + initialDatabases = [{ + name = "iceshrimp"; + }]; + initialScript = '' + CREATE USER iceshrimp WITH PASSWORD 'iceshrimp'; + ALTER USER iceshrimp WITH SUPERUSER; + GRANT ALL ON DATABASE iceshrimp TO iceshrimp; + ''; + listen_addresses = "127.0.0.1"; + port = 5432; + }; + redis = { + enable = true; + bind = "127.0.0.1"; + port = 6379; + }; + }; + }; + }; + }; + }; + }; +} diff --git a/locales/README.md b/locales/README.md new file mode 100644 index 0000000..a31df4f --- /dev/null +++ b/locales/README.md @@ -0,0 +1,6 @@ +# **DO NOT edit locale files** except `ja-JP.yml`. + +When you add text to the ja-JP file (of misskey-dev/misskey), it will automatically be applied to other language files. +Translations added in ja-JP file should contain the original Japanese strings. + +Please see [Contribution guide](../CONTRIBUTING.md) for more information. diff --git a/locales/ar-SA.yml b/locales/ar-SA.yml new file mode 100644 index 0000000..4a990ab --- /dev/null +++ b/locales/ar-SA.yml @@ -0,0 +1,1535 @@ +--- +_lang_: "العربية" +headlineIceshrimp: "شبكة مرتبطة بالملاحظات" +introIceshrimp: "اهلا بك! ميسكي هو منصة تدوين مصغر لا مركزية ومفتوحة المصدر.\nيمكنك مشاركة \"ملاحظات\" عن ما يجري حولك، وإخبار الجميع عن نفسك 📡\nتسمح لك \"الانفعالات\" بتعبير عن شعورك حول ملاحظات الآخرين 👍\nاكتشف عالمًا جديدًا 🚀" +monthAndDay: "{day}/{month}" +search: "البحث" +notifications: "الإشعارات" +username: "اسم المستخدم" +password: "الكلمة السرية" +forgotPassword: "نسيتَ كلمة السر" +fetchingAsApObject: "جارٍ جلبه مِن الفديفرس" +ok: " حسناً" +gotIt: "فهِمت" +cancel: " إلغاء" +enterUsername: "أدخِل إسم مسخدم" +renotedBy: "أعاد نشرها {user}" +noNotes: "لم يُعثر على أية ملاحظات" +noNotifications: "ليس هناك أية اشعارات" +instance: "مثيل الخادم" +settings: "الاعدادات" +basicSettings: "الاعدادات الأساسية" +otherSettings: "إعدادات أخرى" +openInWindow: "افتح في نافذة جديدة" +profile: "الملف التعريفي" +timeline: "الخيط الزمني" +noAccountDescription: "لم يكتب هذا المستخدم سيرته بعد." +login: "لِج" +loggingIn: "جارٍ تسجيل الدخول" +logout: "الخروج" +signup: "أنشئ حسابًا" +uploading: "يرفع..." +save: "حفظ" +users: "المستخدمون" +addUser: "اضافة مستخدم" +favorite: "أضفها للمفضلة" +favorites: "المفضلات" +unfavorite: "إزالة من المفضلة" +favorited: "أُضيف إلى المفضلة." +alreadyFavorited: "تمت إضافته بالفعل إلى المفضلة." +cantFavorite: "تعذرت الإضافة إلى المفضلة." +pin: "دبّسها على الصفحة الشخصية" +unpin: "ألغ تدبيسها من ملفك الشخصي" +copyContent: "انسخ المحتوى" +copyLink: "انسخ الرابط" +delete: "حذف" +deleteAndEdit: "إزالة وإعادة الصياغة" +deleteAndEditConfirm: "أمتأكد من حذف الملاحظة؟ ستفقد كل مشاركاتها، والتفاعلات، والردود عليها." +addToList: "أضفه إلى قائمة" +sendMessage: "أرسل رسالة" +copyUsername: "انسخ اسم المستخدم" +searchUser: "ابحث عن مستخدمين" +reply: "رد" +loadMore: "عرض المزيد" +showMore: "عرض المزيد" +showLess: "اغلق" +youGotNewFollower: "يتابعك" +receiveFollowRequest: "تلقيت طلب متابعة" +followRequestAccepted: "قُبل طلب المتابعة" +mention: "أشر الى" +mentions: "الإشارات" +directNotes: "الملاحظات المباشرة" +importAndExport: "إستورد / صدر" +import: "استيراد" +export: "تصدير" +files: "الملفات" +download: "تنزيل" +driveFileDeleteConfirm: "أمتأكد من حذف ملف {name}؟ كل الملاحظات المُرفق بها هذا الملف ستحذف." +unfollowConfirm: "أمتأكد من إلغاء متابعة {name}؟" +exportRequested: "قد تستغرق عملية التصدير بعض الوقت. بمجرد الانتهاء سيضاف الملف الناتج إلى قرص التخزين." +importRequested: "يستغرق الاستيراد بعض الوقت" +lists: "القوائم" +noLists: "ليس لديك أية قائمة" +note: "ملاحظة" +notes: "الملاحظات" +following: "المتابَعون" +followers: "المتابِعون" +followsYou: "يتابعك" +createList: "إنشاء قائمة" +manageLists: "إدارة القوائم" +error: "خطأ" +somethingHappened: "حدث خطأ" +retry: "حاول مجددًا" +pageLoadError: "فشل تحميل الصفحة" +pageLoadErrorDescription: "عادة ما يكون السبب خطأ في الشبكة أو التخزين المؤقت للمتصفح. امسح التخزين المؤقت ثم أعد المحاولة لاحقًا." +serverIsDead: "الخادم لا يستجيب، حاول بعد قليل" +youShouldUpgradeClient: "حدّث الصفحة لعرضها." +enterListName: "اسم القائمة" +privacy: "الخصوصية" +makeFollowManuallyApprove: "قبول طلبات الإشتراك يدويا" +defaultNoteVisibility: "مدى الرؤية الافتراضي" +follow: "تابِع" +followRequest: "طلب اشتراك" +followRequests: "طلبات الإشتراك" +unfollow: "إلغاء الاشتراك" +followRequestPending: "طلبات الإشتراك المعلّقة" +enterEmoji: "أدخل إيموجي" +renote: "أعد النشر" +unrenote: "إلغاء مشاركة الملاحظة" +renoted: "أُعيد نشره" +cantRenote: "لا يمكن إعادة نشر الملاحظة" +cantReRenote: "لا يمكنك إعادة نشر ملاحظة معاد نشرها" +quote: "اقتبس" +pinnedNote: "ملاحظة مدبسة" +pinned: "دبّسها على الصفحة الشخصية" +you: "أنت" +clickToShow: "اضغط للعرض" +sensitive: "محتوى حساس" +add: "إضافة" +reaction: "التفاعلات" +reactionSetting: "التفاعلات المراد عرضها في منتقي التفاعلات." +reactionSettingDescription2: "اسحب لترتيب ، انقر للحذف ، استخدم \"+\" للإضافة." +rememberNoteVisibility: "تذكر إعدادت مدى رؤية الملاحظات" +attachCancel: "أزل المرفق" +markAsSensitive: "علّمه كمحتوى حساس" +unmarkAsSensitive: "ألغ تعيينه كمحتوى حساس" +enterFileName: "ادخل اسم الملف" +mute: "اكتم" +unmute: "إلغاء الكتم" +block: "احجب" +unblock: "إلغاء الحجب" +suspend: "علِق" +unsuspend: "ألغ التعليق" +blockConfirm: "أمتأكد من حجب هذا الحساب؟" +unblockConfirm: "أمتأكد من إلغاء حجب هذا الحساب؟" +suspendConfirm: "أمتأكد من تعليق الحساب؟" +unsuspendConfirm: "أمتأكد من إلغاء تعليق؟" +selectList: "اختر قائمة" +selectAntenna: "اختر هوائيًا" +selectWidget: "اختر ودجة" +editWidgets: "عدّل الودجات" +editWidgetsExit: "تم" +customEmojis: "إيموجي مخصص" +emoji: "إيموجي" +emojis: "إيموجي" +emojiName: "اسم الإيموجي" +emojiUrl: "رابط الإيموجي" +addEmoji: "إضافة إيموجي" +settingGuide: "الإعدادات المستحسنة" +cacheRemoteFiles: "خزن مؤقتا الملفات البعيدة" +flagAsBot: "علّمه كحساب آلي" +flagAsBotDescription: "فعّل هذا الخيار إذا كان هذا الحساب يُدار عبر برمجية. إذا فُعل فسيكون بمثابة علامة للمطورين الآخرين لتجنب سلاسل لا متناهية من التفاعل بين حسابات الآلية وضبط أنظمة ميسكي للتعامل مع هذا الحساب كآلي." +flagAsCat: "علّم هذا الحساب كحساب قط" +flagAsCatDescription: "فعّل هذا الخيار لوضع علامة على الحساب لتوضيح أنه حساب قط." +flagShowTimelineReplies: "أظهر التعليقات في الخيط الزمني" +flagShowTimelineRepliesDescription: "يظهر الردود في الخيط الزمني" +autoAcceptFollowed: "اقبل طلبات المتابعة تلقائيا من الحسابات المتابَعة" +addAccount: "أضف حساباً" +loginFailed: "فشل الولوج" +showOnRemote: "رؤيته على مثيل الخادم البُعدي" +general: "الرئيسية" +wallpaper: "الخلفية" +setWallpaper: "عيّن خلفية" +removeWallpaper: "أزل الخلفية" +searchWith: "البحث: {q}" +youHaveNoLists: "لا تمتلك أية قائمة" +followConfirm: "أتريد متابعة {name}؟" +proxyAccount: "حساب وكيل البروكسي" +proxyAccountDescription: "يتصرف حساب الوكيل كمتابع بعيد لمستخدمين تحت ظروف معينة. على سبيل المثال ، عندما يضيف مستخدم مستخدمًا بعيدًا إلى قائمة فإن ملاحظاته لن تُرسل إلى المثيل ما لم يُتابعه مستخدم محلي. وبالتالي فإن حساب الوكيل سوف يتابع هذا المستخدم لكي تُرسل ملاحظاته." +host: "المضيف" +selectUser: "حدّد مستخدمًا" +recipient: "المرسَل إليه·ها" +annotation: "التعليقات" +federation: "الفديرالية" +instances: "مثيل الخادم" +registeredAt: "مسجل منذ" +latestRequestSentAt: "آخر طلب أرسِل في" +latestRequestReceivedAt: "آخر طلب تُلقي في" +latestStatus: "الحالات الأخيرة" +storageUsage: "مساحة التخزين المستخدمة" +charts: "المنحنيات البيانية" +perHour: "في الساعة" +perDay: "في اليوم" +stopActivityDelivery: "وقف إرسال النشاط" +blockThisInstance: "احجب مثيل الخادم هذا" +operations: "الإجراءات" +software: "البرمجية" +version: "الإصدار" +metadata: "البيانات الوصفية" +monitor: "شاشة التحكم" +jobQueue: "قائمة الانتظار" +cpuAndMemory: "وحدة المعالجة المركزية والذاكرة" +network: "الشبكة" +disk: "قرص التخزين" +instanceInfo: "معلومات مثيل الخادم" +statistics: "الإحصائيات" +clearQueue: "تفريغ قائمة الإنتظار" +clearQueueConfirmTitle: "أتريد مسح الطابور؟" +clearCachedFiles: "امسح التخزين المؤقت" +clearCachedFilesConfirm: "أتريد حذف التخزين المؤقت للملفات البعيدة؟" +blockedInstances: "المثلاء المحجوبون" +blockedInstancesDescription: "قائمة بالمثلاء التي تريد حظرها بحيث كل نطاق في سطر لوحده. بعد إدراجهم لن يتمكنوا من التفاعل مع هذا المثيل." +muteAndBlock: "المكتومون والمحجوبون" +mutedUsers: "الحسابات المكتومة" +blockedUsers: "الحسابات المحجوبة" +noUsers: "ليس هناك مستخدمون" +editProfile: "تعديل الملف التعريفي" +noteDeleteConfirm: "هل تريد حذف هذه الملاحظة؟" +pinLimitExceeded: "لا يمكنك تدبيس الملاحظات بعد الآن." +intro: "لقد انتهت عملية تنصيب Iceshrimp. الرجاء إنشاء حساب إداري." +done: "تمّ" +processing: "المعالجة جارية" +preview: "معاينة" +default: "افتراضي" +noCustomEmojis: "ليس هناك إيموجي" +noJobs: "لا توجد مهام" +federating: "الفديرالية جارية" +blocked: "محجوب" +suspended: "مُعلّق" +all: "الكل" +notResponding: "لا يستجيب" +instanceFollowing: "المثلاء المتابَعون" +instanceFollowers: "المثلاء المتابِعون" +instanceUsers: "مستخدمو المثيل" +changePassword: "تغيير الكلمة السرية" +security: "الأمان" +retypedNotMatch: "المدخلات لا تتطابق" +currentPassword: "كلمة المرور الحالية" +newPassword: "كلمة المرور الجديدة" +newPasswordRetype: "كرّر كلمة المرور الجديدة:" +attachFile: "أرفق ملفات" +more: "المزيد!" +featured: "المتداولة" +usernameOrUserId: "اسم المستخدم أو معرّفه" +noSuchUser: "لم يُعثَر على المستخدم" +lookup: "البحث" +announcements: "الإعلانات" +imageUrl: "رابط الصورة" +remove: "حذف" +removed: "حُذف بنجاح" +removeAreYouSure: "متأكد من أنك تريد حذف {x}؟" +deleteAreYouSure: "متأكد من أنك تريد حذف {x}؟" +resetAreYouSure: "هل تريد إعادة التعيين؟" +saved: "حُفظ" +messaging: "المحادثة" +upload: "ارفع" +keepOriginalUploading: "ابق الصورة الأصلية" +keepOriginalUploadingDescription: "يحفظ الصور المرفوعة على حالتها الأصلية، وان عطّل ستولد نسخة مخصصة من الصورة." +fromDrive: "من المخزن" +fromUrl: "عبر رابط" +uploadFromUrl: "ارفع عبر رابط" +uploadFromUrlDescription: "رابط الملف المراد رفعه" +uploadFromUrlRequested: "الرفع مطلوب" +uploadFromUrlMayTakeTime: "سيستغرق بعض الوقت لاتمام الرفع " +explore: "استكشاف" +messageRead: "مقروءة" +noMoreHistory: "لا يوجد المزيد من التاريخ" +startMessaging: "ابدأ محادثة" +nUsersRead: "قرأه {n}" +agreeTo: "اوافق على {0}" +tos: "شروط الخدمة" +start: "البداية" +home: "الرئيسي" +remoteUserCaution: "هذه المعلومات قد لا تكون مكتملة بما أن المستخدم من مثيل بعيد." +activity: "النشاط" +images: "الصور" +birthday: "تاريخ الميلاد" +yearsOld: "{age} سنة" +registeredDate: "انضم في" +location: "الموقع الجغرافي" +theme: "المظهر" +themeForLightMode: "الحلة في الوضع الفاتح" +themeForDarkMode: "الحلة في الوضع الداكن" +light: "فاتح" +dark: "داكن" +lightThemes: "الحلة الفاتحة" +darkThemes: "الحلة الداكنة" +syncDeviceDarkMode: "مطابقة الوضع المضلمومع اعدادات الجهاز" +drive: "قرص التخرين" +fileName: "اسم الملف" +selectFile: "اختر ملفًا" +selectFiles: "اختر ملفات" +selectFolder: "اختر مجلدًا" +selectFolders: "اختر مجلدات" +renameFile: "إعادة تسمية الملف" +folderName: "اسم المجلد" +createFolder: "أنشئ مجلدًا" +renameFolder: "إعادة تسمية المجلد" +deleteFolder: "احذف هذا المجلد" +addFile: "إضافة ملف" +emptyDrive: "قرص التخزين فارغ" +emptyFolder: "هذا المجلد فارغ" +unableToDelete: "لا يمكن حذفه" +inputNewFileName: "ادخل الإسم الجديد للملف" +inputNewDescription: "أدخل تعليقًا توضيحيًا" +inputNewFolderName: "ادخل الإسم الجديد للمجلد" +circularReferenceFolder: "المجلد المستهدف ينتمي للمجلد الذي تريد حذفه" +hasChildFilesOrFolders: "الان الملف غير فارغ. لا يمكن حذفه" +copyUrl: "انسخ الرابط" +rename: "إعادة التسمية" +avatar: "الصورة الرمزية" +banner: "الصورة الرأسية" +nsfw: "محتوى حساس" +whenServerDisconnected: "عند فقدان الاتصال بالخادم" +disconnectedFromServer: "قُطِع الإتصال بالخادم" +reload: "انعش" +doNothing: "تجاهل" +reloadConfirm: "هل ترغب في تحديث الجدول الزمني؟" +watch: "راقب" +unwatch: "إلغاء المراقبة" +accept: "السماح" +reject: "رفض" +normal: "عادي" +instanceName: "اسم مثيل الخادم" +instanceDescription: "وصف مثيل الخادم" +maintainerName: "المدير" +maintainerEmail: "عنوان بريد المدير الإلكتروني" +tosUrl: "رابط صفحة شروط الخدمة" +thisYear: "هذا العام" +thisMonth: "هذا الشهر" +today: "اليوم" +dayX: "{day}" +monthX: "{month}" +yearX: "{year}" +pages: "الصفحات" +integration: "التكامل" +connectService: "اتصل" +disconnectService: "اقطع الاتصال" +enableLocalTimeline: "تفعيل الخيط المحلي" +enableGlobalTimeline: "تفعيل الخيط الزمني الشامل" +disablingTimelinesInfo: "سيتمكن المديرون والمشرفون من الوصول إلى كل الخيوط الزمنية حتى وإن لم تفعّل." +registration: "إنشاء حساب" +enableRegistration: "تفعيل إنشاء الحسابات الجديدة" +invite: "دعوة" +driveCapacityPerLocalAccount: "حصة التخزين لكل مستخدم محلي" +driveCapacityPerRemoteAccount: "حصة التخزين لكل مستخدم بعيد" +inMb: "بالميغابايت" +iconUrl: "رابط الأيقونة" +bannerUrl: "رابط صورة اللافتة" +backgroundImageUrl: "رابط صورة الخلفية" +basicInfo: "المعلومات الأساسية " +pinnedUsers: "المستخدمون المدبسون" +pinnedUsersDescription: "قائمة المستخدمين المدبسين في لسان \"استكشف\" ، اجعل كل اسم مستخدم في سطر لوحده." +pinnedPages: "الصفحات المدبسة" +pinnedPagesDescription: "أدخل مسار الصفحات التي تريد تدبيسها في أعلى هذا الموقع، اجعل كل مسار في سطر لوحده." +pinnedClipId: "معرّف المشبك المدبس" +pinnedNotes: "ملاحظة مدبسة" +hcaptcha: "hCaptcha" +enableHcaptcha: "فعّل hCaptcha" +hcaptchaSiteKey: "مفتاح الموقع" +hcaptchaSecretKey: "المفتاح السري" +recaptcha: "reCAPTCHA" +enableRecaptcha: "تمكين reCAPTCHA" +recaptchaSiteKey: "مفتاح الموقع" +recaptchaSecretKey: "المفتاح السري" +avoidMultiCaptchaConfirm: "يمكن أن يتسبب استخدام عدة خدمات لكلمات التحقق في حدوث تداخل. هل ترغب في إلغاء تنشيط الخدمات الأخرى؟ يمكنك ترك هذه الخدمات نشطة بالضغط على \"ألغ\"." +antennas: "الهوائيات" +manageAntennas: "إدارة الهوائيات" +name: "الإسم" +antennaSource: "مصدر الهوائي" +antennaKeywords: "الكلمات المفتاحية للإستقبال" +antennaExcludeKeywords: "الكلمات المفتاحية المستثناة" +antennaKeywordsDescription: "افصل بينهم بمسافة لاستخدام معامل \"و\" أو بسطر لاستخدام معامل \"أو\"" +notifyAntenna: "نبهني بصول ملاحظات جديدة" +withFileAntenna: "ملاحظات تحوي ملفات فقط" +antennaUsersDescription: "اكتب اسم مستخدم لكل سطر" +caseSensitive: "حساسية حالة الأحرف" +withReplies: "بالردود" +connectedTo: "الحسابات التالية متصلة" +notesAndReplies: "الملاحظات والردود" +withFiles: "ذات مرفقات" +silence: "اكتم" +silenceConfirm: "أمتأكد من كتم هذا المستخدم؟" +unsilence: "إلغاء الكتم" +unsilenceConfirm: "أمتأكد من إلغاء كتم هذا المستخدم؟" +popularUsers: "المستخدمون الرائدون" +recentlyUpdatedUsers: "أصحاب النشاطات الأخيرة" +recentlyRegisteredUsers: "المستخدمون المنضمون حديثًا" +recentlyDiscoveredUsers: "المستخدمون المكتشفون حديثًا" +exploreUsersCount: "يوجد {count} مستخدم(ا)" +exploreFediverse: "استكشف الفديفرس" +popularTags: "الوسوم الرائجة" +userList: "القوائم" +about: "عن" +aboutIceshrimp: "عن Iceshrimp" +administrator: "المدير" +token: "الرمز المميز" +twoStepAuthentication: "الإستيثاق بعاملَيْن" +moderator: "مشرِف" +nUsersMentioned: "{n} مستخدمين أُشير إليهم" +securityKey: "مفتاح الأمان" +securityKeyName: "اسم المفتاح" +registerSecurityKey: "سجل مفتاح أمان" +lastUsed: "آخر استخدام" +unregister: "إلغاء التسجيل" +passwordLessLogin: "لِج مِن دون كلمة سرية" +resetPassword: "أعد تعيين كلمتك السرية" +newPasswordIs: "كلمتك السرية الجديدة هي {password}" +reduceUiAnimation: "قلص تأثيرات الواجهة" +share: "شارِك" +notFound: "غير موجود" +notFoundDescription: "تعذر العثور على صفحة يقود إليها هذا الرابط." +uploadFolder: "المجلد الافتراضي للرفع" +cacheClear: "مسح ذاكرة التخزين المؤقت" +markAsReadAllNotifications: "وضع جميع الإشعارات كأنها مقروءة" +markAsReadAllUnreadNotes: "علّم جميع الملاحظات كمقروءة" +markAsReadAllTalkMessages: "علّم جميع الرسائل كمقروءة" +help: "المساعدة" +inputMessageHere: "اكتب رسالتك هنا" +close: "اغلق" +group: "الفريق" +groups: "الفِرَق" +createGroup: "انشئ فريقًا" +ownedGroups: "فِرقي" +joinedGroups: "الفِرق المُنضم إليها" +invites: "دعوة" +groupName: "اسم الفريق" +members: "الأعضاء" +transfer: "نقل" +messagingWithUser: "تحدث مع مستخدم" +messagingWithGroup: "محادثة جماعية" +title: "العنوان" +text: "النص" +enable: "تشغيل" +next: "التالية" +retype: "أعد الكتابة" +noteOf: "ملاحظات {user}" +inviteToGroup: "دعوة إلى فريق" +quoteAttached: "اِقتُبسَ" +quoteQuestion: "أتريد تضمينها كاقتباس" +noMessagesYet: "ليس هناك رسائل بعد" +newMessageExists: "لقد تلقيت رسالة جديدة" +onlyOneFileCanBeAttached: "يمكنك إرفاق ملف واحد بالرسالة" +signinRequired: "رجاءً لِج" +invitations: "دعوة" +invitationCode: "رمز الدعوة" +checking: "التحقق جارٍ" +available: "متوفر" +unavailable: "غير متوفر" +usernameInvalidFormat: "يمكنك استخدام A-z، a-z، 0-9، _" +tooShort: "قصير جدًا" +tooLong: "طويل جدًا" +weakPassword: "الكلمة السرية ضعيفة" +normalPassword: "الكلمة السرية جيدة" +strongPassword: "الكلمة السرية قوية" +passwordMatched: "التطابق صحيح!" +passwordNotMatched: "غير متطابقتان" +signinWith: "الولوج عبر {x}" +signinFailed: "فشل الولوج، خطأ في اسم المستخدم أو كلمة المرور." +tapSecurityKey: "أنقر مفتاح الأمان" +or: "أو" +language: "اللغة" +uiLanguage: "لغة واجهة المستخدم" +groupInvited: "دُعيت إلى فريقٍ" +aboutX: "عن {x}" +useOsNativeEmojis: "استخدم الإيموجي الخاصة بنظام التشغيل" +youHaveNoGroups: "لا تمتلك أية فِرَق" +joinOrCreateGroup: "احصل على دعوة لفريق أو أنشئ واحدًا." +noHistory: "السجل فارغ" +signinHistory: "تاريخ تسجيل الدخول" +doing: "انتظر لحظة" +category: "الفئات" +tags: "الوسوم" +docSource: "مصدر هذا المستند" +createAccount: "أنشئ حسابًا" +existingAccount: "الحسابات الموجودة" +regenerate: "أعِد التوليد" +fontSize: "حجم الخط" +noFollowRequests: "ليس لديك طلبات متابعة معلقة" +openImageInNewTab: "إفتح الصورة بصفحة جديدة" +dashboard: "لوحة التحكم" +local: "المحلي" +remote: "بُعدي" +total: "المجموع" +weekOverWeekChanges: "أسبوعيا" +dayOverDayChanges: "يوميا" +appearance: "المظهر" +clientSettings: "إعدادات العميل" +accountSettings: "إعدادات الحساب" +promotion: "ترقية" +promote: "روِّج" +numberOfDays: "عدد الأيام" +hideThisNote: "إخفاء هذه الملاحظة" +showFeaturedNotesInTimeline: "أظهر الملاحظات الشائعة في الخيط الزمني" +objectStorageBaseUrl: "الرابط الأساسي" +objectStoragePrefix: "البادئة" +objectStoragePrefixDesc: "ستُحفظ الملفات في مجلدات تحوي اسماءها هذه البادئة." +objectStorageEndpoint: "نقطة النهاية" +objectStorageRegion: "المنطقة" +objectStorageUseSSL: "استخدم SSL" +objectStorageUseSSLDesc: "عطل هذا الخيار إذا لم ترد استخدام API عبر HTTPS" +objectStorageUseProxy: "اتصل عبر وكيل" +objectStorageUseProxyDesc: "عطل هذا الخيار إذا لم ترد استخدام API عبر وكيل" +serverLogs: "سجلات الخادم" +deleteAll: "حذف الكل" +showFixedPostForm: "أظهر نموذج الكتابة في أعلى الصفحة" +newNoteRecived: "هناك ملاحظات جديدة" +sounds: "الرنات" +listen: "استمع" +none: "لا شيء" +showInPage: "اعرض في الصفحة" +popout: "منبثقة" +volume: "مستوى الصوت" +masterVolume: "حجم الصوت الرئيس" +details: "التفاصيل" +chooseEmoji: "اختر إيموجي" +unableToProcess: "يتعذر إكمال العملية" +recentUsed: "المستخدمة مؤخرا" +install: "ثبّت" +uninstall: "إلغاء التثبيت" +installedApps: "التطبيقات المُخوّلة" +nothing: "لا يوجد شيء هنا" +installedDate: "تاريخ التثبيت" +lastUsedDate: "آخر استخدام" +state: "الحالة" +sort: "ترتيب حسب" +ascendingOrder: "تصاعدي" +descendingOrder: "تنازلي" +output: "الخارجة" +disablePagesScript: "عطّل AiScript في الصفحات" +updateRemoteUser: "تحديث المعلومات عن المستخدم البعيد" +deleteAllFiles: "حذف كافة الملفات" +deleteAllFilesConfirm: "أتريد حذف كل الملفات؟" +removeAllFollowing: "ألغ متابعة كل المتابَعين" +removeAllFollowingDescription: "تنفيذه سيلغي متابعة المستخدمين المتواجدين على {host}. يمكنك استخدامه إذا فُقد الخادم." +userSuspended: "عُلق هذا المستخدم." +userSilenced: "كُتم هذا المستخدم." +yourAccountSuspendedTitle: "هذا الحساب معلق" +yourAccountSuspendedDescription: "عُلق الحساب بسبب انتهاك شروط خدمة المثيل و ما شابه. إذا أردت معرفة التفصيل تواصل مع مدير المثيل. رجاءً لا تنشئ حساب جديد." +menu: "القائمة" +divider: "فاصل" +addItem: "إضافة عنصر" +relays: "المُرَحلات" +addRelay: "إضافة مُرحّل" +inboxUrl: "رابط صندوق الوارد" +addedRelays: "المرحلات المضافة" +serviceworkerInfo: "يجب أن يفعل لإرسال الإشعارات." +deletedNote: "ملاحظة محذوفة" +invisibleNote: "ملاحظة مخفية" +enableInfiniteScroll: "فعّل التمرير المتواصل" +visibility: "الظهور" +poll: "استطلاع رأي" +useCw: "إخفاء المحتوى" +enablePlayer: "افتح مشغل الفيديو" +disablePlayer: "أغلق مشغل الفيديو" +expandTweet: "وسّع التغريدة" +themeEditor: "مصمم القوالب" +description: "الوصف" +describeFile: "أضف تعليقًا توضيحيًا" +enterFileDescription: "أدخل تعليقًا توضيحيًا" +author: "الكاتب" +leaveConfirm: "لديك تغييرات غير محفوظة. أتريد المتابعة دون حفظها؟" +manage: "إدارة " +plugins: "الإضافات" +useFullReactionPicker: "استخدم الحجم الكامل لمنتقي التفاعلات" +width: "العرض" +height: "الإرتفاع" +large: "كبير" +medium: "متوسط" +small: "صغير" +generateAccessToken: "ولّد رمز الوصول" +permission: "أذونات" +enableAll: "تشغيل الكل" +disableAll: "تعطيل الكل" +tokenRequested: "منح حق الوصول إلى الحساب" +pluginTokenRequestedDescription: "ستتمكن الإضافة من استخدام هذه الأذونات." +notificationType: "أنواع الإشعارات" +edit: "التعديل" +emailServer: "خادم البريد الإلكتروني" +emailConfigInfo: "يستخدم لتأكيد عنوان بريدك الإلكتروني ولإعادة تعيين كلمة المرور إن نسيتها." +email: "البريد الإلكتروني " +emailAddress: "عنوان البريد الالكتروني" +smtpConfig: "إعدادات خادم SMTP" +smtpHost: "المضيف" +smtpPort: "المنفذ" +smtpUser: "اسم المستخدم" +smtpPass: "الكلمة السرية" +emptyToDisableSmtpAuth: "اترك اسم المستخدم وكلمة المرور فارغين لتعطيل التحقق من SMTP" +smtpSecureInfo: "عطل هذا الخيار عند استخدام STARTTLS" +wordMute: "حظر الكلمات" +regexpError: "خطأ في التعبير النمطي" +instanceMute: "المثلاء المكتومون" +userSaysSomething: "كتب {name} شيءً" +makeActive: "تفعيل" +display: "المظهر" +copy: "نسخ" +metrics: "المقاييس" +overview: "ملخص عام" +logs: "السِجلّات" +delayed: "متأخر" +database: "قاعدة البيانات" +channel: "القنوات" +create: "أنشئ" +notificationSetting: "إعدادات التنبيهات" +notificationSettingDesc: "اختر نوع التنبيهات المراد عرضها" +useGlobalSetting: "استخدم الإعدادات العامة" +useGlobalSettingDesc: "اذا فعّل ستطبق إعدادات إشعارات حسابك. إذا عطّل يمكن إجراء تكوينات مخصصة." +other: "منوعات" +regenerateLoginToken: "أعد توليد الرمز" +regenerateLoginTokenDescription: "ينشئ رمز استيثاق جديد في العادة هذا ليس ضروريًا ؛ عند إنشاء رمز جديد ستُخرج جميع الأجهزة." +setMultipleBySeparatingWithSpace: "يمكنك ادخال أكثر من مدخل واحد وذلك بفصلها بمسافات." +fileIdOrUrl: "معرف الملف أو رابط" +behavior: "السلوك" +sample: "مثال" +abuseReports: "البلاغات" +reportAbuse: "أبلغ" +reportAbuseOf: "أبلغ عن {name}" +fillAbuseReportDescription: "أكتب بالتفصيل سبب البلاغ، إذا كنت تبلغ عن ملاحظة أرفق رابط لها." +abuseReported: "أُرسل البلاغ، شكرًا لك" +reporter: "المُبلّغ" +reporteeOrigin: "أصل البلاغ" +reporterOrigin: "أصل المُبلّغ" +forwardReport: "وجّه البلاغ إلى المثيل البعيد" +forwardReportIsAnonymous: "في المثيل البعيد سيظهر المبلّغ كحساب مجهول." +send: "أرسل" +abuseMarkAsResolved: "علّم البلاغ كمحلول" +openInNewTab: "افتح في لسان جديد" +defaultNavigationBehaviour: "سلوك الملاحة الافتراضي" +editTheseSettingsMayBreakAccount: "تعديل هذه الإعدادات قد يسبب عطبًا لحسابك" +instanceTicker: "معلومات المثيل الأصلي للملاحظات" +waitingFor: "في انتظار {x}" +random: "عشوائي" +system: "النظام" +switchUi: "بدّل واجهة المستخدم" +desktop: "سطح المكتب" +clip: "مِشبك" +createNew: "أنشِئ جديد" +optional: "اختياري" +createNewClip: "أنشئ مِشبكَا جديدًا" +public: "علني" +i18nInfo: "يترجم متطوعون ميسكي إلى عدة لغات، يمكنك المساعدة عبر {link}" +manageAccessTokens: "إدارة رموز الوصول" +accountInfo: "معلومات الحساب" +notesCount: "عدد الملاحظات" +repliesCount: "عدد الردود المرسلة" +renotesCount: "عدد الملاحظات المعاد نشرها (المرسلة)" +repliedCount: "عدد الردود المتلقاة" +renotedCount: "عدد الملاحظات المعاد نشرها (المتلقاة)" +followingCount: "عدد الحسابات المتابَعة" +followersCount: "عدد المتابِعين" +sentReactionsCount: "عدد الانفعالات المرسلة" +receivedReactionsCount: "عدد الانفعالات المتلقاة" +pollVotesCount: "عدد الاستطلاعات المرسلة" +pollVotedCount: "عدد الاستطلاعات المتلقاة" +yes: "نعم" +no: "لا" +driveFilesCount: "عدد الملفات في قرص التخزين" +driveUsage: "المستغل من قرص التخزين" +noCrawle: "ارفض فهرسة زاحف الويب" +noCrawleDescription: "يطلب من محركات البحث ألّا يُفهرسوا ملفك الشخصي وملاحظات وصفحاتك وما شابه." +alwaysMarkSensitive: "علّم افتراضيًا جميع ملاحظاتي كذات محتوى حساس" +loadRawImages: "حمّل الصور الأصلية بدلًا من المصغرات" +disableShowingAnimatedImages: "لا تشغّل الصور المتحركة" +verificationEmailSent: "أُرسل بريد التحقق. أنقر على الرابط المضمن لإكمال التحقق." +notSet: "لم يعيّن" +emailVerified: "تُحقّق من بريدك الإلكتروني" +noteFavoritesCount: "عدد الملاحظات المفضلة" +pageLikesCount: "عدد الصفحات التي أعجبت بها" +pageLikedCount: "عدد صفحاتك المُعجب بها" +contact: "التواصل" +useSystemFont: "استخدم الخط الافتراضية للنظام" +clips: "مشابك" +experimentalFeatures: "ميّزات اختبارية" +developer: "المطور" +makeExplorable: "أظهر الحساب في صفحة \"استكشاف\"" +makeExplorableDescription: "بتعطيل هذا الخيار لن يظهر حسابك في صفحة \"استكشاف\"" +showGapBetweenNotesInTimeline: "أظهر فجوات بين المشاركات في الخيط الزمني" +wide: "عريض" +narrow: "رفيع" +reloadToApplySetting: "سيُطبق هذا الإعداد بعد إعادة تحميل الصفحة، أتريد إعادة تحميلها الآن؟" +needReloadToApply: "سيطبق هذا بعد إعادة التحميل." +showTitlebar: "اعرض شريط العنوان" +clearCache: "امسح التخزين المؤقت" +onlineUsersCount: "{n} مستخدم متصل" +nUsers: "{n} مستخدم" +nNotes: "{n} ملاحظة" +sendErrorReports: "أرسل تقارير الأخطاء" +sendErrorReportsDescription: "إذا فعّلته ستساعد في تحسين ميسكي وذلك عبر مشاركة معلومات تفصيلية عن الخطأ.\nومما تحتويه التقارير: نسخة نظام التشغيل ونوع المتصفح وسجل نشاطك إلخ." +myTheme: "سماتي" +backgroundColor: "لون الخلفية" +accentColor: "طابع لوني" +textColor: "لون النص" +saveAs: "احفظ كـ..." +advanced: "متقدم" +value: "القيمة" +createdAt: "أُنشئ في" +updatedAt: "حُدّث في" +saveConfirm: "أتريد خفظ التغييرات؟" +deleteConfirm: "أمتأكد من الحذف؟" +invalidValue: "قيمة غير صالحة." +registry: "السجل" +closeAccount: "اختر حسبًا" +currentVersion: "الإصدار الحالي" +latestVersion: "آخر نسخة مستقرة" +youAreRunningUpToDateClient: "أنت تستخدم أحدث نسخة من العميل." +newVersionOfClientAvailable: "تتوفر نسخة أحدث للعميل" +usageAmount: "الإستخدام" +capacity: "السعة" +inUse: "مستخدم" +editCode: "حرر الشفرة" +apply: "تطبيق" +receiveAnnouncementFromInstance: "استلم إشعارات من هذا المثيل" +emailNotification: "إشعارات البريد الكتروني" +inChannelSearch: "ابحث عن قناة" +useReactionPickerForContextMenu: "افتح منتقي التفاعلات عند النقر بالزر الأيمن" +typingUsers: "{users} يكتب(ون)" +jumpToSpecifiedDate: "انتقل إلى تاريخ محدد" +showingPastTimeline: "أنت تستعرض حاليًا خيطًا زمنيًا قديمًا" +clear: "عودة" +markAllAsRead: "علّم الكل كمقروء" +goBack: "رجوع" +unlikeConfirm: "أتريد إلغاء إعجابك؟" +fullView: "ملء الشاشة" +quitFullView: "اخرج من وضع ملء للشاشة" +addDescription: "أضف وصفًا" +userPagePinTip: "لعرض ملاحظة هنا اختر \"دبسها على الصفحة الشخصية\" من قائمة تلك الملاحظة." +notSpecifiedMentionWarning: "في الملاحظة ذكر لمستخدمين لن يستلموها." +info: "عن" +userInfo: "معلومات المستخدم" +unknown: "مجهول" +onlineStatus: "الحالة" +hideOnlineStatus: "اخف الحالة" +hideOnlineStatusDescription: "قد يؤدي جعل اخفاء حالتك إلى تعطيل أداء بعض الميزات ، مثل البحث." +online: "متصل" +active: "نشط" +offline: "غير متصل" +notRecommended: "غير مستحسن" +botProtection: "الحماية من الحسابات الآلية" +instanceBlocking: "المثيلات المحجوبة" +selectAccount: "اختر حسابًا" +switchAccount: "تغيير الحساب" +enabled: "مفعّل" +disabled: "معطّل" +quickAction: "الإجراءات السّريعة" +user: "المستخدمون" +administration: "إدارة " +accounts: "الحسابات" +switch: "بدّل" +noMaintainerInformationWarning: "لم تُضبط معلومات المدير" +noBotProtectionWarning: "لم تضبط الحماية من الحسابات الآلية" +configure: "اضبط" +postToGallery: "انشر في المعرض" +gallery: "المعرض" +recentPosts: "المشاركات الحديثة" +popularPosts: "المشاركات المتداولة" +shareWithNote: "شاركه في ملاحظة" +ads: "الإعلانات" +expiration: "ينتهي استطلاع الرأي في" +memo: "تذكير" +priority: "الأولوية" +high: "عالية" +middle: "متوسط" +low: "منخفضة" +emailNotConfiguredWarning: "لم تعيّن بريدًا إلكترونيًا" +ratio: "النسبة" +previewNoteText: "اعرض معاينة" +customCss: "CSS مخصصة" +customCssWarn: "استخدم هذه الإعداد فقط إن كان لك علم بماهيّته. إدخال قيمة غير مناسبة سيسسب ضررًا للعميل." +global: "الشامل" +squareAvatars: "اعرض شكل الصور الرمزية كمربعات" +sent: "أرسل" +received: "اُستلم" +searchResult: "نتائج البحث" +hashtags: "الوسوم" +troubleshooting: "استكشاف الأخطاء وإصلاحها" +useBlurEffect: "استخدم تأثير الطمس في الواجهة" +learnMore: "راجع المزيد" +iceshrimpUpdated: "حُدث ميسكي!" +whatIsNew: "اعرض التغييرات" +translate: "ترجم" +translatedFrom: "تُرجم من {x}" +accountDeletionInProgress: "حذف الحساب جارٍ" +usernameInfo: "الاسم الذي يميزك عن بافي مستخدمي هذا الخادم، يمكنك استخدام الحروف اللاتينية (a~z, A~Z) والأرقام (0~9) والشرطة السفلية (_). لا يمكنك تغييره بعد تسجيله." +keepCw: "أبقِ على تحذيرات المحتوى" +lastCommunication: "آخر تواصل" +resolved: "عولج" +unresolved: "لم يعالج" +breakFollow: "إلغاء الاشتراك" +itsOn: "مفعّل" +itsOff: "معطّل" +emailRequiredForSignup: "عنوان البريد الإلكتروني إلزامي للتسجيل" +unread: "غير مقروءة" +filter: "رشّح" +controlPanel: "لوحة التحكم" +manageAccounts: "إدارة الحسابات" +makeReactionsPublic: "اجعل سجل التفاعلات علنيًا" +makeReactionsPublicDescription: "هذا سيجعل قائمة تفاعلاتك مرئية للعلن." +classic: "تقليدي" +muteThread: "اكتم النقاش" +unmuteThread: "ارفع الكتم عن النقاش" +ffVisibility: "مرئية المتابِعين/المتابَعين" +ffVisibilityDescription: "يسمح لك بتحديد من يمكنهم رؤية متابِعيك ومتابَعيك." +deleteAccountConfirm: "سيحذف حسابك نهائيًا، أتريد المتابعة؟" +incorrectPassword: "كلمة السر خاطئة." +voteConfirm: "متيقِّن من تصويتك لـ {choice}؟" +hide: "إخفاء" +leaveGroup: "مغادرة الفريق" +leaveGroupConfirm: "متيقن من مغادرة \"{name}\"؟" +clickToFinishEmailVerification: "انقر [{ok}] لاستيثاق بريدك الإلكتروني." +overridedDeviceKind: "نوع الجهاز" +smartphone: "هاتف ذكي" +tablet: "جهاز لوحي" +auto: "تلقائي" +themeColor: "لون السمة" +size: "الحجم" +numberOfColumn: "عدد الأعمدة" +searchByGoogle: "غوغل" +mutePeriod: "مدة الكتم" +indefinitely: "أبدًا" +tenMinutes: "10 دقائق" +oneHour: "ساعة" +oneDay: "يوم" +oneWeek: "أسبوع" +failedToFetchAccountInformation: "تعذر جلب معلومات الحساب" +file: "الملفات" +reverse: "اقلب" +colored: "ملوّن" +label: "التسمية" +localOnly: "المحلي فقط" +account: "الحسابات" +_emailUnavailable: + used: "هذا البريد الإلكتروني مستخدم" + format: "صيغة البريد الإلكتروني غير صالحة" + mx: "خادم البريد الإلكتروني غير صالح" + smtp: "خادم البريد الإلكتروتي لا يستجيب" +_ffVisibility: + public: "علني" + followers: "مرئية لمتابِعيك فقط" + private: "خاص" +_signup: + almostThere: "كدت تنتهي" + emailAddressInfo: "رجاءً أدخل بريدك الإلكتروني." + emailSent: "أرسلت رسالة تأكيد إلى بريدك الإلكتروني ({email})، أنقر على الرابط الموجود فيها لإكمال التسجيل." +_accountDelete: + accountDelete: "احذف الحساب" + mayTakeTime: "نظرًا لأن حذف الحساب يحتاج موارد كثيرة فقد يستغرق وقتًا طويلاً ليكتمل وذلك بناءً على كمية المحتوى الموجود في الحساب وعدد الملفات المرفوعة." + sendEmail: "عند إنتهاء الحذف سترسل رسالة إلى البريد الإلكتروني المرتبط بهذا الحساب." + requestAccountDelete: "أرسل طلبًا لحذف الحساب" + started: "بدأت عملية الحذف." + inProgress: "عملية الحذف جارية" +_ad: + back: "رجوع" + reduceFrequencyOfThisAd: "قلل عرض هذا الإعلان" +_forgotPassword: + enterEmail: "أدخل البريد الإلكتروني المرتبط بحسابك لكي يرسل إليك رابط لإعادة تعيين كلمة المرور." + ifNoEmail: "إذا لم تربط حسابك ببريد إلكتروني سيتوجب عليك التواصل مع مدير الموقع." + contactAdmin: "هذا المثيل لا يدعم استخدام البريد الإلكتروني، إن أردت إعادة تعيين كلمة المرور تواصل مع المدير." +_gallery: + my: "معرضي" + liked: "المشاركات المُعجب بها" + like: "أعجبني" + unlike: "أزل الإعجاب" +_email: + _follow: + title: "يتابعك" + _receiveFollowRequest: + title: "استلمت طلب متابعة" +_plugin: + install: "ثبّت إضافات" + installWarn: "رجاءً لا تثبت إضافات غير موثوقة." + manage: "إدارة الإضافات" +_registry: + scope: "الحيّز" + key: "مفتاح" + keys: "المفاتيح" + domain: "النّطاق" + createKey: "أنشئ مفتاحًا" +_aboutIceshrimp: + about: "ميسكي هو برمجية مفتوحة المصدر يطورها syuilo منذ 2014." + contributors: "المساهمون الرئيسيون" + allContributors: "كل المساهمين" + source: "الشفرة المصدرية" + translation: "ترجم ميسكي" + donate: "تبرع لميسكي" + morePatrons: "نحن نقدر الدعم الذي قدمه العديد من الأشخاص الذين لم نذكرهم. شكرًا لكم 🥰" + patrons: "الداعمون" +_nsfw: + respect: "اخف الوسائط ذات المحتوى الحساس" + ignore: "اعرض الوسائط ذات المحتوى الحساس" + force: "اخف كل الوسائط" +_mfm: + cheatSheet: "مرجع ملخص عن MFM" + intro: "MFM هي لغة ترميزية مخصصة يمكن استخدامها في عدّة أماكن في ميسكي. يمكنك مراجعة كل تعابيرها مع كيفية استخدامها هنا." + mention: "أشر الى" + mentionDescription: "يمكنك الإشارة لمستخدم معيّن من خلال كتابة @ متبوعة باسم مستخدم." + hashtag: "الوسوم" + hashtagDescription: "يمكنك تعيين وسم من خلال كتابة # متبوعة بالنص المطلوب." + url: "الرابط" + urlDescription: "يمكن عرض الروابط" + link: "رابط" + bold: "عريض" + boldDescription: "جعل الحروف أثخن لإبرازها." + small: "صغير" + smallDescription: "يعرض المحتوى صغيرًا ورفيعًا." + center: "وسط" + centerDescription: "يمركز المحتوى في الوَسَط." + quote: "اقتبس" + quoteDescription: "يعرض المحتوى كاقتباس" + emoji: "إيموجي مخصص" + emojiDescription: "إحاطة اسم الإيموجي بنقطتي تفسير سيستبدله بصورة الإيموجي." + search: "البحث" + searchDescription: "يعرض نصًا في صندوق البحث" + flip: "اقلب" + flipDescription: "يقلب المحتوى عموديًا أو أفقيًا" + jelly: "تأثير (هلام)" + jellyDescription: "يمنح المحتوى حركة هلامية." + tada: "تأثير (تادا)" + tadaDescription: "يمنح للمحتوى تأثير تادا" + jump: "تأثير (قفز)" + jumpDescription: "يمنح للمحتوى حركة قفز." + bounce: "تأثير (ارتداد)" + bounceDescription: "يمنح للمحتوى حركة ارتدادية" + shake: "تأثير (اهتزاز)" + shakeDescription: "يمنح المحتوى حركة اهتزازية." + spin: "تأثير (دوران)" + spinDescription: "يمنح المحتوى حركة دورانية." + x2: "كبير" + x2Description: "يُكبر المحتوى" + x3: "كبير جداً" + x3Description: "يُضخم المحتوى" + x4: "هائل" + x4Description: "يُضخم المحتوى أكثر مما سبق." + blur: "طمس" + blurDescription: "يطمس المحتوى، لكن بالتمرير فوقه سيظهر بوضوح." + font: "الخط" + fontDescription: "الخط المستخدم لعرض المحتوى." + rainbow: "قوس قزح" + rainbowDescription: "اجعل المحتوى يظهر بألوان الطيف" + rotate: "تدوير" + rotateDescription: "يُدير المحتوى بزاوية معيّنة." +_instanceTicker: + none: "لا تظهره بتاتًا" + remote: "أظهر للمستخدمين البِعاد" + always: "أظهره دائمًا" +_serverDisconnectedBehavior: + reload: "إعادة تحميل تلقائية" + dialog: "أظهر مربع حوار التحذيرات" +_channel: + create: "أنشئ قناة" + edit: "عدّل قناة" + setBanner: "عيّن اللافتة" + removeBanner: "أزل اللافتة" + featured: "المتداوَلة" + owned: "قنواتي" + following: "متابَع" + usersCount: "{n} منتسب" + notesCount: "{n} ملاحظة" +_menuDisplay: + sideFull: "جانبي" + top: "الأعلى" + hide: "إخفاء" +_wordMute: + muteWords: "الكلمات المحظورة" + muteWordsDescription: "افصل بينهم بمسافة لاستخدام معامل \"و\" أو بسطر لاستخدام معامل \"أو\"." + muteWordsDescription2: "احصر الكلمات المفتاحية بين بين شرطتين مائلتين لاستخدامها كتعابير نمطية" + softDescription: "اخف الملاحظات التي تستوف الشروط من الخيط الزمني." + hardDescription: "اخف الملاحظات التي تستوف الشروط من الخيط الزمني.بالإضافة إلى أن هذه الملاحظات ستبقى مخفية حتى وإن تغيرت الشروط." + soft: "لينة" + hard: "قاسية" + mutedNotes: "الملاحظات المكتومة" +_instanceMute: + instanceMuteDescription: "هذه سيحجب كل ملاحظات الخوادم المحجوبة ومشاركاتها والردود على تلك الملاحظات حتى وإن كانت من خادم غير محجوب." + instanceMuteDescription2: "مدخلة لكل سطر" + title: "يخفي ملاحظات الخوادم المسرودة." + heading: "قائمة الخوادم المحجوبة" +_theme: + explore: "استكشف قوالب المظهر" + install: "تنصيب قالب" + manage: "إدارة القوالب" + code: "شيفرة القالب" + description: "الوصف" + installed: "تم تنصيب {name}" + installedThemes: "السمات المثبتة" + builtinThemes: "السمات المدمجة" + alreadyInstalled: "هذه السمة مثبتة سلفًا" + invalid: "تنسيق السمة غير صالح" + make: "إنشاء قالب" + addConstant: "أضف ثابتًا" + constant: "ثابت" + defaultValue: "القيمة الافتراضية" + color: "اللون" + key: "مفتاح" + func: "دوال" + funcKind: "نوع الدالة" + argument: "معامل" + alpha: "الشفافية" + inputConstantName: "أدخل اسمًا للثابت" + deleteConstantConfirm: "أمتأكد من حذف الثابت {const}؟" + keys: + accent: "طابع لوني" + bg: "الخلفية" + fg: "النص" + indicator: "المؤشر" + panel: "اللوحة" + shadow: "الظل" + navBg: "خلفية الشريط الجانبي" + navFg: "نص الشريط الجانبي" + navHoverFg: "نص الشريط الجانبي (عند التمرير فوقه)" + link: "رابط" + hashtag: "وسم" + mention: "أشر الى" + renote: "أعد النشر" + divider: "فاصل" + scrollbarHandle: "مقبض شريط التمرير" + scrollbarHandleHover: "مقبض شريط التمرير (عند التمرير فوقه)" + infoWarnBg: "خلفية التحذير" + infoWarnFg: "نص التحذير" + toastBg: "خلفية الإشعارات" + toastFg: "نص الإشعارات" + buttonBg: "خلفية الأزرار" + buttonHoverBg: "خلفية الأزرار (عند التمرير فوقها)" + inputBorder: "حواف حقل الإدخال" + listItemHoverBg: "خلفية عناصر القائمة (عند التمرير فوقها)" + driveFolderBg: "خلفية مجلد قرص التخزين" + messageBg: "خلفية المحادثة" +_sfx: + note: "الملاحظات" + noteMy: "ملاحظتي" + notification: "الإشعارات" + chat: "المحادثة" + chatBg: "المحادثة (الخلفية)" + antenna: "الهوائيات" + channel: "إشعارات القنات" +_ago: + future: "المستقبَل" + justNow: "اللحظة" + secondsAgo: "منذ {n} ثوانٍ" + minutesAgo: "منذ {n} دقائق {n2} ثوانٍ" + hoursAgo: "منذ {n} ساعة {n2} دقائق" + daysAgo: "منذ {n} أيام {n2} ساعة" + weeksAgo: "منذ {n} أسابيع {n2} أيام" + monthsAgo: "منذ {n} أشهر {n2} أسابيع" + yearsAgo: "منذ {n} سنوات {n2} أشهر" +_time: + second: "ثا" + minute: "د" + hour: "سا" + day: "ي" +_tutorial: + title: "How to use Iceshrimp" + step1_1: "Welcome!" + step1_2: "Let's get you set up. You'll be up and running in no time!" + step2_1: "First, please fill out your profile." + step2_2: "Providing some information about who you are will make it easier for others to tell if they want to see your notes or follow you." + step3_1: "Now time to follow some people!" + step3_2: "Your home and social timelines are based off of who you follow, so try following a couple accounts to get started.\nClick the plus circle on the top right of a profile to follow them." + step4_1: "Let's get you out there." + step4_2: "For your first post, some people like to made a {introduction} post or a simple \"Hello world!\"" + step5_1: "Timelines, timelines everywhere!" + step5_2: "Your instance has {timelines} different timelines enabled." + step5_3: "The Home {icon} timeline is where you can see posts from your followers." + step5_4: "The Local {icon} timeline is where you can see posts from everyone else on this instance." + step5_5: "The Recommended {icon} timeline is where you can see posts from instances the admins recommend." + step5_6: "The Social {icon} timeline is where you can see posts from friends of your followers." + step5_7: "The Global {icon} timeline is where you can see posts from every other connected instance." + step6_1: "So, what is this place?" + step6_2: "Well, you didn't just join Iceshrimp. You joined a portal to the Fediverse, an interconnected network of thousands of servers, called \"instances\"." + step6_3: "Each server works in different ways, and not all servers run Iceshrimp. This one does though! It's a bit complicated, but you'll get the hang of it in no time." + step6_4: "Now go, explore, and have fun!" +_2fa: + alreadyRegistered: "سجلت سلفًا جهازًا للاستيثاق بعاملين." + registerTOTP: "سجّل جهازًا جديدًا" + registerSecurityKey: "تسجيل مفتاح أمان جديد" + step1: "أولًا ثبّت تطبيق استيثاق على جهازك (مثل {a} و{b})." + step2: "امسح رمز الاستجابة السريعة الموجد على الشاشة." + step3: "أدخل الرمز الموجود في تطبيقك لإكمال التثبيت." + step4: "من هذه اللحظة أثناء ولوجك سيُطلب منك الرمز." +_permissions: + "read:account": "اعرض معلومات حسابك" + "write:account": "تعديل معلومات حسابك" + "read:blocks": "اعرض قائمة المستخدمين المحجوبين" + "write:blocks": "عدّل قائمة المستخدمين المحجوبين" + "read:drive": "تصفح قرص التخزين" + "write:drive": "احذف أو عدّل محتويات قرص التخزين" + "read:favorites": "اعرض المفضلة" + "write:favorites": "عدّل المفضلة" + "read:following": "اعرض معلومات متابَعيك" + "write:following": "تابع أو ألغ متابعة حسابات" + "read:messaging": "اعرض المحادثات" + "write:messaging": "اكتب أو احذف رسائل محادثة" + "read:mutes": "اعرض قائمة المستخدمين المكتومين" + "write:mutes": "عدّل قائمة المستخدمين المكتومين" + "write:notes": "أنشئ أو احذف ملاحظات" + "read:notifications": "اظهر الإشعارات" + "write:notifications": "إدارة الإشعارات" + "read:reactions": "اعرض تفاعلاتك" + "write:reactions": "عدّل تفاعلاتك" + "write:votes": "صوّت" + "read:pages": "اعرض صفحاتك" + "write:pages": "عدّل أو احذف صفحاتك" + "read:page-likes": "يعرض ما أعجبك من ملاحظات في صفحات" + "read:user-groups": "اعرض فِرق المستخدمين" + "write:user-groups": "عدّل أو احذف فِرق المستخدمين" + "read:channels": "طالع قنواتك" + "write:channels": "عدّل القنوات" + "read:gallery": "اعرض المعرض" + "write:gallery": "عدّل المعرض" + "read:gallery-likes": "يعرض ما أعجبك من مشاركات المعرض" +_auth: + shareAccess: "أتريد التفويض لـ \"{name}\" بالوصول لحسابك؟" + shareAccessAsk: "هل تخول لهذا التطبيق الوصول لحسابك؟" + permissionAsk: "يطلب التطبيق الأذون التالية" + pleaseGoBack: "رجاءً عد للتطبيق" + callback: "العودة للتطبيق" + denied: "رُفض الوصول" +_antennaSources: + all: "كل الملاحظات" + homeTimeline: "ملاحظات المستخدمين المتابَعين" + users: "ملاحظات مستخدمين محددين" +_weekday: + sunday: "الأحد" + monday: "الإثنين" + tuesday: "الثلاثاء" + wednesday: "الأربعاء" + thursday: "الخميس" + friday: "الجمعة" + saturday: "السبت" +_widgets: + memo: "ملاحظة لاصقة" + notifications: "الإشعارات" + timeline: "الخيط الزمني" + calendar: "التقويم" + trends: "المتداوَلة" + clock: "الساعة" + rss: "تدفق RSS" + activity: "النشاط" + photos: "الصور" + digitalClock: "ساعة رقمية" + federation: "الفديرالية" + postForm: "أنشئ ملاحظة" + slideshow: "عرض الشرائح" + button: "زر" + onlineUsers: "المتّصلون" + jobQueue: "قائمة الانتظار" + serverMetric: "إحصائيات الخادم" +_cw: + hide: "إخفاء" + show: "عرض المزيد" + chars: "{count} أحرف" + files: "{count} ملفات" +_poll: + noOnlyOneChoice: "تحتاج إلى خيارَين على الأقل" + choiceN: "الخيار {n}" + noMore: "لا يمكنك إضافة خيارات أخرى" + canMultipleVote: "السماح بالإجابات المتعددة" + expiration: "ينتهي استطلاع الرأي في" + infinite: "أبدًا" + at: "تاريخ الإنتهاء" + after: "ينتهي بعد…" + deadlineDate: "تاريخ الانتهاء" + deadlineTime: "سا" + duration: "المدة" + votesCount: "{n} أصوات" + totalVotes: "المجموع {n} أصوات" + vote: "قم بالتصويت" + showResult: "اعرض النتائج" + voted: "تم التصويت" + closed: "انتهى" + remainingDays: "{d} أيام و {h} ساعات متبقية" + remainingHours: "{h} ساعات و {m} دقائق متبقية" + remainingMinutes: "{m} دقائق و {s} ثوانٍ متبقية" + remainingSeconds: "{s} ثوانٍ متبقية" +_visibility: + public: "علني" + publicDescription: "ستكون ملاحظتك مرئية لكل المستخدمين" + home: "الرئيسي" + homeDescription: "انشر في الخيط الزمني الرئيسي فقط" + followers: "المتابِعون" + followersDescription: "اجعلها مرئية لمتابِعيك فقط" + specified: "مباشرة" + specifiedDescription: "اجعلها مرئية لمستخدمين محددين" + localOnly: "المحلي فقط" + localOnlyDescription: "ليس مرئيًا للمستخدمين البِعاد" +_postForm: + replyPlaceholder: "رد على هذه الملاحظة…" + quotePlaceholder: "اقتبس هذه الملاحظة…" + channelPlaceholder: "انشر في قناة..." + _placeholders: + a: "ما الذي تنوي فعله؟" + b: "ماذا يحدث حولك ؟" + c: "ما الذي تفكر فيه؟" + d: "ما الذي تريد قوله؟" + e: "أكتب..." + f: "بانتظارك لتكتب..." +_profile: + name: "الإسم" + username: "اسم المستخدم" + description: "السيرة" + youCanIncludeHashtags: "يمكنك أيضًا إضافة وسوم إلى سيرتك التعريفية." + metadata: "معلومات إضافية" + metadataEdit: "عدّل المعلومات الإضافية" + metadataLabel: "التسمية" + metadataContent: "المحتوى" + changeAvatar: "غيّر الصورة الرمزية" + changeBanner: "غيّر اللافتة" +_exportOrImport: + allNotes: "كل الملاحظات" + followingList: "المتابَعون" + muteList: "المستخدمون المكتومون" + blockingList: "المستخدمون المحجوبون" + userLists: "القوائم" + excludeMutingUsers: "استثن الحسابات المكتومة" + excludeInactiveUsers: "استثن المستخدمين الخاملين" +_charts: + federation: "الفديرالية" + apRequest: "الطلبات" + usersIncDec: "تباين عدد المستخدمين" + usersTotal: "مجموع عدد المستخدمين والمستخدمات" + activeUsers: "المستخدمون النشطون" + notesIncDec: "تباين عدد الملاحظات" + localNotesIncDec: "تباين عدد الملاحظات المحلية" + remoteNotesIncDec: "تباين عدد الملاحظات البعيدة" + notesTotal: "إجمالي الملاحظات" + filesIncDec: "تباين عدد الملفات" + filesTotal: "العدد الإجمالي للملفات" +_instanceCharts: + requests: "الطلبات" + users: "تباين عدد المستخدمين" + usersTotal: "تباين عدد المستخدمين" + notes: "تباين عدد الملاحظات" + notesTotal: "تباين عدد الملاحظات" + ff: "تباين عدد حسابات المتابَعة/المتابِعة" + ffTotal: "تباين عدد حسابات المتابَعة/المتابِعة" + files: "تباين عدد الملفات" + filesTotal: "تباين عدد الملفات" +_timelines: + home: "الرئيسي" + local: "المحلي" + social: "الاجتماعي" + global: "الشامل" +_pages: + newPage: "أنشئ صفحة جديدة" + editPage: "عدّل الصفحة" + readPage: "نُشّط عرض المصدر" + created: "نجح إنشاء الصفحة" + updated: "نجح تعديل الصفحة" + deleted: "نجح حذف الصفحة" + pageSetting: "إعدادات الصفحة" + nameAlreadyExists: "رابط الصفحة موجود مسبقًا" + invalidNameTitle: "رابط الصفحة ليس صالحًا" + invalidNameText: "تأكد أن عنوان الصفحة ليس فارغًا" + editThisPage: "عدّل هذه الصفحة" + viewSource: "اظهر المصدر" + viewPage: "اعرض صفحاتك" + like: "أعجبني" + unlike: "أزل الإعجاب" + my: "صفحاتي" + liked: "الصفحات المُعجب بها" + featured: "الأكثر شعبية" + contents: "المحتوى" + variables: "متغيّرات" + title: "العنوان" + url: "رابط الصفحة" + summary: "ملخص الصفحة" + alignCenter: "توسيط العناصر" + hideTitleWhenPinned: "اخف عنوان الصفحة عند تدبيسها في ملف الشخصي" + font: "الخط" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + eyeCatchingImageSet: "عيّن صورة مصغّرة" + eyeCatchingImageRemove: "احذف صورة مصغّرة" + chooseBlock: "إضافة كتلة" + selectType: "اختر النوع" + enterVariableName: "أدخل اسم المتغيّر" + variableNameIsAlreadyUsed: "هذا الاسم محجوز" + contentBlocks: "المحتوى" + inputBlocks: "مُدخل" + specialBlocks: "خاص" + blocks: + text: "نص" + textarea: "حقل نصي" + section: "قسم" + image: "الصور" + button: "زرّ" + _if: + variable: "متغيّر" + post: "أنشئ ملاحظة" + _post: + text: "المحتوى" + textInput: "مُدخل نصي" + _textInput: + name: "اسم المتغير" + text: "العنوان" + default: "القيمة الافتراضية" + textareaInput: "مدخل نصي متعدد الأسطر" + _textareaInput: + name: "اسم المتغير" + text: "العنوان" + default: "القيمة الافتراضية" + numberInput: "مُدخل رقمي" + _numberInput: + name: "اسم المتغير" + text: "العنوان" + default: "القيمة الافتراضية" + _canvas: + width: "العُرض" + height: "الإرتفاع" + note: "ملاحظة مضمّنة" + _note: + id: "معرّف الملاحظة" + idDescription: "كبديل يمكنك إدخال رابك الملاحظة هنا" + detailed: "عرض مفصّل" + switch: "بدّل" + _switch: + name: "اسم المتغير" + text: "العنوان" + default: "القيمة الافتراضية" + counter: "العداد" + _counter: + name: "اسم المتغير" + text: "العنوان" + inc: "زِد" + _button: + text: "العنوان" + colored: "ملوّن" + action: "الإجراء عند ضغط الزّر" + _action: + dialog: "أظهر مربع حوار" + _dialog: + content: "المحتوى" + resetRandom: "صفِّر البذرة" + pushEvent: "أرسل حدثًا" + _pushEvent: + event: "اسم الحدث" + message: "إظهار رسالة عند التفعيل" + variable: "أرسل المتغيّر" + no-variable: "لا شيء" + _callAiScript: + functionName: "اسم الدالة" + radioButton: "الخيار " + _radioButton: + name: "اسم المتغير" + title: "العنوان" + values: "قائمة الخيارات (كل خيار في سطر لوحده)" + default: "القيمة الافتراضية" + script: + categories: + logical: "عمليّة منطقيّة" + operation: "حساب" + comparison: "مقارنة" + random: "عشوائي" + value: "القيم" + fn: "دوال" + text: "إجراءات على النصوص" + convert: "تحويل" + list: "القوائم" + blocks: + text: "نص" + textList: "قائمة نصية" + _textList: + info: "اجعل كل مدخل في سطر لوحده" + strLen: "طول النص" + _strLen: + arg1: "نص" + strPick: "استخرج محرفًا" + _strPick: + arg1: "نص" + arg2: "موضع المحرف" + strReplace: "استبدال النّص" + _strReplace: + arg1: "نص" + arg2: "استُبدِل بـ" + arg3: "استُبدِل بـ" + strReverse: "اقلب النص" + _strReverse: + arg1: "نص" + _join: + arg1: "القوائم" + arg2: "فاصل" + add: "إضافة" + _add: + arg1: "أ" + arg2: "ب" + subtract: "اطرح" + _subtract: + arg1: "أ" + arg2: "ب" + multiply: "اضرب" + _multiply: + arg1: "أ" + arg2: "ب" + divide: "اقسم" + _divide: + arg1: "أ" + arg2: "ب" + mod: "الباقي" + _mod: + arg1: "أ" + arg2: "ب" + round: "تقريب عدد عشري" + _round: + arg1: "رقم" + eq: "أ و ب متساويان" + _eq: + arg1: "أ" + arg2: "ب" + notEq: "أ و ب مختلفان" + _notEq: + arg1: "أ" + arg2: "ب" + and: "أ و ب" + _and: + arg1: "أ" + arg2: "ب" + or: "أ أو ب" + _or: + arg1: "أ" + arg2: "ب" + lt: "أ أصغر من ب" + _lt: + arg1: "أ" + arg2: "ب" + gt: "أ أكبر من ب" + _gt: + arg1: "أ" + arg2: "ب" + ltEq: "أ أصغر من أو يساوي ب" + _ltEq: + arg1: "أ" + arg2: "ب" + gtEq: "أ أكبر من أو يساوي ب" + _gtEq: + arg1: "أ" + arg2: "ب" + if: "فرع" + random: "عشوائي" + rannum: "رقم عشوائي" + _rannum: + arg1: "أدنى قيمة" + arg2: "أقصى قيمة" + randomPick: "اختر عشوائيًا من القائمة" + _randomPick: + arg1: "القوائم" + dailyRandom: "عشوائي (يتغير مرة يوميًا لكل مستخدم)" + dailyRannum: "رقم عشوائي (يتغير مرة يوميًا لكل مستخدم)" + _dailyRannum: + arg1: "أدنى قيمة" + arg2: "أقصى قيمة" + dailyRandomPick: "اختيار عشوائي من قائمة (يتغير مرة يوميًا لكل مستخدم)" + _dailyRandomPick: + arg1: "القوائم" + seedRandom: "عشوائي (عبر بذرة)" + _seedRandom: + arg1: "البذرة" + seedRannum: "رقم عشوائي (عبر بذرة)" + _seedRannum: + arg1: "البذرة" + arg2: "أدنى قيمة" + arg3: "أقصى قيمة" + seedRandomPick: "اختيار عشوائي من القائمة (عبر بذرة)" + _seedRandomPick: + arg1: "البذرة" + arg2: "القوائم" + DRPWPM: "اختيار عشوائي من قائمة الاحتمالات (تتغير مرة يوميًا لكل مستخدم)" + _DRPWPM: + arg1: "قائمة نصية" + pick: "اختر من القائمة" + _pick: + arg1: "القوائم" + arg2: "الموضع" + listLen: "طول القائمة" + _listLen: + arg1: "القوائم" + number: "رقم" + stringToNumber: "حوّل نصًا إلى رقم" + _stringToNumber: + arg1: "نص" + numberToString: "حوّل رقمًا إلى نص" + _numberToString: + arg1: "رقم" + _splitStrByLine: + arg1: "نص" + ref: "متغيّر" + aiScriptVar: "متغيّر AiScript" + fn: "دالة" + _fn: + slots: "خانات" + arg1: "المُخرج" + for: "حلقة تكرار" + _for: + arg1: "عدد مرات التكرار" + arg2: "الإجراء" + typeError: "الخانة {slot} تقبل \"{expect}\" لكن القيمة المعطاة هي \"{actual}\"!" + thereIsEmptySlot: "الخانة {slot} فارغة!" + types: + string: "نص" + number: "رقم" + array: "القوائم" + stringArray: "قائمة نصية" + emptySlot: "خانة فارغة" + enviromentVariables: "متغيرات البيئة" + pageVariables: "متغيرات الصفحة" + argVariables: "خانة إدخال" +_relayStatus: + requesting: "مُعلّق" + accepted: "مقبول" + rejected: "مرفوض" +_notification: + fileUploaded: "نجح رفع الملف" + youGotMention: "{name} أشار إليك" + youGotReply: "ردّ عليك {name}" + youGotQuote: "اقتبس منك {name}" + youRenoted: "إعادت نشر من {name}" + youGotPoll: "شارك {name} في استطلاع الرأي" + youGotMessagingMessageFromUser: "لقد تلقيت رسالة مِن {name}" + youGotMessagingMessageFromGroup: "لقد أرسِلَت رسالة إلى الفريق {name}" + youWereFollowed: "يتابعك" + youReceivedFollowRequest: "تلقيتَ طلب متابعة" + yourFollowRequestAccepted: "قُبل طلب المتابعة" + youWereInvitedToGroup: "دُعيت إلى فريقٍ" + pollEnded: "ظهرت نتائج الاستطلاع" + _types: + all: "الكل" + follow: "متابِعون جدد" + mention: "الإشارات" + reply: "الردود" + renote: "أعد النشر" + quote: "الاقتباسات" + reaction: "التفاعلات" + pollVote: "مصوِت شارك في الاستطلاع" + receiveFollowRequest: "طلبات المتابعة المتلقاة" + followRequestAccepted: "طلبات المتابعة المقبولة" + groupInvited: "دعوات الفريق" + app: "إشعارات التطبيقات المرتبطة" + _actions: + followBack: "تابعك بالمثل" + reply: "رد" + renote: "أعد النشر" +_deck: + alwaysShowMainColumn: "أظهر العمود الرئيسي دائمًا" + columnAlign: "حاذِ الأعمدة" + addColumn: "أضف عمودًا" + swapLeft: "حرّك لليسار" + swapRight: "حرّك لليمين" + swapUp: "حرّك لأعلى" + swapDown: "حرّك لأسفل" + profile: "الملف الشخصي" + _columns: + main: "الرئيسي" + widgets: "الودجات" + notifications: "الإشعارات" + tl: "الخيط الزمني" + antenna: "الهوائيات" + list: "القوائم" + mentions: "الإشارات" + direct: "مباشرة" diff --git a/locales/bn-BD.yml b/locales/bn-BD.yml new file mode 100644 index 0000000..ff94cf7 --- /dev/null +++ b/locales/bn-BD.yml @@ -0,0 +1,1660 @@ +--- +_lang_: "বাংলা" +headlineIceshrimp: "নোট ব্যাবহার করে সংযুক্ত নেটওয়ার্ক" +introIceshrimp: "স্বাগতম! মিসকি একটি ওপেন সোর্স, ডিসেন্ট্রালাইজড মাইক্রোব্লগিং পরিষেবা। \n\"নোট\" তৈরির মাধ্যমে যা ঘটছে তা সবার সাথে শেয়ার করুন 📡\n\"রিঅ্যাকশন\" গুলির মাধ্যমে যেকোনো নোট সম্পর্কে আপনার অনুভূতি ব্যাক্ত করতে পারেন 👍\nএকটি নতুন দুনিয়া ঘুরে দেখুন 🚀\n" +monthAndDay: "{day}/{month}" +search: "খুঁজুন" +notifications: "বিজ্ঞপ্তি" +username: "ব্যবহারকারীর নাম" +password: "পাসওয়ার্ড" +forgotPassword: "পাসওয়ার্ড ভুলে গেছেন" +fetchingAsApObject: "ফেডিভার্স থেকে খবর আনা হচ্ছে" +ok: "ঠিক" +gotIt: "বুঝেছি" +cancel: "বাতিল" +enterUsername: "ইউজারনেম লিখুন" +renotedBy: "{user} রিনোট করেছেন" +noNotes: "কোন নোট নেই" +noNotifications: "কোনো বিজ্ঞপ্তি নেই" +instance: "ইন্সট্যান্স" +settings: "সেটিংস" +basicSettings: "সাধারণ সেটিংস" +otherSettings: "অন্যান্য সেটিংস" +openInWindow: "নতুন উইন্ডোতে খুলা" +profile: "প্রোফাইল" +timeline: "টাইমলাইন" +noAccountDescription: "এই ব্যাবহারকারীর কোন বায়ো নেই" +login: "প্রবেশ করুন" +loggingIn: "প্রবেশ করা হচ্ছে..." +logout: "লগআউট" +signup: "নিবন্ধন করুন" +uploading: "আপলোড হচ্ছ …" +save: "সংরক্ষণ" +users: "ব্যবহারকারীগণ" +addUser: "ব্যবহারকারী যোগ করুন" +favorite: "পছন্দ" +favorites: "পছন্দগুলি" +unfavorite: "পছন্দ না" +favorited: "পছন্দ করা হয়েছে" +alreadyFavorited: "ইতিমধ্যে পছন্দ করা হয়েছে" +cantFavorite: "পছন্দ করা যায়নি" +pin: "পিন করা" +unpin: "পিন সরান" +copyContent: "বিষয়বস্তু কপি করুন" +copyLink: "লিঙ্ক কপি করুন" +delete: "মুছুন" +deleteAndEdit: "মুছুন এবং সম্পাদনা করুন" +deleteAndEditConfirm: "আপনি কি এই নোটটি মুছে এটি সম্পাদনা করার বিষয়ে নিশ্চিত? আপনি এটির সমস্ত রিঅ্যাকশন, রিনোট এবং জবাব হারাবেন।" +addToList: "লিস্ট এ যোগ করুন" +sendMessage: "একটি বার্তা পাঠান" +copyUsername: "ব্যবহারকারীর নাম কপি করুন" +searchUser: "ব্যবহারকারী খুঁজুন..." +reply: "জবাব" +loadMore: "আরও দেখুন" +showMore: "আরও দেখুন" +showLess: "বন্ধ" +youGotNewFollower: "আপনাকে অনুসরণ করছে" +receiveFollowRequest: "অনুসরণ করার জন্য অনুরোধ পাওয়া গেছে" +followRequestAccepted: "অনুসরণ করার অনুরোধ গৃহীত হয়েছে" +mention: "উল্লেখ" +mentions: "উল্লেখসমূহ" +directNotes: "ডাইরেক্ট নোটগুলি" +importAndExport: "আমদানি এবং রপ্তানি" +import: "আমদানি করুণ" +export: "রপ্তানি" +files: "ফাইলগুলি" +download: "ডাউনলোড" +driveFileDeleteConfirm: "আপনি কি নিশ্চিত যে আপনি \"{name}\" ডিলিট করতে চান? যে সকল নোটের সাথে এই ফাইলটি সংযুক্ত সেগুলোও ডিলিট করা হবে।" +unfollowConfirm: "{name} কে আনফলোও করার ব্যাপারে নিশ্চিত?" +exportRequested: "আপনার তথ্যসমূহ রপ্তানির জন্য অনুরোধ করেছেন। এতে কিছু সময় লাগতে পারে। রপ্তানি সম্পন্ন হলে তা আপনার ড্রাইভে সংরক্ষিত হবে।" +importRequested: "আপনার তথ্যসমূহ আমদানির জন্য অনুরোধ করেছেন। এতে কিছু সময় লাগতে পারে। " +lists: "লিস্ট" +noLists: "কোন লিস্ট নেই" +note: "নোট" +notes: "নোটগুলি" +following: "অনুসরণ করা হচ্ছে" +followers: "অনুসরণকারী" +followsYou: "আপনাকে অনুসরণ করে" +createList: "লিস্ট তৈরি করুন" +manageLists: "লিস্ট ব্যাবস্থাপনা" +error: "সমস্যা" +somethingHappened: "একটি ত্রুটি হয়েছে" +retry: "আবার চেষ্টা করুন" +pageLoadError: "পেজ লোড করা যায়নি" +pageLoadErrorDescription: "এটি সাধারনত নেটওয়ার্কের সমস্যার বা ব্রাউজার ক্যাশের কারণে ঘটে থাকে। ব্রাউজার এর ক্যাশ পরিষ্কার করুন এবং একটু পর আবার চেষ্টা করুন। " +serverIsDead: "এই সার্ভার বর্তমানে সাড়া দিচ্ছে না। একটু পরে আবার চেষ্টা করুন।" +youShouldUpgradeClient: "এই পেজ দেখার জন্য আপনার ব্রাউজার রিফ্রেশ করে ক্লায়েন্ট আপডেট করুন। " +enterListName: "লিস্টের নাম লিখুন" +privacy: "গোপনীয়তা" +makeFollowManuallyApprove: "অনুসরণ করার অনুরোধগুলি গৃহীত হওয়ার জন্য আপনার অনুমতি লাগবে" +defaultNoteVisibility: "ডিফল্ট দৃশ্যমান্যতা" +follow: "অনুসরণ" +followRequest: "অনুসরণ করার অনুরোধ" +followRequests: "অনুসরণ করার অনুরোধসমূহ" +unfollow: "অনুসরণ বাতিল" +followRequestPending: "অনুসরণ করার অনুরোধ বিচারাধীন" +enterEmoji: "ইমোজি প্রবেশ করান" +renote: "রিনোট" +unrenote: "রিনোট সরান " +renoted: "রিনোট করা হয়েছে" +cantRenote: "এই নোটটি রিনোট করা যাবে না।" +cantReRenote: "রিনোটকে রিনোট করা যাবে না।" +quote: "উদ্ধৃতি" +pinnedNote: "পিন করা নোট" +pinned: "পিন করা" +you: "আপনি" +clickToShow: "দেখার জন্য ক্লিক করুন" +sensitive: "সংবেদনশীল বিষয়বস্তু" +add: "যুক্ত করুন" +reaction: "প্রতিক্রিয়া" +reactionSetting: "রিঅ্যাকশন পিকারে যেসকল প্রতিক্রিয়া দেখানো হবে" +reactionSettingDescription2: "পুনরায় সাজাতে টেনে আনুন, মুছতে ক্লিক করুন, যোগ করতে + টিপুন।" +rememberNoteVisibility: "নোটের দৃশ্যমান্যতার সেটিংস মনে রাখুন" +attachCancel: "অ্যাটাচমেন্ট সরান " +markAsSensitive: "সংবেদনশীল হিসাবে চিহ্নিত করুন" +unmarkAsSensitive: "সংবেদনশীল চিহ্ন সরান" +enterFileName: "ফাইলের নাম লিখুন" +mute: "মিউট" +unmute: "আনমিউট" +block: "ব্লক" +unblock: "ব্লক সরান" +suspend: "স্থগিত করা" +unsuspend: "অস্থগিত করা" +blockConfirm: "ব্লক করতে চান?" +unblockConfirm: "ব্লক সরাতে চান?" +suspendConfirm: "স্থগিত করতে চান?" +unsuspendConfirm: "অস্থগিত করতে চান?" +selectList: "লিস্ট নির্বাচন করুন" +selectAntenna: "অ্যান্টেনা নির্বাচন করুন" +selectWidget: "উইজেট নির্বাচন করুন" +editWidgets: "উইজেট সম্পাদনা করুন" +editWidgetsExit: "সম্পাদনা শেষ করুন" +customEmojis: "স্বনির্ধারিত ইমোজিগুলি" +emoji: "ইমোজি" +emojis: "ইমোজিগুলি" +emojiName: "ইমোজির নাম" +emojiUrl: "ইমোজির URL" +addEmoji: "ইমোজি যুক্ত করুন" +settingGuide: "সুপারিশকৃত সেটিংস" +cacheRemoteFiles: "রিমোট ফাইলসমুহ ক্যাশ করুন" +cacheRemoteFilesDescription: "যখন এই অপশনটি বন্ধ থাকে তখন রিমোট ফাইল সমূহ সরাসরি রিমোট ইন্সট্যান্স থেকে লোড করা হয়। এই অপশনটি বন্ধ করলে স্টোরেজ এর ব্যাবহার কমবে তবে থাম্বনেইল তৈরি না করার কারণে নেটওয়ার্ক ব্যান্ডউইথ বেশী লাগবে। " +flagAsBot: "বট হিসাবে চিহ্নিত করুন" +flagAsBotDescription: "এই অ্যাকাউন্টটি যদি একটি প্রোগ্রাম দ্বারা পরিচালিত হয়, তাহলে এই অপশনটি চালু করুন। ইন্টারঅ্যাকশান চেইনিং রোধ করতে, মিস্কির সিস্টেম পরিচালনাকে বট-বান্ধব করতে এবং অন্যান্য ডেভেলপারদের সাহায্য করতে আপনার বট এ এই অপশনটি চালু করুন৷" +flagAsCat: "বিড়াল হিসাবে চিহ্নিত করুন" +flagAsCatDescription: "অ্যাকাউন্টটিকে বিড়াল হিসাবে চিহ্নিত করার জন্য অপশনটি চালু করুন।" +flagShowTimelineReplies: "টাইমলাইনে নোটগুলির রিপ্লাই দেখান" +flagShowTimelineRepliesDescription: "চালু করলে, টাইমলাইন ব্যবহারকারীর নোট ছাড়াও ব্যবহারকারীর অন্যান্য নোটের জবাবগুলো দেখায়।" +autoAcceptFollowed: "আপনি যেসব অ্যাকাউন্ট অনুসরণ করেন, স্বয়ংক্রিয়ভাবে তাদের অনুসরণের অনুরধ স্বীকার করুন" +addAccount: "অ্যাকাউন্ট যোগ করুন" +loginFailed: "প্রবেশ করা যায়নি" +showOnRemote: "রিমোট সার্ভারে দেখুন" +general: "সাধারণ" +wallpaper: "ওয়ালপেপার" +setWallpaper: "ওয়ালপেপার সেট করুন" +removeWallpaper: "ওয়ালপেপার সরান" +searchWith: "খুঁজুন: {q}" +youHaveNoLists: "আপনার কোন লিস্ট নেই" +followConfirm: "{name} কে ফলোও করার ব্যাপারে নিশ্চিত?" +proxyAccount: "প্রক্সি অ্যাকাউন্ট" +proxyAccountDescription: "একটি প্রক্সি অ্যাকাউন্ট এমন একটি অ্যাকাউন্ট যা নির্দিষ্ট শর্তে ব্যবহারকারীদের জন্য রিমোট অনুসরণকারী হিসাবে কাজ করে। উদাহরণস্বরূপ, যখন একজন ব্যবহারকারী একটি রিমোট ব্যবহারকারীকে তালিকাভুক্ত করে, তখন ক্রিয়াকলাপের দৃষ্টান্তে বিতরণ করা হবে না যদি না কেউ তালিকাভুক্ত ব্যবহারকারীকে অনুসরণ করে, তাই প্রক্সি অ্যাকাউন্ট দ্বারা তাকে অনুসরণ করা হবে।" +host: "হোস্ট" +selectUser: "ব্যবহারকারী নির্বাচন করুন" +recipient: "প্রতি" +annotation: "মন্তব্য" +federation: "ফেডিভার্স" +instances: "ইন্সট্যান্স" +registeredAt: "যোগ দিয়েছেন" +latestRequestSentAt: "শেষ রিকুয়েস্ট পাঠানো হয়েছে" +latestRequestReceivedAt: "শেষ রিকুয়েস্ট গৃহীত হয়েছে" +latestStatus: "সর্বশেষ অবস্থা" +storageUsage: "স্টোরেজের ব্যাবহার" +charts: "চার্ট" +perHour: "ঘন্টা প্রতি" +perDay: "দৈনিক" +stopActivityDelivery: "অ্যাক্টিভিটি পাঠানো বন্ধ করুন" +blockThisInstance: "ইন্সট্যান্স ব্লক করুন" +operations: "ক্রিয়াকলাপ" +software: "সফটওয়্যার" +version: "সংস্করণ" +metadata: "মেটাডাটা" +monitor: "মনিটর" +jobQueue: "জব কিউ" +cpuAndMemory: "সিপিউ এবং মেমরি" +network: "নেটওয়ার্ক" +disk: "ডিস্ক" +instanceInfo: "ইন্সট্যান্সের তথ্য" +statistics: "পরিসংখ্যান" +clearQueue: "কিউ পরিষ্কার করুন" +clearQueueConfirmTitle: "আপনি কি কিউ পরিষ্কার করার ব্যাপারে নিশ্চিত?" +clearQueueConfirmText: "বিতরণ না করা নোট আর বিতরণ করা হবে না। সাধারণত আপনার এটি করার দরকার নেই।" +clearCachedFiles: "ক্যাশ পরিষ্কার করুন" +clearCachedFilesConfirm: "আপনি কি ক্যাশ পরিষ্কার করার ব্যাপারে নিশ্চিত?" +blockedInstances: "ব্লককৃত ইন্সট্যান্সসমুহ" +blockedInstancesDescription: "আপনি যে ইন্সট্যান্সগুলি ব্লক করতে চান তার হোস্টনেমগুলি প্রত্যেকটি আলাদা লাইনে লিখুন। ব্লককৃত ইন্সট্যান্সগুলি এই ইন্সট্যান্সের সাথে যোগাযোগ করতে পারবেনা৷" +muteAndBlock: "মিউট এবং ব্লকগুলি" +mutedUsers: "নিঃশব্দকৃত ব্যবহারকারী" +blockedUsers: "যাদের ব্লক করা হয়েছে" +noUsers: "কোন ব্যাবহারকারী নেই" +editProfile: "প্রোফাইল সম্পাদনা করুন" +noteDeleteConfirm: "আপনি কি নোট ডিলিট করার ব্যাপারে নিশ্চিত?" +pinLimitExceeded: "আপনি আর কোন নোট পিন করতে পারবেন না" +intro: "Iceshrimp এর ইন্সটলেশন সম্পন্ন হয়েছে!দয়া করে অ্যাডমিন ইউজার তৈরি করুন।" +done: "সম্পন্ন" +processing: "প্রক্রিয়াধীন" +preview: "পূর্বরূপ দেখুন" +default: "পূর্বনির্ধারিত" +noCustomEmojis: "কোন ইমোজি নাই" +noJobs: "কোন জব নাই" +federating: "ফেডারেট করা হচ্ছে" +blocked: "ব্লক করা হয়েছে" +suspended: "স্থগিত করা হয়েছে" +all: "সবগুলো" +subscribing: "সদস্যতা নেয়া হচ্ছে" +publishing: "প্রকাশ করা হচ্ছে" +notResponding: "সাড়া নেই" +instanceFollowing: "ইন্সট্যান্স অনুসরণ করা হচ্ছে" +instanceFollowers: "ইন্সট্যান্স অনুসরণকারী" +instanceUsers: "ইন্সট্যান্স ব্যাবহারকারী" +changePassword: "পাসওয়ার্ড পরিবর্তন করুন" +security: "নিরাপত্তা" +retypedNotMatch: "ইনপুট মেলে না।" +currentPassword: "বর্তমান পাসওয়ার্ড" +newPassword: "নতুন পাসওয়ার্ড" +newPasswordRetype: "নতুন পাসওয়ার্ড (পুনরায় লিখুন)" +attachFile: "ফাইল সংযুক্ত করুন" +more: "আরও!" +featured: "হাইলাইট" +usernameOrUserId: "ব্যাবহারকারীর নাম বা ব্যাবহারকারী ID" +noSuchUser: "কোন ব্যবহারকারী খুঁজে পাওয়া যায়নি" +lookup: "খুঁজে দেখো" +announcements: "ঘোষণা" +imageUrl: "চিত্রের URL" +remove: "মুছুন" +removed: "সরানো হয়েছে" +removeAreYouSure: "আপনি কি \"{x}\" সরানোর ব্যাপারে নিশ্চিত?" +deleteAreYouSure: "আপনি কি \"{x}\" সরানোর ব্যাপারে নিশ্চিত?" +resetAreYouSure: "রিসেট করার ব্যাপারে নিশ্চিত?" +saved: "সংরক্ষিত হয়েছে" +messaging: "চ্যাট" +upload: "আপলোড" +keepOriginalUploading: "আসল ছবি রাখুন" +keepOriginalUploadingDescription: "ছবিটি আপলোড করার সময় আসল সংস্করণটি রাখুন। অপশনটি বন্ধ থাকলে, আপলোডের সময় ওয়েব প্রকাশনার জন্য ছবি ব্রাউজারে তৈরি করা হবে।" +fromDrive: "ড্রাইভ হতে" +fromUrl: "URL হতে" +uploadFromUrl: "URL হতে আপলোড" +uploadFromUrlDescription: "যে ফাইলটি আপলোড করতে চান, সেটির URL" +uploadFromUrlRequested: "আপলোড অনুরোধ করা হয়েছে" +uploadFromUrlMayTakeTime: "URL হতে আপলোড হতে কিছু সময় লাগতে পারে।" +explore: "ঘুরে দেখুন" +messageRead: "পড়া" +noMoreHistory: "আর কোন ইতিহাস নেই" +startMessaging: "চ্যাট শুরু করুন" +nUsersRead: "{n} জন পড়েছেন" +agreeTo: "{0} এর প্রতি আমি সম্মত" +tos: "পরিষেবার শর্তাদি" +start: "শুরু করুন" +home: "মূল পাতা" +remoteUserCaution: "এই ব্যাবহারকারী রিমোট ইন্সট্যান্সের, নিম্নক্ত তথ্য অসম্পূর্ণ হতে পারে।" +activity: "কার্যকলাপ" +images: "ছবি" +birthday: "জন্মদিন" +yearsOld: "{age} বছর" +registeredDate: "যোগদানের তারিখ" +location: "অবস্থান" +theme: "থিম" +themeForLightMode: "লাইট মোডের থিম" +themeForDarkMode: "ডার্ক মোডের থিম" +light: "আলোকিত" +dark: "অন্ধকার" +lightThemes: "আলোকিত থিম" +darkThemes: "অন্ধকার থিম" +syncDeviceDarkMode: "ডিভাইসের সেটিং অনুযায়ী ডার্ক মোড সেট করুন" +drive: "ড্রাইভ" +fileName: "ফাইলের নাম" +selectFile: "ফাইল নির্বাচন করুন" +selectFiles: "ফাইল নির্বাচন করুন" +selectFolder: "ফোল্ডার নির্বাচন করুন" +selectFolders: "ফোল্ডার নির্বাচন করুন" +renameFile: "ফাইল পুনঃনামকরন" +folderName: "ফোল্ডারের নাম" +createFolder: "ফোল্ডার তৈরি করুন" +renameFolder: "ফোল্ডার পুনঃনামকরন" +deleteFolder: "ফোল্ডার মুছুন" +addFile: "ফাইল যোগ করুন" +emptyDrive: "আপনার ড্রাইভ খালি" +emptyFolder: "এই ফোল্ডার খালি" +unableToDelete: "মুছে ফেলা যায়নি" +inputNewFileName: "ফাইলের নতুন নাম লিখুন" +inputNewDescription: "নতুন ক্যাপশন লিখুন" +inputNewFolderName: "ফোল্ডারের নতুন নাম লিখুন" +circularReferenceFolder: "গন্তব্য ফোল্ডারটি আপনি যে ফোল্ডারটি সরাতে চান তার একটি সাবফোল্ডার।" +hasChildFilesOrFolders: "এই ফোল্ডারটি খালি না হওয়ায় ডিলিট করা যায়নি।" +copyUrl: "URL কপি করুন" +rename: "পুনঃনামকরণ" +avatar: "প্রোফাইল ছবি" +banner: "ব্যানার" +nsfw: "সংবেদনশীল বিষয়বস্তু" +whenServerDisconnected: "সার্ভারের সাথে সংযোগ বিচ্ছিন্ন হয়ে গেলে" +disconnectedFromServer: "সার্ভার থেকে সংযোগ বিচ্ছিন্ন হয়েছে" +reload: "আবার লোড করুন" +doNothing: "কিছু করবেন না" +reloadConfirm: "আপনি কি রিলোড করতে চান?" +watch: "বিজ্ঞপ্তি পান" +unwatch: "বিজ্ঞপ্তি পাওয়া বন্ধ করুন " +accept: "অনুমোদন" +reject: "প্রত্যাখ্যান" +normal: "স্বাভাবিক" +instanceName: "ইন্সট্যান্সের নাম" +instanceDescription: "ইন্সট্যান্সের বর্ণনা" +maintainerName: "মেইনটেইনার" +maintainerEmail: "মেইনটেইনারের ইমেইল" +tosUrl: "ব্যবহারের শর্তাবলীর URL" +thisYear: "বছর" +thisMonth: "মাস" +today: "আজ" +dayX: "{day}" +monthX: "{month}" +yearX: "{year}" +pages: "পৃষ্ঠা" +integration: "ইন্টিগ্রেশন" +connectService: "সংযুক্ত করুন" +disconnectService: "সংযোগ বিচ্ছিন্ন করুন" +enableLocalTimeline: "স্থানীয় টাইমলাইন চালু করুন" +enableGlobalTimeline: "গ্লোবাল টাইমলাইন চালু করুন" +disablingTimelinesInfo: "আপনি এই টাইমলাইনগুলি বন্ধ করলেও প্রশাসক এবং মডারেটররা এই টাইমলাইনগুলি ব্যাবহার করতে পারবে" +registration: "নিবন্ধন" +enableRegistration: "নতুন ব্যাবহারকারী নিবন্ধন চালু করুন" +invite: "আমন্ত্রণ" +driveCapacityPerLocalAccount: "প্রত্যেক স্থানীয় ব্যাবহারকারীর জন্য ড্রাইভের জায়গা" +driveCapacityPerRemoteAccount: "প্রত্যেক রিমোট ব্যাবহারকারীর জন্য ড্রাইভের জায়গা" +inMb: "মেগাবাইটে লিখুন" +iconUrl: "আইকনের URL (ফ্যাভিকন, ইত্যাদি)" +bannerUrl: "ব্যানার ছবির URL" +backgroundImageUrl: "পটভূমির চিত্রের URL" +basicInfo: "আপনার ব্যক্তিগত তথ্য" +pinnedUsers: "পিন করা ব্যাবহারকারীগণ" +pinnedUsersDescription: "আপনি যেসব ব্যবহারকারীদের \"ঘুরে দেখুন\" পৃষ্ঠায় পিন করতে চান তাদের বর্ণনা করুন, প্রত্যেকের বর্ণনা আলাদা লাইনে লিখুন" +pinnedPages: "পিন করা পৃষ্ঠাসুমহ" +pinnedPagesDescription: "আপনি যেসকল পৃষ্ঠাসমূহকে \"ঘুরে দেখুন\" পৃষ্ঠায় পিন করতে চান তাদের বর্ণনা করুন, প্রত্যেকের বর্ণনা আলাদা লাইনে লিখুন" +pinnedClipId: "পিনকৃত ক্লিপের ID" +pinnedNotes: "পিন করা নোট" +hcaptcha: "hCaptcha" +enableHcaptcha: "hCaptcha চালু করুন" +hcaptchaSiteKey: "সাইট কী" +hcaptchaSecretKey: "সিক্রেট কী" +recaptcha: "reCAPTCHA" +enableRecaptcha: "reCAPTCHA চালু করুন" +recaptchaSiteKey: "সাইট কী" +recaptchaSecretKey: "সিক্রেট কী" +avoidMultiCaptchaConfirm: "একাধিক Captcha ব্যবহার করলে তারা পরস্পরের কাজে বাধা দিতে পারে। আপনি কি অন্যান্য Captcha নিষ্ক্রিয় করতে চান? আপনি 'বাতিল' ক্লিক করার মাধ্যমে একাধিক Captcha চালু রাখতে পারেন।" +antennas: "অ্যান্টেনা" +manageAntennas: "অ্যান্টেনা ব্যবস্থাপনা" +name: "নাম" +antennaSource: "অ্যান্টেনার উৎস" +antennaKeywords: "যেসব কীওয়ার্ড দেখা হবে" +antennaExcludeKeywords: "যেসব কীওয়ার্ড দেখা হবে না" +antennaKeywordsDescription: "স্পেস দিয়ে আলাদা করলে AND শর্ত তৈরি হবে এবং আলাদা লাইনে লিখলে OR শর্ত তৈরি হবে।" +notifyAntenna: "নতুন নোট সম্পর্কে অবহিত করুন" +withFileAntenna: "শুধুমাত্র ফাইলযুক্ত নোট" +enableServiceworker: "ServiceWorker চালু করুন" +antennaUsersDescription: "প্রত্যেক লাইনে একজন ব্যবহারকারীর নাম লিখুন" +caseSensitive: "ছোট হাতের এবং বড় হাতের অক্ষর নির্দিষ্ট করুন" +withReplies: "জবাবসমুহ যুক্ত করুন" +connectedTo: "আপনি নিম্নলিখিত অ্যাকাউন্টের সাথে সংযুক্ত" +notesAndReplies: "নোটসমূহ এবং জবাবগুলি" +withFiles: "ফাইলগুলি যুক্ত করুন" +silence: "নীরব" +silenceConfirm: "আপনি কি এই ব্যাবহারকারীকের নীরব করতে চান?" +unsilence: "সরব" +unsilenceConfirm: "আপনি কি এই ব্যাবহারকারীকের সরব করতে চান?" +popularUsers: "জনপ্রিয় ব্যবহারকারীগন" +recentlyUpdatedUsers: "সম্প্রতি পোস্ট করা ব্যবহারকারীগন" +recentlyRegisteredUsers: "নতুন যোগ দেওয়া ব্যবহারকারীগন" +recentlyDiscoveredUsers: "নতুন খুঁজে পাওয়া ব্যবহারকারীগন" +exploreUsersCount: "{count} জন ব্যাবহারকারী" +exploreFediverse: "Fediverse ঘুরে দেখুন" +popularTags: "জনপ্রিয় ট্যাগগুলি" +userList: "লিস্ট" +about: "আপনার সম্পর্কে" +aboutIceshrimp: "Iceshrimp সম্পর্কে" +administrator: "প্রশাসক" +token: "টোকেন" +twoStepAuthentication: "২-ধাপ প্রমাণীকরণ" +moderator: "মডারেটর" +nUsersMentioned: "{n} জনকে উল্লেখ করা হয়েছে" +securityKey: "সিকিউরিটি কী" +securityKeyName: "কী'র নাম" +registerSecurityKey: "সিকিউরিটি কী নিবন্ধন করুন" +lastUsed: "শেষ ব্যাবহার করা হয়েছে" +unregister: "নিবন্ধনমুক্ত হন" +passwordLessLogin: "পাসওয়ার্ড-বিহীন লগইন সেট আপ করুন" +resetPassword: "পাসওয়ার্ড রিসেট করুন" +newPasswordIs: "নতুন পাসওয়ার্ড হচ্ছে \"{password}\"" +reduceUiAnimation: "UI অ্যানিমেশন কমান" +share: "শেয়ার" +notFound: "পাওয়া যায়নি" +notFoundDescription: "এই URL-এর সাথে সম্পর্কিত কোনো পৃষ্ঠা নেই।" +uploadFolder: "আপলোডের জন্য ডিফল্ট ফোল্ডার" +cacheClear: "ক্যাশ পরিষ্কার করুন" +markAsReadAllNotifications: "সমস্ত বিজ্ঞপ্তিগুলি পঠিত হিসাবে চিহ্নিত করুন" +markAsReadAllUnreadNotes: "সমস্ত নোটগুলি পঠিত হিসাবে চিহ্নিত করুন" +markAsReadAllTalkMessages: "সমস্ত মেসেজ পঠিত হিসাবে চিহ্নিত করুন" +help: "সহায়তা" +inputMessageHere: "এখানে মেসেজ লিখুন" +close: "বন্ধ" +group: "গ্রুপ" +groups: "গ্রুপসমূহ" +createGroup: "গ্রুপ তৈরী করুন" +ownedGroups: "আপনার গ্রুপগুলি" +joinedGroups: "যেসব গ্রুপে আপনি আছেন" +invites: "আমন্ত্রণ" +groupName: "গ্রুপের নাম" +members: "সদস্যবৃন্দ" +transfer: "হস্তান্তর" +messagingWithUser: "প্রাইভেট চ্যাট" +messagingWithGroup: "গ্রুপ চ্যাট" +title: "শিরোনাম" +text: "পাঠ্য" +enable: "সক্রিয়" +next: "পরবর্তী" +retype: "পুনঃ প্রবেশ" +noteOf: "{user} এর নোট" +inviteToGroup: "গ্রুপে আমন্ত্রণ জানান" +quoteAttached: "উদ্ধৃত" +quoteQuestion: "উদ্ধৃতি হিসাবে সংযুক্ত করবেন?" +noMessagesYet: "কোন মেসেজ নেই" +newMessageExists: "নতুন মেসেজ পেয়েছেন" +onlyOneFileCanBeAttached: "আপনি মেসেজের সাথে সর্বোচ্চ একটি ফাইল যুক্ত করতে পারবেন" +signinRequired: "দয়া করে লগ ইন করুন" +invitations: "আমন্ত্রণ" +invitationCode: "ইনভাইট কোড" +checking: "পরীক্ষা করা হচ্ছে..." +available: "উপলব্ধ" +unavailable: "অনুপলব্ধ" +usernameInvalidFormat: "আপনি কেবলমাত্র a-z, A-Z, 0-9, _ ব্যবহার করতে পারেন" +tooShort: "খুব ছোট" +tooLong: "খুব বড়" +weakPassword: "দুর্বল পাসওয়ার্ড" +normalPassword: "সাধারণ পাসওয়ার্ড" +strongPassword: "শক্তিশালী পাসওয়ার্ড" +passwordMatched: "মিলেছে" +passwordNotMatched: "মিলেনি" +signinWith: "{x} এর সাহায্যে সাইন ইন করুন" +signinFailed: "লগ ইন করা যায়নি। আপনার ব্যবহারকারীর নাম এবং পাসওয়ার্ড চেক করুন." +tapSecurityKey: "সিকিউরিটি কী স্পর্শ করুন" +or: "অথবা" +language: "ভাষা" +uiLanguage: "UI এর ভাষা" +groupInvited: "আপনি একটি গ্রুপে আমন্ত্রিত হয়েছেন" +aboutX: "{x} সম্পর্কে" +useOsNativeEmojis: "অপারেটিং সিস্টেমের নেটিভ ইমোজি ব্যবহার করুন" +disableDrawer: "ড্রয়ার মেনু প্রদর্শন করবেন না" +youHaveNoGroups: "আপনার কোন গ্রুপ নেই " +joinOrCreateGroup: "একটি বিদ্যমান গ্রুপের আমন্ত্রণ পান বা একটি নতুন গ্রুপ তৈরি করুন৷" +noHistory: "কোনো ইতিহাস নেই" +signinHistory: "প্রবেশ করার ইতিহাস" +disableAnimatedMfm: "অ্যানিমেটেড MFM অক্ষম করুন" +doing: "প্রক্রিয়া করছে..." +category: "বিভাগ" +tags: "ট‍্যাগসমূহ" +docSource: "ডকুমেন্টের উৎস" +createAccount: "অ্যাকাউন্ট তৈরি করুন" +existingAccount: "বিদ্যমান অ্যাকাউন্ট" +regenerate: "আবারও তৈরি করুন" +fontSize: "ফন্টের আকার" +noFollowRequests: "আপনার কোন ফলোও রিকুয়েস্ট নেই" +openImageInNewTab: "ছবি নতুন ট্যাবে খুলুন" +dashboard: "ড্যাশবোর্ড" +local: "স্থানীয়" +remote: "রিমোট" +total: "মোট" +weekOverWeekChanges: "গত সপ্তাহে" +dayOverDayChanges: "গতকাল" +appearance: "অবয়ব" +clientSettings: "ক্লায়েন্ট সেটিংস" +accountSettings: "অ্যাকাউন্ট সেটিংস" +promotion: "প্রমোশন" +promote: "প্রচার করুন" +numberOfDays: "দিনের সংখ্যা" +hideThisNote: "নোটটি লুকান" +showFeaturedNotesInTimeline: "টাইমলাইনে সুপারিশকৃত নোটগুলি দেখান" +objectStorage: "অবজেক্ট স্টোরেজ" +useObjectStorage: "অবজেক্ট স্টোরেজ ব্যাবহার করুন" +objectStorageBaseUrl: "Base URL" +objectStorageBaseUrlDesc: "রেফারেন্স হিসাবে ব্যবহৃত URL। আপনি একটি CDN বা প্রক্সি ব্যবহার করলে URL, S3: 'https://.s3.amazonaws.com', GCS: 'https://storage.googleapis.com/'।" +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "ব্যবহার করা পরিষেবার bucket এর নাম লিখুন। " +objectStoragePrefix: "Prefix" +objectStoragePrefixDesc: "ফাইলসমূহ এই prefix যুক্ত ফোল্ডারের অধীনে সংরক্ষণ করা হবে।" +objectStorageEndpoint: "এন্ডপয়েন্ট" +objectStorageEndpointDesc: "S3 এর জন্য ফাঁকা রাখুন, অন্যথায় প্রতিটি পরিষেবার এন্ডপয়েন্ট নির্দিষ্ট করুন। ''বা': ' হিসেবে লিখুন।" +objectStorageRegion: "Region" +objectStorageRegionDesc: "'xx-east-1'-এর মতো একটি region নির্দিষ্ট করুন। যদি আপনার পরিষেবাতে region এর ধারণা না থাকে, তাহলে এটি খালি বা 'us-east-1' হওয়া উচিত।" +objectStorageUseSSL: "SSL ব্যাবহার করুন" +objectStorageUseSSLDesc: "API কানেকশনগুলির জন্য যদি https ব্যাবহার না করেন, তাহলে এই অপশনটি বন্ধ করুন" +objectStorageUseProxy: "Proxy ব্যাবহার করুন" +objectStorageUseProxyDesc: "আপনি API সংযোগের জন্য proxy ব্যবহার না করলে, এটি বন্ধ করুন।" +objectStorageSetPublicRead: "আপলোডের উপর ''public-read' সেট করুন" +serverLogs: "সার্ভার লগ" +deleteAll: "সব মুছুন" +showFixedPostForm: "টাইমলাইনের শীর্ষে পোস্ট করার ফর্মটি দেখান" +newNoteRecived: "নতুন নোট আছে" +sounds: "শব্দ" +listen: "শুনুন" +none: "কিছুই না" +showInPage: "পেজে দেখান" +popout: "পপ-আউট" +volume: "আওয়াজের মাত্রা" +masterVolume: "মাস্টার আওয়াজের মাত্রা" +details: "আরও জানুন" +chooseEmoji: "ইমোজি নির্বাচন করুন" +unableToProcess: "কাজটি সম্পন্ন করা যায়নি" +recentUsed: "সম্প্রতি ব্যবহৃত" +install: "ইন্সটল" +uninstall: "আনইন্সটল" +installedApps: "ইন্সটল করা অ্যাপসমূহ" +nothing: "এখানে কিছুই নাই" +installedDate: "ইন্সটল করার তারিখ" +lastUsedDate: "সর্বশেষ ব্যাবহৃত" +state: "অবস্থা" +sort: "সাজান" +ascendingOrder: "ঊর্ধ্বক্রমে" +descendingOrder: "নিম্নক্রমে" +scratchpad: "স্ক্র্যাচপ্যাড" +scratchpadDescription: "স্ক্র্যাচপ্যাড AiScript-এর জন্য একটি পরীক্ষামূলক পরিবেশ প্রদান করে। আপনি মিস্কির সাথে ইন্টারঅ্যাক্ট করে এমন কোড লিখতে, চালাতে এবং তার ফলাফল দেখতে পারেন।" +output: "আউটপুট" +script: "স্ক্রিপ্ট" +disablePagesScript: "পেজগুলোতে AiScript অক্ষম করুন" +updateRemoteUser: "রিমোট ব্যবহারকারীর তথ্য আপডেট করুন" +deleteAllFiles: "সকল ফাইল ডিলিট করুন" +deleteAllFilesConfirm: "সকল ফাইল ডিলিট করতে চান?" +removeAllFollowing: "সকল অনুসরণ বাতিল করুন" +removeAllFollowingDescription: "{host} এর সকল ব্যাবহারকারীকে আর ফলোও করবেন না । যদি ইন্সত্যান্সটির কোন সমস্যা (যেমনঃ ইন্সত্যান্সটি আর নেই) হয়ে থাকে তবে এটি ব্যাবহার করুন । " +userSuspended: "এই ব্যাবহারকারির অ্যাকাউন্ট স্থগিত করা হয়েছে" +userSilenced: "এই ব্যাবহারকারিকে মিউট করা হয়েছে" +yourAccountSuspendedTitle: "এই অ্যাকাউন্টটি স্থগিত করা হয়েছে" +yourAccountSuspendedDescription: "সার্ভারের ব্যবহারের শর্তাবলী লঙ্ঘনের মতো কারণে এই অ্যাকাউন্টটি স্থগিত করা হয়েছে৷ বিস্তারিত জানার জন্য প্রশাসকের সাথে যোগাযোগ করুন । একটি নতুন অ্যাকাউন্ট তৈরি করবেন না দয়া করে ।" +menu: "মেনু" +divider: "খন্ডক" +addItem: "আইটেম যোগ করুন" +relays: "রিলেগুলি" +addRelay: "রিলে যোগ করুন" +inboxUrl: "inbox এর URL" +addedRelays: "যোগকৃত রিলেগুলি" +serviceworkerInfo: "পুশ বিজ্ঞপ্তির জন্য চালু করা লাগবে।" +deletedNote: "ডিলিট করা নোট" +invisibleNote: "অদৃশ্য নোট" +enableInfiniteScroll: "ইনফিনিট স্ক্রল চালু করুন" +visibility: "দৃশ্যমানতা" +poll: "জরিপ" +useCw: "কন্টেন্ট লুকান" +enablePlayer: "ভিডিও প্লেয়ার খুলুন" +disablePlayer: "ভিডিও প্লেয়ার বন্ধ করুন" +expandTweet: "টুইট বিস্তারিত করুন" +themeEditor: "থিম সম্পাদক" +description: "বর্ণনা" +describeFile: "ক্যাপশন যোগ করুন" +enterFileDescription: "ক্যাপশন যোগ করুন" +author: "লেখক" +leaveConfirm: "কিছু পরিবর্তন সেভ করা হয়নি। আপনি কি চলে যেতে চান?" +manage: "পরিচালনা" +plugins: "প্লাগইনসমূহ" +deck: "ডেক" +undeck: "ডেকমুক্ত করুন" +useBlurEffectForModal: "মোডালের জন্য ব্লার ইফেক্ট ব্যবহার করুন" +useFullReactionPicker: "সম্পূর্ণ বৈশিষ্ট্যযুক্ত রিঅ্যাকশন পিকার ব্যবহার করুন" +width: "প্রস্থ" +height: "উচ্চতা" +large: "বড়" +medium: "মাঝারি" +small: "ছোট" +generateAccessToken: "অ্যাক্সেস টোকেন তৈরি করুন" +permission: "অনুমতি" +enableAll: "সবগুলি সক্রিয় করুন" +disableAll: "সবগুলি নিষ্ক্রিয় করুন" +tokenRequested: "অ্যাকাউন্টে অ্যাক্সেস প্রদান করবেন" +pluginTokenRequestedDescription: "এই প্লাগইনটি এখানে দেওয়া অনুমুতিসমূহ ব্যাবহার করবে" +notificationType: "বিজ্ঞপ্তির ধরন" +edit: "সম্পাদনা" +emailServer: "ইমেইল সার্ভার" +enableEmail: "ইমেইল বিতরণ চালু করুন" +emailConfigInfo: "আপনার ইমেল ঠিকানা নিশ্চিত করতে এবং আপনার পাসওয়ার্ড পুনরায় সেট করতে ব্যবহৃত হয়" +email: "ইমেইল" +emailAddress: "ইমেইল ঠিকানা" +smtpConfig: "SMTP সার্ভার কনফিগারেশন" +smtpHost: "হোস্ট" +smtpPort: "পোর্ট" +smtpUser: "ব্যবহারকারীর নাম" +smtpPass: "পাসওয়ার্ড" +emptyToDisableSmtpAuth: "আপনি ব্যবহারকারীর নাম এবং পাসওয়ার্ড ফাঁকা রেখে SMTP প্রমাণীকরণ নিষ্ক্রিয় করতে পারেন।" +smtpSecure: "SMTP সংযোগের জন্য SSL/TLS ব্যবহার করুন" +smtpSecureInfo: "STARTTLS ব্যবহার করার সময় এটি বন্ধ করুন।" +testEmail: "ইমেল বিতরণ পরীক্ষা করুন" +wordMute: "বিশেষ কোন শব্দকে মিউট করুন" +regexpError: "রেগুলার এক্সপ্রেশন ত্রুটি" +regexpErrorDescription: "{tab} ওয়ার্ড মিউটের {line} লাইনে রেগুলার এক্সপ্রেশনে একটি ত্রুটি ছিল:" +instanceMute: "মিউট করা ইন্সত্যান্সগুলি" +userSaysSomething: "{name} কিছু বলেছে" +makeActive: "সক্রিয় করা" +display: "প্রদর্শন" +copy: "অনুলিপি" +metrics: "মেট্রিক্স" +overview: "সারাংশ" +logs: "লগ" +delayed: "দেরি করুন" +database: "ডেটাবেজ" +channel: "চ্যানেলগুলি" +create: "তৈরি করুন" +notificationSetting: "বিজ্ঞপ্তির সেটিংস" +notificationSettingDesc: "কি ধরনের বিজ্ঞপ্তি পাবেন তা নির্ধারণ করুন" +useGlobalSetting: "গ্লোবাল সেটিংস ব্যাবহার করুন" +useGlobalSettingDesc: "চালু করলে, আপনার অ্যাকাউন্টের বিজ্ঞপ্তি সেটিংস ব্যবহার করা হবে। বন্ধ করলে, এটি পৃথকভাবে সেট করা যেতে পারে।" +other: "অন্যান্য" +regenerateLoginToken: "লগইন টোকেন আবার বানান" +regenerateLoginTokenDescription: "লগ ইন করার জন্য ব্যবহৃত অভ্যন্তরীণ টোকেন পুনরায় তৈরি করে। সাধারণত আপনার এটি করার দরকার নেই। এটি করলে, আপনি সমস্ত ডিভাইসে লগ আউট হয়ে যাবেন৷" +setMultipleBySeparatingWithSpace: "আপনি একটি স্পেস দিয়ে আলাদা করে একাধিক এন্ট্রি দিতে পারেন।" +fileIdOrUrl: "ফাইল ID অথবা URL" +behavior: "আচরণ" +sample: "উদাহরণ" +abuseReports: "অভিযোগ" +reportAbuse: "অভিযোগ" +reportAbuseOf: "{name} এ অভিযোগ করুন" +fillAbuseReportDescription: "রিপোর্টের কারণ বর্ণনা করুন. একটি বিশেষ নোট এর জন্য রিপোর্টটি হয়ে থাকে তবে তার URL টি অন্তর্ভুক্ত করুন। " +abuseReported: "আপনার অভিযোগটি দাখিল করা হয়েছে। আপনাকে ধন্যবাদ।" +reporter: "অভিযোগকারী" +reporteeOrigin: "অভিযোগটির উৎস" +reporterOrigin: "অভিযোগকারীর উৎস" +forwardReport: "রিমোট ইন্সত্যান্সে অভিযোগটি পাঠান" +forwardReportIsAnonymous: "আপনার তথ্য রিমোট ইন্সত্যান্সে পাঠানো হবে না এবং একটি বেনামী সিস্টেম অ্যাকাউন্ট হিসাবে প্রদর্শিত হবে।" +send: "পাঠান" +abuseMarkAsResolved: "অভিযোগটিকে সমাধাকৃত হিসাবে চিহ্নিত করুন" +openInNewTab: "নতুন ট্যাবে খুলুন" +openInSideView: "সাইড ভিউতে খুলুন" +defaultNavigationBehaviour: "ডিফল্ট নেভিগেশন" +editTheseSettingsMayBreakAccount: "এসব সেটিংস সম্পাদনা করলে আপনার অ্যাকাউন্টের ক্ষতি হতে পারে। " +instanceTicker: "ইন্সত্যান্সে নোটের তথ্য" +waitingFor: "{x} এর জন্য অপেক্ষা করা হচ্ছে" +random: "র‍্যান্ডম" +system: "সিস্টেম" +switchUi: "UI পরিবর্তন করুন" +desktop: "ডেস্কটপ" +clip: "ক্লিপ" +createNew: "নতুন" +optional: "প্রয়োজনীয় নয়" +createNewClip: "নতুন ক্লিপ তৈরি করুন" +public: "সর্বজনীন" +i18nInfo: "Iceshrimp স্বেচ্ছাসেবকদের দ্বারা বিভিন্ন ভাষায় অনুবাদ করা হচ্ছে। আপনি {link} এ গিয়ে অনুবাদে সহযোগিতা করতে পারেন।" +manageAccessTokens: "অ্যাক্সেস টোকেন পরিচালনা করুন" +accountInfo: "অ্যাকাউন্টের তথ্য" +notesCount: "নোটের সংখ্যা" +repliesCount: "জবাবের সংখ্যা" +renotesCount: "রিনোটের সংখ্যা" +repliedCount: "জবাব গ্রহন করা হয়েছে" +renotedCount: "রিনোট পেয়েছেন" +followingCount: "যাদেরকে অনুসরণ করেন, তাদের সংখ্যা" +followersCount: "অনুসরণকারীদের সংখ্যা" +sentReactionsCount: "রিঅ্যাকশন পাঠানো হয়েছে" +receivedReactionsCount: "রিঅ্যাকশন পেয়েছেন" +pollVotesCount: "পোল ভোট দিয়েছেন" +pollVotedCount: "পোল ভোট পেয়েছেন" +yes: "হ্যাঁ" +no: "না" +driveFilesCount: "ড্রাইভে ফাইল এর সংখ্যা" +driveUsage: "ড্রাইভ এর ব্যাবহার" +noCrawle: "ক্রলার ইন্ডেক্সিং বন্ধ করুন" +noCrawleDescription: "সার্চ ইঞ্জিনগুলিকে আপনার প্রোফাইল, নোট, পেজ ইত্যাদি ইনডেক্স করতে নিষেধ করুন। " +lockedAccountInfo: "এমনকি আপনি আপনার অনুসরণকারীদের বেছে বেছে অনুমোদন করলেও, যে কেউ আপনার নোটগুলি দেখতে পাবে, যতক্ষণ না আপনি আপনার নোটগুলিকে \"অনুসারীদের জন্য\" হিসাবে সেট না করেন৷" +alwaysMarkSensitive: "সর্বদা স্পর্শকাতর হিসাবে চিহ্নিত করুন" +loadRawImages: "সংযুক্ত ছবির থাম্বনেইলটি দেখানর পরিবর্তে আসল ছবি দেখান" +disableShowingAnimatedImages: "অ্যানিমেটেড চিত্র দেখানো বন্ধ করুন" +verificationEmailSent: "নিশ্চিতকরণ ইমেল পাঠানো হয়েছে। সেটআপ সম্পূর্ণ করতে ইমেল এর লিঙ্ক অনুসরণ করুন।" +notSet: "সেট করা হয়নি" +emailVerified: "ইমেইল নিশ্চিত করা হয়েছে" +noteFavoritesCount: "পছন্দ করা নোটের সংখ্যা" +pageLikesCount: "পেজ লাইক করেছেন" +pageLikedCount: "পেজ লাইক পেয়েছেন" +contact: "পরিচিতি সমূহ" +useSystemFont: "সিস্টেম ফন্ট ব্যাবহার করুন" +clips: "ক্লিপগুলি " +experimentalFeatures: "পরীক্ষামূলক বৈশিষ্ট্যগুলি" +developer: "ডেভেলপার" +makeExplorable: "অ্যাকাউন্ট \"ঘুরে দেখুন\" পৃষ্ঠায় দেখান" +makeExplorableDescription: "আপনি এটি বন্ধ করলে, আপনার অ্যাকাউন্ট \"ঘুরে দেখুন\" পৃষ্ঠায় প্রদর্শিত হবে না।" +showGapBetweenNotesInTimeline: "টাইমলাইন এবং নোটের মাঝে ফাকা জায়গা রাখুন" +duplicate: "প্রতিরূপ" +left: "বাম" +center: "মাঝখান" +wide: "চওড়া" +narrow: "সংকীর্ণ" +reloadToApplySetting: "পৃষ্ঠাটি রিলোড করার পর সেটিংসটি প্রয়োগ করা হবে। আপনি কি এখন রিলোড করতে চান?" +needReloadToApply: "পৃষ্ঠাটি রিলোড করার পর সেটিংসটি প্রয়োগ করা হবে।" +showTitlebar: "টাইটেল বার দেখান" +clearCache: "ক্যাশ পরিষ্কার করুন" +onlineUsersCount: "{n} জন ব্যাবহারকারী অনলাইন" +nUsers: "{n} জন ব্যাবহারকারী" +nNotes: "{n} টি নোট" +sendErrorReports: "ক্রুটি প্রতিবেদন পাঠান" +sendErrorReportsDescription: "চালু থাকলে, বিস্তারিত ত্রুটির তথ্য Iceshrimp-এর সাথে শেয়ার করা হয়। যা সফ্টওয়্যারটির গুণমান উন্নত করতে সাহায্য করে। ত্রুটির তথ্যের মধ্যে রয়েছে OS সংস্করণ, ব্রাউজারের ধরন, কর্মের ইতিহাস ইত্যাদি।" +myTheme: "আমার থিম" +backgroundColor: "পটভূমির রং" +accentColor: "এক্সেন্টের রং" +textColor: "লেখার রং" +saveAs: "এইরূপে সংরক্ষণ করুন" +advanced: "উন্নত" +value: "মান" +createdAt: "তৈরি হয়েছে" +updatedAt: "শেষ হালনাগাদ হয়েছে" +saveConfirm: "পরিবর্তনগুলি সংরক্ষণ করতে চান?" +deleteConfirm: "আসলেই মুছে ফেলতে চান?" +invalidValue: "অগ্রহণযোগ্য মান" +registry: "রেজিস্ট্রি" +closeAccount: "অ্যাকাউন্ট বন্ধ করুন" +currentVersion: "বর্তমান সংস্করণ" +latestVersion: "সর্বশেষ সংস্করণ" +youAreRunningUpToDateClient: "আপনি সবচেয়ে নতুন ক্লায়েন্ট ব্যাবহার করছেন" +newVersionOfClientAvailable: "আপনার ক্লায়েন্টের একটি নতুন ভার্সন চলে এসেছে" +usageAmount: "ব্যাবহার" +capacity: "ধারণক্ষমতা" +inUse: "ব্যবহৃত" +editCode: "কোড সম্পাদনা করুন" +apply: "প্রয়োগ করুন" +receiveAnnouncementFromInstance: "এই ইন্সট্যান্স থেকে বিজ্ঞপ্তি পান" +emailNotification: "ইমেইল বিজ্ঞপ্তি" +publish: "প্রকাশ" +inChannelSearch: "চ্যানেলে খুঁজুন" +useReactionPickerForContextMenu: "রাইট ক্লিকের মাধ্যমে রিঅ্যাকশন পিকার খুলুন" +typingUsers: "{users} লেখছে" +jumpToSpecifiedDate: "একটি নির্দিষ্ট তারিখে যান" +showingPastTimeline: "অতীতের টাইমলাইন দেখানো হচ্ছে" +clear: "পরিষ্কার" +markAllAsRead: "সব পঠিত হিসেবে চিহ্নিত করুন" +goBack: "পিছনে" +unlikeConfirm: "আসলেই লাইক সরিয়ে নিবেন?" +fullView: "ফুল ভিউ" +quitFullView: "ফুল ভিউ বন্ধ করুন" +addDescription: "বর্ণনা যোগ করুন" +userPagePinTip: "আপনি প্রতিটি নোটের জন্য মেনু থেকে \"প্রোফাইলে পিন করুন\" নির্বাচন করে আপনার নোটগুলি এখানে প্রদর্শন করতে পারেন।" +notSpecifiedMentionWarning: "প্রাপক ছাড়াও এই নোটে অন্য ব্যাবহারকারীদের উল্লেখ্য করা হয়েছে" +info: "আপনার সম্পর্কে" +userInfo: "ব্যবহারকারীর তথ্য" +unknown: "অজানা" +onlineStatus: "অনলাইন স্ট্যাটাস" +hideOnlineStatus: "অনলাইন স্ট্যাটাস লুকান" +hideOnlineStatusDescription: "অনলাইন স্ট্যাটাস লুকিয়ে রাখলে সার্চের মতো কিছু ফাংশনের সুবিধা কমে যায়।" +online: "অনলাইন" +active: "অ্যাকটিভ" +offline: "অফলাইন" +notRecommended: "সুপারিশ করা হয় না" +botProtection: "বট প্রোটেকশন" +instanceBlocking: "ব্লক করা ইন্সট্যান্সগুলি" +selectAccount: "অ্যাকাউন্ট নির্বাচন" +switchAccount: "অ্যাকাউন্ট পাল্টান" +enabled: "চালু" +disabled: "বন্ধ" +quickAction: "কুইক অ্যাকশন" +user: "ব্যবহারকারীগণ" +administration: "পরিচালনা" +accounts: "অ্যাকাউন্টগুলি" +switch: "পাল্টান" +noMaintainerInformationWarning: "প্রশাসকের তথ্য সেট করা হয়নি।" +noBotProtectionWarning: "বট প্রোটেকশন সেট করা হয়নি।" +configure: "কনফিগার করুন" +postToGallery: "গ্যালারী পোস্ট তৈরি করুন" +gallery: "গ্যালারী" +recentPosts: "নতুন পোস্ট" +popularPosts: "জনপ্রিয় পোস্ট" +shareWithNote: "নোটের মাধ্যমে শেয়ার করুন" +ads: "বিজ্ঞাপন" +expiration: "নির্দিষ্ট সময়সীমা" +memo: "মেমো" +priority: "অগ্রাধিকার" +high: "উচ্চ" +middle: "মাঝারি" +low: "নিম্ন" +emailNotConfiguredWarning: "ইমেইল অ্যাড্রেস সেট করা হয়নি।" +ratio: "অনুপাত" +previewNoteText: "প্রিভিউ দেখান" +customCss: "কাস্টম CSS" +customCssWarn: "এই ব্যাপারে অভিজ্ঞতা না থাকলে এই সেটিংটি ব্যাবহার করবেন না। অনুপযুক্ত সেটিংস ক্লায়েন্টকে স্বাভাবিকভাবে ব্যবহার করতে বাধা দিতে পারে।" +global: "গ্লোবাল" +squareAvatars: "চারকোনা প্রোফাইল পিকচার দেখান " +sent: "পাঠান" +received: "প্রাপ্ত" +searchResult: "অনুসন্ধানের ফলাফল" +hashtags: "হ্যাশট্যাগ" +troubleshooting: "ট্রাবলশুটিং" +useBlurEffect: "UI তে ব্লার ইফেক্ট ব্যাবহার করুন" +learnMore: "আরও জানুন" +iceshrimpUpdated: "Iceshrimp আপডেট করা হয়েছে!" +whatIsNew: "পরিবর্তনগুলি দেখান" +translate: "অনুবাদ" +translatedFrom: "{x} হতে অনুবাদ করা" +accountDeletionInProgress: "অ্যাকাউন্ট মুছে ফেলা হচ্ছে" +usernameInfo: "একটি নাম যা সার্ভারে আপনার অ্যাকাউন্টটিকে অনন্যভাবে সনাক্ত করে। আপনি বর্ণমালা (a ~ z, A ~ Z), সংখ্যা (0 ~ 9), এবং আন্ডারস্কোর (_) ব্যবহার করতে পারেন। ব্যবহারকারীর নাম পরে পরিবর্তন করা যাবে না।" +aiChanMode: "Ai মোড" +keepCw: "CW রাখুন" +pubSub: "Pub/Sub অ্যাকাউন্টগুলো" +lastCommunication: "শেষ যোগাযোগ" +resolved: "সমাধান হয়েছে" +unresolved: "সমাধান হয়নি" +breakFollow: "অনুসরণ করা বন্ধ" +itsOn: "চালু" +itsOff: "বন্ধ" +emailRequiredForSignup: "অ্যাকাউন্ট তৈরির জন্য ইমেইল এর দরকার পড়বে" +unread: "অপঠিত" +filter: "ফিল্টার" +controlPanel: "নিয়ন্ত্রন কেন্দ্র" +manageAccounts: "অ্যাকাউন্টগুলি পরিচালনা করুন" +makeReactionsPublic: "রিঅ্যাকশনের ইতিহাস উন্মুক্ত করুন" +makeReactionsPublicDescription: "আপনার পূর্ববর্তী রিঅ্যাকশনগুলির তালিকা যে কারও কাছে দৃশ্যমান হবে।" +classic: "ক্লাসিক" +muteThread: "থ্রেড মিউট করুন" +unmuteThread: "থ্রেড আনমিউট করুন" +ffVisibility: "অনুসরণ/অনুসরণকারীদের দৃশ্যমান্যতা" +ffVisibilityDescription: "আপনি কাকে অনুসরণ করেন এবং কে আপনাকে অনুসরণ করে, সেটা কারা দেখতে পাবে তা নির্ধারণ করে।" +continueThread: "আরো থ্রেড দেখুন" +deleteAccountConfirm: "আপনার অ্যাকাউন্ট মুছে ফেলা হবে। ঠিক আছে?" +incorrectPassword: "আপনার দেওয়া পাসওয়ার্ডটি ভুল।" +voteConfirm: "\"{choice}\" এ ভোট দিতে চান?" +hide: "লুকান" +leaveGroup: "গ্রুপ ছেড়ে চলে যান" +leaveGroupConfirm: "\"{name}\" গ্রুপ ছেড়ে চলে যেতে চান?" +useDrawerReactionPickerForMobile: "মোবাইলে রিঅ্যাকশন পিকারকে ড্রয়ারে প্রদর্শন করুন" +clickToFinishEmailVerification: " [{ok}] ক্লিক করার মাধ্যমে আপনার ইমেল ঠিকানা নিশ্চিত করুন।" +overridedDeviceKind: "ডিভাইসের ধরন" +smartphone: "স্মার্টফোন" +tablet: "ট্যাবলেট" +auto: "স্বয়ংক্রিয়" +themeColor: "থিমের রং" +size: "আকার" +numberOfColumn: "কলামের সংখ্যা" +searchByGoogle: "গুগল" +instanceDefaultLightTheme: "ইন্সট্যান্সের ডিফল্ট লাইট থিম" +instanceDefaultDarkTheme: "ইন্সট্যান্সের ডিফল্ট ডার্ক থিম" +instanceDefaultThemeDescription: "অবজেক্ট ফরম্যাটে থিম কোড লিখুন" +mutePeriod: "মিউটের সময়কাল" +indefinitely: "অনির্দিষ্ট" +tenMinutes: "১০ মিনিট" +oneHour: "১ ঘণ্টা" +oneDay: "একদিন" +oneWeek: "এক সপ্তাহ" +reflectMayTakeTime: "এটির কাজ দেখা যেতে কিছুটা সময় লাগতে পারে।" +failedToFetchAccountInformation: "অ্যাকাউন্টের তথ্য উদ্ধার করা যায়নি" +rateLimitExceeded: "রেট লিমিট ছাড়িয়ে গেছে " +file: "ফাইলগুলি" +reverse: "উল্টান" +colored: "রঙ্গিন" +label: "লেবেল" +localOnly: "শুধুমাত্র লোকাল" +account: "অ্যাকাউন্টগুলি" +_emailUnavailable: + used: "এই ইমেইল ঠিকানাটি ইতোমধ্যে ব্যবহৃত হয়েছে" + format: "এই ইমেল ঠিকানাটি সঠিকভাবে লিখা হয়নি" + disposable: "অস্থায়ী ইমেইল ঠিকানা ব্যাবহার করা যাবে না" + mx: "ইমেইল ​​সার্ভারটি ঠিক নাই" + smtp: "ইমেইল সার্ভারটি সাড়া দিচ্ছে না" +_ffVisibility: + public: "প্রকাশ" + followers: "শুধুমাত্র অনুসরণকারীদের কাছে দৃশ্যমান" + private: "ব্যাক্তিগত" +_signup: + almostThere: "প্রায় শেষ" + emailAddressInfo: "আপনি যে ইমেল ঠিকানাটি ব্যবহার করবেন সেটি লিখুন। আপনার ইমেইল ঠিকানা প্রকাশ করা হবে না।" + emailSent: "আপনার দেওয়া ইমেল ঠিকানায় ({email}) একটি নিশ্চিতকরণ ইমেল পাঠানো হয়েছে। অ্যাকাউন্ট তৈরি সম্পূর্ণ করতে ইমেলের লিঙ্কটি অ্যাক্সেস করুন।" +_accountDelete: + accountDelete: "অ্যাকাউন্ট মুছে ফেলুন" + mayTakeTime: "একটি অ্যাকাউন্ট মুছে ফেলা একটি দীর্ঘ প্রক্রিয়া এবং আপনি যদি প্রচুর পরিমাণে সামগ্রী তৈরি করে থাকেন বা ফাইল আপলোড করেন তবে এটি সম্পূর্ণ হতে দীর্ঘ সময় নিতে পারে।" + sendEmail: "অ্যাকাউন্ট মুছে ফেলা সম্পূর্ণ হলে, নিবন্ধিত ইমেল ঠিকানায় একটি বিজ্ঞপ্তি পাঠানো হবে।" + requestAccountDelete: "অ্যাকাউন্ট মুছে ফেলার অনুরোধ করুন" + started: "মুছে ফেলার প্রক্রিয়া শুরু হয়েছে।" + inProgress: "মুছে ফেলার কাজ চলছে" +_ad: + back: "পিছনে" + reduceFrequencyOfThisAd: "এই বিজ্ঞাপনটি কম দেখান" +_forgotPassword: + enterEmail: "আপনি আপনার অ্যাকাউন্টের জন্য নিবন্ধিত ইমেল ঠিকানা লিখুন. সেই ঠিকানায় একটি পাসওয়ার্ড রিসেট লিঙ্ক পাঠানো হবে।" + ifNoEmail: "আপনি যদি নিবন্ধনের সময় ই-মেইল ঠিকানা না দিয়ে থাকেন, তাহলে অনুগ্রহ করে প্রশাসকের সাথে যোগাযোগ করুন।" + contactAdmin: "এই ইন্সট্যান্সটি ইমেইল ব্যাবহার করে না, তাই আপনার পাসওয়ার্ড পুনরায় সেট করতে প্রশাসকের সাথে যোগাযোগ করুন৷" +_gallery: + my: "আমার গ্যালারী" + liked: "পছন্দ করা পোস্ট" + like: "পছন্দ করা" + unlike: "পছন্দ সরান" +_email: + _follow: + title: "আপনাকে অনুসরণ করছে" + _receiveFollowRequest: + title: "অনুসরণ করার অনুরোধ পেয়েছেন" +_plugin: + install: "প্লাগইন ইন্সটল করুন" + installWarn: "অবিশ্বস্ত প্লাগইন ইনস্টল করবেন না।" + manage: "প্লাগইন ম্যানেজ করুন" +_registry: + scope: "স্কোপ" + key: "কী" + keys: "কী - সমূহ" + domain: "ডোমেন" + createKey: "কী বানান" +_aboutIceshrimp: + about: "Iceshrimp, একটি ওপেন সোর্স সফ্টওয়্যার যা 2014 সাল থেকে syuilo তৈরি করছেন।" + contributors: "প্রধান কন্ট্রিবিউটারগণ" + allContributors: "সকল কন্ট্রিবিউটারগণ" + source: "সোর্স কোড" + translation: "Iceshrimp অনুবাদ করুন" + donate: "Iceshrimp তে দান করুন" + morePatrons: "আরও অনেকে আমাদের সাহায্য করছেন। তাদের সবাইকে ধন্যবাদ 🥰" + patrons: "সমর্থনকারী" +_nsfw: + respect: "স্পর্শকাতর মিডিয়া লুকান" + ignore: "স্পর্শকাতর মিডিয়া লুকাবেন না" + force: "সকল মিডিয়া লুকান" +_mfm: + cheatSheet: "MFM চিটশিট" + intro: "MFM একটি মার্কআপ ভাষা যা Iceshrimp-এর মধ্যে বিভিন্ন জায়গায় ব্যবহার করা যেতে পারে। এখানে আপনি MFM-এর সিনট্যাক্সগুলির একটি তালিকা দেখতে পারবেন।" + dummy: "মিসকি ফেডিভার্সের বিশ্বকে প্রসারিত করে" + mention: "উল্লেখ" + mentionDescription: "@ চিহ্ন + ব্যবহারকারীর নাম একটি নির্দিষ্ট ব্যবহারকারীকে নির্দেশ করতে ব্যবহার করা যায়।" + hashtag: "হ্যাশট্যাগ" + hashtagDescription: "আপনি একটি # চিহ্ন + ট্যাগ সহ একটি হ্যাশট্যাগ নির্দেশ করতে পারেন।" + url: "URL" + urlDescription: "URL দেখানো সম্ভব।" + link: "লিংক" + linkDescription: "আপনি পাঠ্যের একটি নির্দিষ্ট অংশকে URL হিসাবে দেখাতে পারেন৷" + bold: "গাঢ়" + boldDescription: "অক্ষরগুলিকে মোটাকরে প্রদর্শন করা হবে।" + small: "ছোট" + smallDescription: "লেখা ছোট এবং পাতলা করে দেখানো হবে।" + center: "সেন্টার" + centerDescription: "লেখা মাঝ বরাবর দেখানো হবে" + inlineCode: "কোড (ইনলাইন)" + inlineCodeDescription: " প্রোগ্রামের কোডের জন্য ইনলাইন সিনট্যাক্স হাইলাইটিং করা হবে" + blockCode: "কোড (ব্লক)" + blockCodeDescription: "মাল্টি-লাইন প্রোগ্রামের কোডের জন্য সিনট্যাক্স হাইলাইট করে।" + inlineMath: "গাণিতিক সূত্র (ইনলাইন)" + inlineMathDescription: "গাণিতিক সূত্র প্রদর্শন করুন (KaTeX) ইনলাইন।" + blockMath: "গাণিতিক সূত্র (ব্লক)" + blockMathDescription: "একটি ব্লকে একাধিক লাইনের গাণিতিক সূত্র প্রদর্শন করুন (KaTeX)।" + quote: "উদ্ধৃতি" + quoteDescription: "বিষয়বস্তুকে একটি উদ্ধৃতি হিসাবে দেখানো হবে।" + emoji: "স্বনির্ধারিত ইমোজিগুলি" + emojiDescription: "আপনি একটি কাস্টম ইমোজির নাম কোলনে আবদ্ধ করে কাস্টম ইমোজিটি দেখাতে পারেন৷" + search: "খুঁজুন" + searchDescription: "পূর্ব-টাইপ করা পাঠ্য সহ একটি অনুসন্ধান বাক্স প্রদর্শন করে।" + flip: "উল্টান" + flipDescription: "বিষয়বস্তু উপরে/নীচে বা বাম/ডানে উল্টান।" + jelly: "অ্যানিমেশন (জেলি)" + jellyDescription: "জেলির মত অ্যানিমেশন দেখায়।" + tada: "অ্যানিমেশন (টাডা)" + tadaDescription: "\"টাডা!\" এর মত অ্যানিমেশন দেখায়।" + jump: "অ্যানিমেশন (লাফ)" + jumpDescription: "বিষয়বস্তুতে লাফ মারার মত অ্যানিমেশন দেখায়।" + bounce: "অ্যানিমেশন (তিড়িং বিড়িং)" + bounceDescription: "তিড়িং বিড়িং করার মত অ্যানিমেশন দেখায়।" + shake: "অ্যানিমেশন (ঝাঁকি)" + shakeDescription: "ঝাঁকির মত অ্যানিমেশন দেখায়।" + twitch: "অ্যানিমেশন (মোচড়ানো)" + twitchDescription: "মোচড়ানোর মত অ্যানিমেশন দেখায়।" + spin: "অ্যানিমেশন (ঘুরা)" + spinDescription: "ঘুরার মত অ্যানিমেশন দেখায়।" + x2: "বড়" + x2Description: "বিষয়বস্তু বড় করে দেখায়।" + x3: "অনেক বড়" + x3Description: "বিষয়বস্তু আরও বড় করে দেখায়।" + x4: "অস্বাভাবিক বড়" + x4Description: "বিষয়বস্তুকে আগের থেকেও আরও বড় করে দেখায়।" + blur: "ব্লার" + blurDescription: "বিষয়বস্তুকে ব্লার করতে পারেন। আপনি এর উপর মাউস কার্সার রাখলে, এটি পরিষ্কারভাবে দেখতে পাবেন।" + font: "ফন্ট" + fontDescription: "বিষয়বস্তুকে কোন ফন্টে দেখানো হবে তা নির্ধারণ করে।" + rainbow: "রেইনবো" + rainbowDescription: "বিষয়বস্তুকে রংধনুর রং গুলিতে প্রদর্শন করে।" + sparkle: "চিক চিক" + sparkleDescription: "বিষয়বস্তুকে একটি চিকচিকে কণা প্রভাব দেয়।" + rotate: "ঘুরান" + rotateDescription: "বিষয়বস্তুকে একটি নির্দিষ্ট কোনে ঘুরায়।" +_instanceTicker: + none: "দেখাবেন না" + remote: "রিমোট ব্যাবহারকারীদের জন্য দেখান" + always: "সর্বদা দেখান" +_serverDisconnectedBehavior: + reload: "স্বয়ংক্রিয়ভাবে রিলোড" + dialog: "সতর্কতা ডায়ালগ দেখান" + quiet: "অগচরী সতর্কতা দেখান" +_channel: + create: "চ্যানেল বানান" + edit: "চ্যানেল সম্পাদনা করুন" + setBanner: "ব্যানার সেট করুন" + removeBanner: "ব্যানার সরান" + featured: "বর্তমানে জনপ্রিয়" + owned: "নিজের" + following: "অনুসরণ করা হচ্ছে" + usersCount: "{n} জন অংশগ্রহণকারী" + notesCount: "{n} টি নোট" +_menuDisplay: + sideFull: "পাশে" + sideIcon: "পাশে (আইকন)" + top: "শীর্ষে" + hide: "লুকান" +_wordMute: + muteWords: "নিঃশব্দ করা শব্দগুলি" + muteWordsDescription: "স্পেস দিয়ে আলাদা করলে AND শর্ত তৈরি হবে এবং আলাদা লাইনে লিখলে OR শর্ত তৈরি হবে।" + muteWordsDescription2: "রেগুলার এক্সপ্রেশন ব্যবহার করতে স্ল্যাশ দিয়ে কীওয়ার্ডকে ঘিরে রাখুন।" + softDescription: "টাইমলাইন থেকে নির্দিষ্ট শর্তানুযায়ী নোট লুকিয়ে রাখে।" + hardDescription: "নির্দিষ্ট শর্তানুযায়ী নোটগুলিকে টাইমলাইন থেকে বাদ দেয়। আপনি শর্ত পরিবর্তন করলেও যে নোটগুলি যোগ করা হয়নি সেগুলি বাদ দেওয়া হবে।" + soft: "নমনীয়" + hard: "কঠোর" + mutedNotes: "মিউট করা নোটগুলি" +_instanceMute: + instanceMuteDescription: "কনফিগার করা ইন্সট্যান্সের সব নোট এবং রিনোট মিউট করুন, মিউট করা ইন্সট্যান্সের ব্যবহারকারীদের উত্তর সহ।" + instanceMuteDescription2: "প্রতিটিকে আলাদা লাইনে লিখুন" + title: "কনফিগার করা ইন্সট্যান্সের নোটগুলিকে লুকিয়ে রাখে।" + heading: "মিউট করা ইন্সত্যান্সের তালিকা" +_theme: + explore: "থিমগুলি ঘুরে দেখুন" + install: "থিম ইনস্টল করুন" + manage: "থিম ব্যাবস্থাপনা" + code: "থিম কোড" + description: "বর্ণনা" + installed: "{name} ইন্সটল করা হয়েছে" + installedThemes: "ইন্সটল করা থিমসমূহ" + builtinThemes: "বিল্ট-ইন থিমসমূহ" + alreadyInstalled: "এই থিমটি ইতিমধ্যে ইন্সটল করা হয়েছে" + invalid: "থিমটির ফরম্যাট সঠিক নয়" + make: "থিম বানান" + base: "বেস" + addConstant: "ধ্রুবক যোগ করুন" + constant: "ধ্রুবক" + defaultValue: "ডিফল্ট মান" + color: "রং" + refProp: "প্রোপার্টি রেফারেন্স করুন" + refConst: "ধ্রুবক রেফারেন্স করুন" + key: "কী" + func: "ফাংশন" + funcKind: "ফাংশনের ধরন" + argument: "আর্গুমেন্ট" + basedProp: "রেফারেন্স করা প্রোপার্টি" + alpha: "অস্বচ্ছতা" + darken: "অন্ধকার করুন" + lighten: "উজ্জ্বল করুন" + inputConstantName: "ধ্রুবকটির নাম লিখুন" + importInfo: "আপনি এখানে থিম কোড পেস্ট করতে পারেন এবং সেটিকে এডিটরে ইম্পোর্ট করতে পারেন" + deleteConstantConfirm: "আপনি কি ধ্রুবক {const} মুছে ফেলতে চান?" + keys: + accent: "অ্যাকসেন্ট" + bg: "পটভূমি" + fg: "লেখা" + focus: "ফোকাস" + indicator: "ইনডিকেটর" + panel: "প্যানেল" + shadow: "ছায়া" + header: "হেডার" + navBg: "সাইডবারের পটভূমি" + navFg: "সাইডবারের পাঠ্য" + navHoverFg: "সাইডবারের পাঠ্য (হভার)" + navActive: "সাইডবারের পাঠ্য (অ্যাকটিভ)" + navIndicator: "সাইডবারের ইনডিকেটর" + link: "লিংক" + hashtag: "হ্যাশট্যাগ" + mention: "উল্লেখ" + mentionMe: "আপনাকে উল্লেখ্য করা" + renote: "রিনোট" + modalBg: "মোডালের পটভূমি" + divider: "খন্ডক" + scrollbarHandle: "স্ক্রলবার হ্যান্ডেল" + scrollbarHandleHover: "স্ক্রলবার হ্যান্ডেল (হভার)" + dateLabelFg: "তারিখ লেবেলের পাঠ্য" + infoBg: "তথ্যের পটভূমি" + infoFg: "তথ্যের পাঠ্য" + infoWarnBg: "ওয়ার্নিং এর পটভূমি" + infoWarnFg: "ওয়ার্নিং এর পাঠ্য" + cwBg: "CW বাটনের পটভূমি" + cwFg: "CW বাটনের পাঠ্য" + cwHoverBg: "CW বাটনের পটভূমি (হভার)" + toastBg: "বিজ্ঞপ্তির পটভূমি" + toastFg: "বিজ্ঞপ্তির পাঠ্য" + buttonBg: "বাটনের পটভূমি" + buttonHoverBg: "বাটনের পটভূমি (হভার)" + inputBorder: "ইনপুট ফিল্ডের বর্ডার" + listItemHoverBg: "লিস্ট আইটেমের পটভূমি (হোভার)" + driveFolderBg: "ড্রাইভ ফোল্ডারের পটভূমি" + wallpaperOverlay: "ওয়ালপেপার ওভারলে" + badge: "ব্যাজ" + messageBg: "চ্যাটের পটভূমি" + accentDarken: "অ্যাকসেন্ট (গাঢ়)" + accentLighten: "অ্যাকসেন্ট (হাল্কা)" + fgHighlighted: "হাইলাইট করা পাঠ্য" +_sfx: + note: "নোটগুলি" + noteMy: "নোট (আপনার)" + notification: "বিজ্ঞপ্তি" + chat: "চ্যাট" + chatBg: "চ্যাট (ব্যাকগ্রাউন্ড)" + antenna: "অ্যান্টেনাগুলি" + channel: "চ্যানেলের বিজ্ঞপ্তি" +_ago: + future: "ভবিষ্যৎ" + justNow: "এইমাত্র" + secondsAgo: "{n} সেকেন্ড আগে" + minutesAgo: "{n} মিনিট {n2} সেকেন্ড আগে" + hoursAgo: "{n} ঘণ্টা {n2} মিনিট আগে" + daysAgo: "{n} দিন {n2} ঘণ্টা আগে" + weeksAgo: "{n} সপ্তাহ {n2} দিন আগে" + monthsAgo: "{n} মাস {n2} সপ্তাহ আগে" + yearsAgo: "{n} বছর {n2} মাস আগে" +_time: + second: "সেকেন্ড" + minute: "মিনিট" + hour: "ঘণ্টা" + day: "দিন" +_tutorial: + title: "How to use Iceshrimp" + step1_1: "Welcome!" + step1_2: "Let's get you set up. You'll be up and running in no time!" + step2_1: "First, please fill out your profile." + step2_2: "Providing some information about who you are will make it easier for others to tell if they want to see your notes or follow you." + step3_1: "Now time to follow some people!" + step3_2: "Your home and social timelines are based off of who you follow, so try following a couple accounts to get started.\nClick the plus circle on the top right of a profile to follow them." + step4_1: "Let's get you out there." + step4_2: "For your first post, some people like to made a {introduction} post or a simple \"Hello world!\"" + step5_1: "Timelines, timelines everywhere!" + step5_2: "Your instance has {timelines} different timelines enabled." + step5_3: "The Home {icon} timeline is where you can see posts from your followers." + step5_4: "The Local {icon} timeline is where you can see posts from everyone else on this instance." + step5_5: "The Recommended {icon} timeline is where you can see posts from instances the admins recommend." + step5_6: "The Social {icon} timeline is where you can see posts from friends of your followers." + step5_7: "The Global {icon} timeline is where you can see posts from every other connected instance." + step6_1: "So, what is this place?" + step6_2: "Well, you didn't just join Iceshrimp. You joined a portal to the Fediverse, an interconnected network of thousands of servers, called \"instances\"." + step6_3: "Each server works in different ways, and not all servers run Iceshrimp. This one does though! It's a bit complicated, but you'll get the hang of it in no time." + step6_4: "Now go, explore, and have fun!" +_2fa: + alreadyRegistered: "আপনি ইতিমধ্যে একটি 2-ফ্যাক্টর অথেনটিকেশন ডিভাইস নিবন্ধন করেছেন৷" + registerTOTP: "নতুন ডিভাইস নিবন্ধন করুন" + registerSecurityKey: "সিকিউরিটি কী নিবন্ধন করুন" + step1: "প্রথমে, আপনার ডিভাইসে {a} বা {b} এর মতো একটি অথেনটিকেশন অ্যাপ ইনস্টল করুন৷" + step2: "এরপরে, অ্যাপের সাহায্যে প্রদর্শিত QR কোডটি স্ক্যান করুন।" + step2Url: "ডেস্কটপ অ্যাপে, নিম্নলিখিত URL লিখুন:" + step3: "অ্যাপে প্রদর্শিত টোকেনটি লিখুন এবং আপনার কাজ শেষ।" + step4: "আপনাকে এখন থেকে লগ ইন করার সময়, এইভাবে টোকেন লিখতে হবে।" + securityKeyInfo: "আপনি একটি হার্ডওয়্যার সিকিউরিটি কী ব্যবহার করে লগ ইন করতে পারেন যা FIDO2 বা ডিভাইসের ফিঙ্গারপ্রিন্ট সেন্সর বা পিন সমর্থন করে৷" +_permissions: + "read:account": "অ্যাকাউন্টের তথ্য দেখুন" + "write:account": "অ্যাকাউন্টের তথ্য সম্পাদন করুন" + "read:blocks": "ব্লক করা ব্যাবহারকারীদের তালিকা দেখুন" + "write:blocks": "ব্লক করা ব্যাবহারকারীদের তালিকা সম্পাদনা করুন" + "read:drive": "ড্রাইভের ফাইল এবং ফোল্ডারসমূহ পড়া" + "write:drive": "ড্রাইভের ফাইল এবং ফোল্ডারসমূহ সম্পাদনা করা" + "read:favorites": "পছন্দের তালিকা পড়া" + "write:favorites": "পছন্দের তালিকা সম্পাদনা করা" + "read:following": "অনুসরণ তথ্য দেখুন" + "write:following": "অনুসরণ তথ্য সম্পাদনা করা" + "read:messaging": "চ্যাটগুলি দেখুন" + "write:messaging": "চ্যাটগুলি সম্পাদনা করুন" + "read:mutes": "মিউটের লিস্ট দেখুন" + "write:mutes": "মিউটের লিস্ট সম্পাদনা করুন" + "write:notes": "নোট লিখা" + "read:notifications": "বিজ্ঞপ্তিগুলি দেখুন" + "write:notifications": "বিজ্ঞপ্তি নিয়ে কাজ করে" + "read:reactions": "রিঅ্যাকশনগুলি দেখুন" + "write:reactions": "রিঅ্যাকশনগুলি সম্পাদনা করুন" + "write:votes": "ভোট দিন" + "read:pages": "আপনার পেজগুলি দেখুন" + "write:pages": "পেজগুলি সম্পাদনা বা ডিলিট করুন" + "read:page-likes": "পৃষ্ঠায় দেয়া পছন্দগুলি দেখুন" + "write:page-likes": "পৃষ্ঠায় দেয়া পছন্দগুলি সম্পাদনা করুন" + "read:user-groups": "ব্যাবহারকারী গ্রুপগুলি দেখুন" + "write:user-groups": "ব্যাবহারকারী গ্রুপগুলি সম্পাদনা করুন" + "read:channels": "চ্যানেলগুলি দেখুন" + "write:channels": "চ্যানেলগুলি সম্পাদনা করুন" + "read:gallery": "গ্যালারী দেখুন" + "write:gallery": "গ্যালারী সম্পাদনা করুন" + "read:gallery-likes": "গ্যালারীর পছন্দগুলি দেখুন" + "write:gallery-likes": "গ্যালারীর পছন্দগুলি সম্পাদনা করুন" +_auth: + shareAccess: "\"{name}\" কে অ্যাকাউন্টের অ্যাক্সেস দিবেন?" + shareAccessAsk: "অ্যাপ্লিকেশনটিকে অ্যাকাউন্টের অ্যাক্সেস দিবেন?" + permissionAsk: "এই অ্যাপ্লিকেশনটি নিম্নলিখিত অনুমতি চাই" + pleaseGoBack: "দয়া করে অ্যাপ্লিকেশনে ফিরে যান" + callback: "অ্যাপ্লিকেশনে ফিরে যাচ্ছি" + denied: "প্রবেশ নিষেধ" +_antennaSources: + all: "সকল নোট" + homeTimeline: "আপনি অনুসরণ করছেন, এমন ব্যবহারকারীদের নোট" + users: "এক বা একাধিক নির্দিষ্ট ব্যবহারকারীর নোট" + userList: "নির্দিষ্ট তালিকায় নাম থাকা ব্যবহারকারীদের নোট" + userGroup: "নির্দিষ্ট গ্রুপে থাকা ব্যবহারকারীদের নোট" +_weekday: + sunday: "রবিবার" + monday: "সোমবার" + tuesday: "মঙ্গলবার" + wednesday: "বুধবার" + thursday: "বৃহস্পতিবার" + friday: "শুক্রবার" + saturday: "শনিবার" +_widgets: + memo: "স্টিকি নোট" + notifications: "বিজ্ঞপ্তি" + timeline: "টাইমলাইন" + calendar: "ক্যালেন্ডার" + trends: "বর্তমানে জনপ্রিয়" + clock: "ঘড়ি" + rss: "RSS রিডার" + activity: "কার্যকলাপ" + photos: "ফটোগুলি" + digitalClock: "ডিজিটাল ঘড়ি" + federation: "ফেডিভার্স" + postForm: "নোট লিখুন" + slideshow: "স্লাইডশো" + button: "বাটন" + onlineUsers: "অনলাইনে থাকা ব্যবহারকারীগণ" + jobQueue: "জব কিউ" + serverMetric: "সার্ভার মেট্রিক্স" + aiscript: "AiScript কনসোল" + aichan: "আই চান" +_cw: + hide: "লুকান" + show: "আরও দেখুন" + chars: "{count} টি অক্ষর" + files: "{count} টি ফাইল" +_poll: + noOnlyOneChoice: "সর্বনিম্ন 2 টি অপশন বেছে নিতে হবে" + choiceN: "বিকল্পগুলি {n}" + noMore: "আপনি আর কোন বিকল্প যোগ করতে পারবেন না" + canMultipleVote: "একাধিক বিকল্প বাছাই করা যাবে" + expiration: "পোলের সময়সীমা" + infinite: "অনির্দিষ্ট" + at: "শেষ হবে" + after: "শেষ হবে" + deadlineDate: "শেষ হওয়ার তারিখ" + deadlineTime: "ঘণ্টা" + duration: "ব্যাপ্তিকাল" + votesCount: "{n} টি ভোট" + totalVotes: "সর্বমোট {n} টি ভোট" + vote: "ভোট দিন" + showResult: "রেজাল্ট দেখান" + voted: "ভোট দিয়েছেন" + closed: "শেষ হয়ে গেছে" + remainingDays: "আর {d} দিন {h} ঘণ্টা বাকি আছে" + remainingHours: "আর {h} ঘণ্টা {m} মিনিট বাকি আছে" + remainingMinutes: "আর বাকি আছে {m} মিনিট {s} সেকেন্ড" + remainingSeconds: "আর বাকি আছে {s} সেকেন্ড" +_visibility: + public: "সর্বজনীন" + publicDescription: "সবাই আপনার নোটগুলি দেখতে পাবে" + home: "মূল পাতা" + homeDescription: "শুধুমাত্র হোম টাইমলাইনে আপনার নোটগুলি পোস্ট করুন" + followers: "অনুসরণকারী" + followersDescription: "শুধুমাত্র আপনার অনুসরণকারীদের নিকট পোস্ট করুন" + specified: "ডাইরেক্ট নোট" + specifiedDescription: "শুধুমাত্র নির্দিষ্ট ব্যাবহারকারীর নিকট পাঠান" + localOnly: "শুধুমাত্র লোকাল" + localOnlyDescription: "রিমোট ব্যাবহারকারীদের নিকট দৃশ্যমান নয়" +_postForm: + replyPlaceholder: "নোটটির জবাব দিন..." + quotePlaceholder: "নোটটিকে উদ্ধৃত করুন..." + channelPlaceholder: "চ্যানেলে পোস্ট করুন..." + _placeholders: + a: "আপনি এখন কি করছেন?" + b: "আপনার আশে পাশে কি হচ্ছে?" + c: "আপনি কি ভাবছেন?" + d: "আপনি কি বলতে চান?" + e: "লেখা শুরু করুন..." + f: "আপনার লেখার জন্য অপেক্ষা করছি..." +_profile: + name: "নাম" + username: "ব্যবহারকারীর নাম" + description: "আপনার সম্পর্কে" + youCanIncludeHashtags: "হ্যাশট্যাগ অন্তর্ভুক্ত করা যেতে পারে।" + metadata: "অতিরিক্ত তথ্য" + metadataEdit: "অতিরিক্ত তথ্য সম্পাদনা করুন" + metadataDescription: "আপনি আপনার প্রোফাইলে একটি টেবিল হিসাবে চারটি অতিরিক্ত তথ্য দেখাতে পারেন।. আপনি আপনার প্রোফাইলে লিঙ্কটি যাচাই করতে {rel} এর সাথে একটি {a} ট্যাগ বা {l} ট্যাগ যোগ করতে পারেন!" + metadataLabel: "লেবেল" + metadataContent: "বিষয়বস্তু" + changeAvatar: "অ্যাভাটার পরিবর্তন করুন" + changeBanner: "ব্যানার পরিবর্তন করুন" +_exportOrImport: + allNotes: "সকল নোট" + followingList: "অনুসরণ করা হচ্ছে" + muteList: "মিউট" + blockingList: "ব্লক" + userLists: "লিস্ট" + excludeMutingUsers: "মিউটকৃত ব্যবহারকারীদের বাদ দিন" + excludeInactiveUsers: "অব্যাবহৃত অ্যাকাউন্ট বাদ দিন" +_charts: + federation: "ফেডিভার্স" + apRequest: "অনুরোধসমূহ" + usersIncDec: "ব্যবহারকারীদের সংখ্যার পরিবর্তন" + usersTotal: "ব্যবহারকারীদের সংখ্যা" + activeUsers: "সক্রিয় ব্যাবহারকারী" + notesIncDec: "নোটের সংখ্যার পরিবর্তন" + localNotesIncDec: "লোকাল নোটের সংখ্যার পরিবর্তন" + remoteNotesIncDec: "রিমোট নোটের সংখ্যার পরিবর্তন" + notesTotal: "নোটের সংখ্যা" + filesIncDec: "ফাইলের সংখ্যার পরিবর্তন" + filesTotal: "ফাইলের সংখ্যা" + storageUsageIncDec: "স্টোরেজের ব্যাবহারের পরিবর্তন" + storageUsageTotal: "মোট স্টোরেজের ব্যাবহার" +_instanceCharts: + requests: "অনুরোধসমূহ" + users: "ব্যবহারকারীদের সংখ্যার পরিবর্তন" + usersTotal: "ক্রমবর্ধমান ব্যবহারকারীদের সংখ্যা" + notes: "নোটের সংখ্যার পরিবর্তন" + notesTotal: "ক্রমবর্ধমান নোটের সংখ্যা" + ff: "অনুসরণকারী / অনুসরণ করা ব্যাবহারকারীদের সংখ্যার পরিবর্তন" + ffTotal: "অনুসরণকারী / অনুসরণ করা ব্যাবহারকারীদের ক্রমবর্ধমান সংখ্যা" + cacheSize: "ক্যাশ সাইজের পরিবর্তন" + cacheSizeTotal: "ক্রমবর্ধমান ক্যাশ সাইজ" + files: "ফাইলের সংখ্যার পরিবর্তন" + filesTotal: "ক্রমবর্ধমান ফাইলের সংখ্যা" +_timelines: + home: "মূল পাতা" + local: "স্থানীয়" + social: "সামাজিক" + global: "গ্লোবাল" +_pages: + newPage: "নতুন পৃষ্ঠা বানান" + editPage: "পৃষ্ঠাটি সম্পাদনা করুন" + readPage: "উৎস দেখছেন" + created: "পৃষ্ঠা তৈরি করা হয়েছে" + updated: "পৃষ্ঠা সম্পাদনা করা হয়েছে" + deleted: "পৃষ্ঠা মুছে ফেলা হয়েছে" + pageSetting: "পৃষ্ঠার সেটিংস" + nameAlreadyExists: "পৃষ্ঠার URLটি ইতিমধ্যেই ব্যাবহার করা হয়েছে" + invalidNameTitle: "পৃষ্ঠার URL অবৈধ" + invalidNameText: "নিশ্চিত করুন যে এটি ফাঁকা নয়" + editThisPage: "পৃষ্ঠাটি সম্পাদনা করুন" + viewSource: "উৎস দেখুন" + viewPage: "আপনার পেজগুলি দেখুন" + like: "পছন্দ" + unlike: "পছন্দ সরান" + my: "আমার পৃষ্ঠাগুলি" + liked: "পছন্দ করা পৃষ্ঠাগুলি" + featured: "জনপ্রিয়" + inspector: "ইনিস্পেক্টর" + contents: "বিষয়বস্তু" + content: "পৃষ্ঠার ব্লক" + variables: "চলকগুলি" + title: "শিরোনাম" + url: "পৃষ্ঠার URL" + summary: "পৃষ্ঠার বর্ণনা" + alignCenter: "সেন্টার" + hideTitleWhenPinned: "পিন করা হলে টাইটেল লুকান" + font: "ফন্ট" + fontSerif: "সেরিফ" + fontSansSerif: "স্যান্স সেরিফ" + eyeCatchingImageSet: "থাম্বনেইল সেট করুন" + eyeCatchingImageRemove: "থাম্বনেইল সরান" + chooseBlock: "ব্লক যোগ করুন" + selectType: "ধরন নির্বাচন করুন" + enterVariableName: "চলকের নাম লিখুন" + variableNameIsAlreadyUsed: "চলকের নামটি ইতিপূর্বে ব্যাবহৃত হয়েছে" + contentBlocks: "বিষয়বস্তু" + inputBlocks: "ইনপুট" + specialBlocks: "বিশেষ" + blocks: + text: "লেখা" + textarea: "টেক্সট এরিয়া" + section: "বিভাগ" + image: "ছবি" + button: "বাটন" + if: "যদি" + _if: + variable: "চলকগুলি" + post: "নোট লিখুন" + _post: + text: "বিষয়বস্তু" + attachCanvasImage: "ক্যানভাস ছবিসহ পোস্ট করুন" + canvasId: "ক্যানভাস ID" + textInput: "টেক্সট ইনপুট" + _textInput: + name: "চলকের নাম" + text: "শিরোনাম" + default: "ডিফল্ট মান" + textareaInput: "একাধিক লাইনের টেক্সট ইনপুট" + _textareaInput: + name: "চলকের নাম" + text: "শিরোনাম" + default: "ডিফল্ট মান" + numberInput: "সংখ্যা ইনপুট" + _numberInput: + name: "চলকের নাম" + text: "শিরোনাম" + default: "ডিফল্ট মান" + canvas: "ক্যানভাস" + _canvas: + id: "ক্যানভাস ID" + width: "প্রস্থ" + height: "উচ্চতা" + note: "এম্বেড নোট" + _note: + id: "নোট ID" + idDescription: "আপনি এর বদলে নোটের URL পেস্ট করতে পারেন." + detailed: "বিস্তারিত দেখুন" + switch: "সুইচ" + _switch: + name: "চলকের নাম" + text: "শিরোনাম" + default: "ডিফল্ট মান" + counter: "কাউন্টার" + _counter: + name: "চলকের নাম" + text: "শিরোনাম" + inc: "এভাবে মান বাড়ান" + _button: + text: "শিরোনাম" + colored: "রঙ্গিন" + action: "বাটনে ক্লিক করলে যা হবে" + _action: + dialog: "ডায়ালগ দেখান " + _dialog: + content: "বিষয়বস্তু" + resetRandom: "র‍্যানডম সিড রিসেট করুন" + pushEvent: "ইভেন্ট পাঠান" + _pushEvent: + event: "ইভেন্টের নাম" + message: "চালু হলে প্রদর্শনের জন্য বার্তা" + variable: "পাঠানো চলক" + no-variable: "কিছুই না" + callAiScript: "AiScript চালান" + _callAiScript: + functionName: "ফাংশনের নাম" + radioButton: "বহুনির্বাচনী" + _radioButton: + name: "চলকের নাম" + title: "শিরোনাম" + values: "বিকল্পগুলিকে আলাদা লাইনে লিখুন" + default: "ডিফল্ট মান" + script: + categories: + flow: "নিয়ন্ত্রণ" + logical: "লজিক্যাল অপারেশন" + operation: "হিসাব-নিকাশ" + comparison: "তুলনা" + random: "র‍্যান্ডম" + value: "মান" + fn: "ফাংশন" + text: "টেক্সট ম্যানিপুলেশন" + convert: "রুপান্তর" + list: "লিস্ট" + blocks: + text: "লেখা" + multiLineText: "লেখা (একাধিক লাইন)" + textList: "লেখার লিস্ট" + _textList: + info: "প্রতিটি এন্ট্রিকে আলাদা লাইনে লিখুন" + strLen: "লেখার দৈর্ঘ্য" + _strLen: + arg1: "লেখা" + strPick: "অক্ষর বের করে আনুন" + _strPick: + arg1: "লেখা" + arg2: "অক্ষরের অবস্থান" + strReplace: "লেখা প্রতিস্থাপন" + _strReplace: + arg1: "লেখা" + arg2: "যে লেখা প্রতিস্থাপন করা হবে" + arg3: "যা দ্বারা প্রতিস্থাপন করা হবে" + strReverse: "লেখা উল্টান" + _strReverse: + arg1: "লেখা" + join: "লেখা যুক্ত করুন" + _join: + arg1: "লিস্ট" + arg2: "বিভাজক" + add: "যোগ" + _add: + arg1: "A" + arg2: "B" + subtract: "বিয়োগ" + _subtract: + arg1: "A" + arg2: "B" + multiply: "গুন" + _multiply: + arg1: "A" + arg2: "B" + divide: "ভাগ" + _divide: + arg1: "A" + arg2: "B" + mod: "ভাগশেষ" + _mod: + arg1: "A" + arg2: "B" + round: "দশমিক রাউন্ড করুন" + _round: + arg1: "সংখ্যা" + eq: "A ও B সমান" + _eq: + arg1: "A" + arg2: "B" + notEq: "A ও B সমান না" + _notEq: + arg1: "A" + arg2: "B" + and: "A এবং B" + _and: + arg1: "A" + arg2: "B" + or: "A অথবা B" + _or: + arg1: "A" + arg2: "B" + lt: "< A , B হতে কম" + _lt: + arg1: "A" + arg2: "B" + gt: "> A , B হতে বেশী" + _gt: + arg1: "A" + arg2: "B" + ltEq: "<= A , B হতে কম বা সমান" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: ">= A , B হতে বেশী বা সমান" + _gtEq: + arg1: "A" + arg2: "B" + if: "যদি" + _if: + arg1: "যদি" + arg2: "তাহলে" + arg3: "তাছাড়া" + not: "না" + _not: + arg1: "না" + random: "র‍্যান্ডম" + _random: + arg1: "সম্ভাব্যতা" + rannum: "র‍্যানডম সংখ্যা" + _rannum: + arg1: "ন্যূনতম মান" + arg2: "সর্বোচ্চ মান" + randomPick: "তালিকা থেকে দৈবচয়ন করুন" + _randomPick: + arg1: "লিস্ট" + dailyRandom: "র‍্যান্ডম সংখ্যা (প্রতিটি ব্যবহারকারীর জন্য প্রতিদিন পরিবর্তীত হয়)" + _dailyRandom: + arg1: "সম্ভাব্যতা" + dailyRannum: "র‍্যান্ডম সংখ্যা (প্রতিটি ব্যবহারকারীর জন্য প্রতিদিন পরিবর্তীত হয়)" + _dailyRannum: + arg1: "ন্যূনতম মান" + arg2: "সর্বোচ্চ মান" + dailyRandomPick: "তালিকা থেকে এলোমেলোভাবে নির্বাচন করুন (প্রতিটি ব্যবহারকারীর জন্য প্রতিদিন পরিবর্তীত হয়)" + _dailyRandomPick: + arg1: "লিস্ট" + seedRandom: "র‍্যানডম (সীড দ্বারা)" + _seedRandom: + arg1: "সীড" + arg2: "সম্ভাব্যতা" + seedRannum: "র‍্যানডম সংখ্যা (সীড দ্বারা)" + _seedRannum: + arg1: "সীড" + arg2: "ন্যূনতম মান" + arg3: "সর্বোচ্চ মান" + seedRandomPick: "তালিকা থেকে দৈবচয়ন করুন (সীড দ্বারা)" + _seedRandomPick: + arg1: "সীড" + arg2: "লিস্ট" + DRPWPM: "সম্ভাব্যতা সহ একটি তালিকা থেকে এলোমেলোভাবে নির্বাচন করুন (প্রতিটি ব্যবহারকারীর জন্য প্রতিদিন)" + _DRPWPM: + arg1: "লেখার লিস্ট" + pick: "তালিকা থেকে নির্বাচন করুন" + _pick: + arg1: "লিস্ট" + arg2: "অবস্থান" + listLen: "লিস্টের দৈর্ঘ্য পান" + _listLen: + arg1: "লিস্ট" + number: "সংখ্যা" + stringToNumber: "পাঠ্য থেকে সংখ্যা" + _stringToNumber: + arg1: "লেখা" + numberToString: "সংখ্যা থেকে পাঠ্য" + _numberToString: + arg1: "সংখ্যা" + splitStrByLine: "পাঠ্যকে লাইনে বিভক্ত করুন" + _splitStrByLine: + arg1: "লেখা" + ref: "চলক" + aiScriptVar: "AiScript চলক" + fn: "ফাংশন" + _fn: + slots: "স্লটগুলি" + slots-info: "প্রতিটি স্লটকে আলাদা লাইনে লিখুন" + arg1: "আউটপুট" + for: "for-লুপ" + _for: + arg1: "কতবার চলবে" + arg2: "অ্যাকশন" + typeError: "স্লট {slot}, {expect} ধরনের মান গ্রহণ করে, কিন্তু {actual} ধরনের মান দেওয়া হয়েছে!" + thereIsEmptySlot: "স্লট {slot} খালি!" + types: + string: "লেখা" + number: "সংখ্যা" + boolean: "ফ্ল্যাগ" + array: "লিস্ট" + stringArray: "লেখার লিস্ট" + emptySlot: "খালি স্লট" + enviromentVariables: "এনভাইরনমেন্ট ভ্যারিয়েবল" + pageVariables: "পেজের চলক" + argVariables: "ইনপুটের জায়গা" +_relayStatus: + requesting: "অপেক্ষমান" + accepted: "অনুমোদিত" + rejected: "প্রত্যাখিত" +_notification: + fileUploaded: "ফাইল সফলভাবে আপলোড করা হয়েছে" + youGotMention: "{name} আপনাকে উল্লেখ্য করেছে" + youGotReply: "{name} আপনাকে জবাব দিয়েছে" + youGotQuote: "{name} আপনাকে উদ্ধৃত করেছে" + youRenoted: "{name} এর Renote" + youGotPoll: "{name} আপনার পোলে ভোট দিয়েছে" + youGotMessagingMessageFromUser: "{name} আপনাকে মেসেজ করেছে" + youGotMessagingMessageFromGroup: "{name} গ্রুপে একটি নতুন মেসেজ আছে" + youWereFollowed: "আপনাকে অনুসরণ করছে" + youReceivedFollowRequest: "অনুসরণ করার জন্য অনুরোধ পাওয়া গেছে" + yourFollowRequestAccepted: "আপনার অনুসরণ করার অনুরোধ গৃহীত হয়েছে" + youWereInvitedToGroup: "আপনি একটি গ্রুপে আমন্ত্রিত হয়েছেন" + pollEnded: "পোলের ফলাফল দেখা যাবে" + emptyPushNotificationMessage: "আপডেট করা পুশ বিজ্ঞপ্তি" + _types: + all: "সকল" + follow: "অনুসরণ করা হচ্ছে" + mention: "উল্লেখ" + reply: "উত্তর দিন" + renote: "রিনোট" + quote: "উদ্ধৃতি" + reaction: "প্রতিক্রিয়া" + pollVote: "পোলে ভোট আছে" + pollEnded: "পোল শেষ" + receiveFollowRequest: "প্রাপ্ত অনুসরণের অনুরোধসমূহ" + followRequestAccepted: "গৃহীত অনুসরণের অনুরোধসমূহ" + groupInvited: "গ্রুপের আমন্ত্রনসমূহ" + app: "লিঙ্ক করা অ্যাপ থেকে বিজ্ঞপ্তি" + _actions: + followBack: "ফলো ব্যাক করেছে" + reply: "জবাব" + renote: "রিনোট" +_deck: + alwaysShowMainColumn: "সর্বদা মেইন কলাম দেখান" + columnAlign: "কলাম সাজান" + addColumn: "কলাম যুক্ত করুন" + configureColumn: "কলাম সেটিংস" + swapLeft: "বামে সরান" + swapRight: "ডানে সরান" + swapUp: "উপরে উঠান" + swapDown: "নিচে নামান" + stackLeft: "বাম কলামে সাজান" + popRight: "ডানদিকে রাখুন" + profile: "প্রোফাইল" + _columns: + main: "প্রধান" + widgets: "উইজেটগুলি" + notifications: "বিজ্ঞপ্তি" + tl: "টাইমলাইন" + antenna: "অ্যান্টেনা" + list: "লিস্ট" + mentions: "উল্লেখসমূহ" + direct: "ডাইরেক্ট নোটগুলি" diff --git a/locales/bul-BG.yml b/locales/bul-BG.yml new file mode 100644 index 0000000..c51d7b6 --- /dev/null +++ b/locales/bul-BG.yml @@ -0,0 +1,823 @@ +username: Потребителско име +password: Парола +ok: Добре +_lang_: Български +forgotPassword: Забравена парола +error: Грешка +search: Търсене +notifications: Известия +fetchingAsApObject: Извличане от Федивселената +gotIt: Разбрах! +cancel: Отказ +noThankYou: Не, благодаря +enterUsername: Въведи потребителско име +noNotes: Няма публикации +noNotifications: Няма известия +instance: Сървър +settings: Настройки +basicSettings: Основни Настройки +otherSettings: Други Настройки +profile: Профил +timeline: Инфопоток +noAccountDescription: Този потребител все още не е написал своята биография. +login: Вписване +loggingIn: Влизане +logout: Отписване +signup: Регистриране +uploading: Качване… +save: Запазване +users: Потребители +addUser: Добавяне на потребител +addInstance: Добавяне на сървър +favorite: Добавяне към отметките +favorites: Отметки +alreadyFavorited: Вече е добавено към отметките. +cantFavorite: Неуспешно добавяне към отметките. +unpin: Откачане от профила +copyLink: Копиране на връзката +delete: Изтриване +deleted: Изтрито +deleteAndEdit: Изтриване и редактиране +edited: Редактирано на {date} {time} +addToList: Добавяне към списък +sendMessage: Изпращане на съобщение +copyUsername: Копиране на потребителското име +searchUser: Търсене на потребител +reply: Отговор +jumpToPrevious: Премини към предишно +loadMore: Зареди още +showMore: Покажи още +newer: по-ново +receiveFollowRequest: Заявка за последване получена +mention: Споменаване +mentions: Споменавания +directNotes: Директни съобщения +cw: Предупреждение за съдържание +importAndExport: Импорт/Експорт на Данни +import: Импортиране +files: Файлове +download: Изтегляне +lists: Списъци +noLists: Нямаш никакви списъци +note: Публикуване +following: Последвани +followers: Последователи +followsYou: Следва те +createList: Създаване на списък +privacy: Поверителност +follow: Последване +followRequest: Заявка за последване +followRequests: Заявки за последване +unfollow: Отследване +enterEmoji: Въведи емоджи +quote: Цитиране +pinnedNote: Закачена публикация +pinned: Закачено в профила +you: Ти +clickToShow: Щракни за показване +add: Добавяне +reaction: Реакции +enterFileName: Въведи име на файл +block: Блокиране +unblock: Отблокиране +emojiName: Име на емоджи +emojiUrl: URL адрес на емоджи +addEmoji: Добавяне +flagSpeakAsCat: Говорене като котка +flagShowTimelineReplies: Показване на отговори в инфопотока +addAccount: Добавяне на акаунт +showOnRemote: Отваряне на оригиналната страница +general: Общи +emoji: Емоджи +emojis: Емоджита +wallpaper: Тапет +setWallpaper: Задаване на тапет +searchWith: 'Търсене: {q}' +youHaveNoLists: Нямаш никакви списъци +host: Хост +selectUser: Избор на потребител +recipient: Получател(и) +instances: Сървъри +registeredAt: Регистриран на +latestRequestSentAt: Последно изпратена заявка +blockThisInstance: Блокиране на този сървър +operations: Операции +version: Версия +metadata: Метаданни +disk: Диск +instanceInfo: Информация за сървъра +statistics: Статистика +noUsers: Няма потребители +noInstances: Няма сървъри +editProfile: Редактиране на профила +done: Готово +preview: Преглед +default: По подразбиране +defaultValueIs: 'По подразбиране: {value}' +federating: Федериране +instanceFollowers: Последователи на сървъра +instanceUsers: Потребители на този сървър +changePassword: Промяна на паролата +security: Сигурност +newPassword: Нова парола +more: Още +featured: Представени +noSuchUser: Потребителят не е намерен +upload: Качване +explore: Разглеждане +start: Започване +activity: Дейност +images: Изображения +birthday: Рожден ден +yearsOld: на {age} години +location: Местоположение +theme: Теми +light: Светла +dark: Тъмна +lightThemes: Светли теми +darkThemes: Тъмни теми +fileName: Име на файл +selectFile: Избор на файл +selectFiles: Избор на файлове +selectFolder: Избор на папка +selectFolders: Избор на папки +renameFile: Преименуване на файла +folderName: Име на папка +createFolder: Създаване на папка +renameFolder: Преименуване на тази папка +addFile: Добавяне на файл +emptyFolder: Тази папка е празна +inputNewFileName: Въведи ново име на файл +inputNewFolderName: Въведи ново име на папка +copyUrl: Копиране на URL адреса +rename: Преименуване +reload: Опресняване +thisYear: Година +thisMonth: Месец +today: Днес +dayX: '{day}' +monthX: '{month}' +yearX: '{year}' +pages: Страници +integration: Интеграции +registration: Регистрация +markAsReadAllUnreadNotes: Маркиране на всички публикации като прочетени +markAsReadAllTalkMessages: Маркиране на всички съобщения като прочетени +help: Помощ +inputMessageHere: Въведи съобщение тук +close: Затваряне +group: Група +groups: Групи +invites: Покани +members: Членове +messagingWithGroup: Групов чат +title: Заглавие +text: Текст +enable: Включване +next: Следващо +retype: Въведи отново +noMessagesYet: Все още няма съобщения +newMessageExists: Има нови съобщения +invitations: Покани +invitationCode: Код на поканата +available: Свободно +tooShort: Твърде кратко +tooLong: Твърде дълго +weakPassword: Слаба парола +normalPassword: Средна парола +strongPassword: Силна парола +signinHistory: История на вписванията +passwordNotMatched: Не съвпада +or: Или +language: Език +aboutX: Относно {x} +unfavorite: Премахване от отметките +favorited: Добавено към отметките. +copyContent: Копиране на съдържанието +monthAndDay: '{day}/{month}' +deleteAndEditConfirm: Сигурен ли си, че искаш да изтриеш тази публикация и да я редактираш? + Ще загубиш всички реакции, подсилвания и отговори към нея. +older: по-старо +followRequestAccepted: Заявка за последване приета +export: Експортиране +notes: Публикации +manageLists: Управление на списъци +retry: Повторен опит +defaultNoteVisibility: Видимост по подразбиране +followRequestPending: Заявка за последване в изчакване +removeReaction: Премахване на реакцията +editWidgetsExit: Готово +flagAsCat: Котка ли си? 😺 +loginFailed: Неуспешно вписване +removeWallpaper: Премахване на тапета +selectInstance: Избор на сървър +latestRequestReceivedAt: Последно получена заявка +stopActivityDelivery: Спиране на изпращането на дейности +software: Софтуер +blockedInstances: Блокирани сървъри +pinLimitExceeded: Не може да закачаш повече публикации +instanceFollowing: Последвани на сървъра +currentPassword: Текуща парола +keepOriginalUploading: Запазване на оригиналното изображение +themeForLightMode: Тема за използване в светъл режим +themeForDarkMode: Тема за използване в тъмен режим +syncDeviceDarkMode: Синхронизиране на тъмния режим с настройките на устройството +deleteFolder: Изтриване на тази папка +hasChildFilesOrFolders: Тъй като тази папка не е празна, тя не може да бъде изтрита. +signinRequired: Моля, регистрирайте се или се впишете, преди да продължите +signinFailed: Неуспешно вписване. Въведените потребителско име или парола са неправилни. +emailRequiredForSignup: Изискване на адрес на ел. поща за регистриране +createGroup: Създаване на група +noteOf: Публикация от {user} +unavailable: Не е свободно +passwordMatched: Съвпада +unmarkAsSensitive: Отмаркиране като деликатно +introIceshrimp: Добре дошли! Iceshrimp е децентрализирана социална медийна платформа + с отворен код, която е безплатна завинаги! 🚀 +headlineIceshrimp: Децентрализирана социална медийна платформа с отворен код, която + е безплатна завинаги! 🚀 +searchPlaceholder: Търсене във Федивселената +pin: Закачане в профила +youGotNewFollower: те последва +showLess: Покажи по-малко +markAsSensitive: Маркиране като деликатно +clearCachedFiles: Изчистване на кеша +inMb: В мегабайти +pinnedUsers: Закачени потребители +pinnedNotes: Закачени публикации +hcaptcha: hCaptcha +enableHcaptcha: Включване на hCaptcha +recaptcha: reCAPTCHA +enableRecaptcha: Включване на reCAPTCHA +license: Лиценз +_theme: + explore: Разглеждане на темите + description: Описание + color: Цвят + key: Ключ + func: Функции + argument: Аргумент + defaultValue: Стойност по подразбиране + installedThemes: Инсталирани теми + keys: + link: Връзка + hashtag: Хаштаг + mention: Споменаване + fg: Текст + renote: Подсилване + manage: Управление на темите + install: Инсталиране на тема + code: Код на темата + builtinThemes: Вградени теми + constant: Константа + addConstant: Добавяне на константа +_time: + second: Секунди + minute: Минути + hour: Часа + day: Дни +_ago: + future: Бъдеще + justNow: Току-що + secondsAgo: преди {n}сек + minutesAgo: "преди {n}мин {n2}сек" + hoursAgo: "преди {n}ч {n2}мин" + daysAgo: "преди {n}д {n2}ч" + weeksAgo: "преди {n}се {n2}д" + monthsAgo: "преди {n}мес {n2}сед" + yearsAgo: "преди {n}г {n2}мес" +_tutorial: + step1_1: Добре дошли! + step1_2: Нека да ви настроим. Ще бъдете готови за нула време! + step3_1: Сега е време да последвате няколко хора! + title: Как се използва Iceshrimp + step2_1: Първо, моля, попълнете своя профил. + step2_2: Предоставянето на известна информация за това кой сте вие ще улесни другите + да разберат дали искат да видят вашите публикации или да ви следват. + step4_2: За първата си публикация някои хора обичат да правят публикация {introduction} + или просто „Здравей свят!“ + step5_2: Вашият сървър има активирани {timelines} различни инфопотоци. + step5_3: Началният {icon} инфопоток е мястото, където можеш да видиш публикации + от акаунтите, които следваш. + step5_4: Местният {icon} инфопоток е мястото, където можеш да видиш публикации от + всички останали на този сървър. + step5_5: Социалният {icon} инфопоток е комбинация от Началния и Местния инфопоток. + step5_6: Препоръчаният {icon} инфопоток е мястото, където можеш да видиш публикации + от сървъри, препоръчани от администраторите. + step6_1: И така, какво е това място? + step6_4: Сега отидете, изследвайте и се забавлявайте! + step6_3: Всеки сървър работи по различни начини и не всички сървъри работят с Iceshrimp. + Този обаче го прави! Малко е сложно, но ще разберете за нула време. + step5_7: Глобалният {icon} инфопоток е мястото, където можете да видиш публикации + от всеки друг свързан сървър. +_filters: + fromUser: От потребител + notesBefore: Публикации преди + notesAfter: Публикации след + followingOnly: Само последвани + followersOnly: Само последователи + _dialog: + word: дума +_permissions: + "write:favorites": Редактирай списъка си с отметки + "read:favorites": Виж списъка си с отметки +_2fa: + renewTOTPCancel: Отказ +_widgets: + timeline: Инфопоток + calendar: Календар + digitalClock: Дигитален часовник + clock: Часовник + notifications: Известия + button: Бутон + photos: Снимки + unixClock: UNIX часовник + activity: Дейност + slideshow: Слайдшоу + trends: Актуални + _userList: + chooseList: Избор на списък +_profile: + description: Биография + metadata: Допълнителна информация + metadataContent: Съдържание + name: Име + username: Потребителско име + metadataEdit: Редактиране на допълнителната информация + changeAvatar: Промяна на профилната снимка + metadataLabel: Етикет +_timelines: + recommended: Препоръчани + local: Местни + social: Социални + global: Глобални + home: Начало +_pages: + blocks: + _numberInput: + default: Стойност по подразбиране + text: Заглавие + _switch: + default: Стойност по подразбиране + text: Заглавие + _radioButton: + default: Стойност по подразбиране + title: Заглавие + _textInput: + default: Стойност по подразбиране + text: Заглавие + _textareaInput: + default: Стойност по подразбиране + text: Заглавие + button: Бутон + if: Ако + image: Изображения + text: Текст + _post: + text: Съдържание + _button: + _action: + _dialog: + content: Съдържание + text: Заглавие + _counter: + text: Заглавие + inc: Стъпка + _if: + variable: Променлива + script: + categories: + list: Списъци + value: Стойности + fn: Функции + blocks: + _join: + arg1: Списъци + add: Добави + _dailyRannum: + arg1: Минимална стойност + arg2: Максимална стойност + _seedRannum: + arg2: Минимална стойност + arg3: Максимална стойност + _rannum: + arg1: Минимална стойност + arg2: Максимална стойност + text: Текст + _strLen: + arg1: Текст + _strPick: + arg1: Текст + _strReplace: + arg1: Текст + _strReverse: + arg1: Текст + _if: + arg2: Тогава + arg3: Иначе + arg1: Ако + _stringToNumber: + arg1: Текст + _splitStrByLine: + arg1: Текст + ref: Променлива + fn: Функция + _listLen: + arg1: Списък + _dailyRandomPick: + arg1: Списък + _seedRandomPick: + arg2: Списък + _pick: + arg1: Списък + _randomPick: + arg1: Списък + types: + string: Текст + array: Списък + contentBlocks: Съдържание + created: Страницата е създадена успешно + deleted: Страницата е изтрита успешно + newPage: Създаване на нова страница + editPage: Редактиране на тази страница + featured: Популярни + like: Харесване + contents: Съдържание + font: Шрифт + title: Заглавие + liked: Харесани страници + my: Моите страници + pageSetting: Настройки на страницата + editThisPage: Редактиране на тази страница + updated: Страницата е редактирана успешно +aboutIceshrimp: Относно Iceshrimp +token: Токен +moderator: Модератор +moderation: Модерация +userList: Списъци +_deck: + _columns: + tl: Инфопоток + direct: Директни съобщения + notifications: Известия + antenna: Антена + list: Списък + addColumn: Добавяне на колона +createNewClip: Създай нова подборка +unclip: Премахни от подборка +repliesCount: Брой изпратени отговори +repliedCount: Брой получени отговори +followersCount: Брой последователи +sentReactionsCount: Брой изпратени реакции +receivedReactionsCount: Брой получени реакции +value: Стойност +currentVersion: Настояща версия +latestVersion: Най-нова версия +createdAt: Създадено на +clips: Подборки +translate: Превеждане +translatedFrom: Преведено от {x} +whatIsNew: Показване на промените +searchResult: Резултати от търсенето +_aboutIceshrimp: + translation: Преводи + contributors: Основни сътрудници + source: Iceshrimp разработка + changelog: Дневник на промените + documentation: Документация + chatroom: Чат стая + allContributors: Всички сътрудници +pinnedPages: Закачени страници +about: Относно +administrator: Администратор +invalidValue: Невалидна стойност. +_notification: + _types: + follow: Нови последователи + reaction: Реакции + renote: Подсилвания + quote: Цитирания + pollVote: Гласувания в анкети + receiveFollowRequest: Получени заявки за следване + reply: Отговора + mention: Споменавания + followRequestAccepted: Приети заявки за следване + all: Всички + pollEnded: Приключване на анкети + groupInvited: Покани в групи + app: Известия от свързани приложения + youGotQuote: '{name} те цитира' + youGotReply: '{name} ти отговори' + youGotMention: '{name} те спомена' + youGotMessagingMessageFromUser: '{name} ти изпрати чат съобщение' + youWereFollowed: те последва + _actions: + renote: Подсилвания + reply: Отговор + fileUploaded: Файлът е качен успешно +apps: Приложения +remindMeLater: Може би по-късно +clip: Подборка +notesCount: Брой публикации +followingCount: Брой последвани акаунти +create: Създаване +oneHour: Един час +oneDay: Един ден +oneWeek: Една седмица +tenMinutes: 10 минути +numberOfColumn: Брой колони +video: Видео +file: Файл +image: Изображение +audio: Звук +failedToUpload: Неуспешно качване +openInWindow: Отваряне в прозорец +renotedBy: Подсилено от {user} +unfollowConfirm: Сигурен ли си, че искаш да спреш да следваш {name}? +somethingHappened: Възникна грешка +pageLoadError: Възникна грешка при зареждането на страницата. +enterListName: Въведи име за списъка +_mfm: + link: Връзка + hashtag: Хаштаг + url: URL адрес + mention: Споменаване + font: Шрифт +share: Споделяне +openInNewTab: Отваряне в нов раздел +showInPage: Показване в страницата +shareWithNote: Споделяне чрез публикация +flagAsBot: Маркиране на този акаунт като бот 🤖 +nsfw: Деликатно +hideFromHome: Скриване от началния инфопоток +expandAllCws: Показване на съдържанието за всички отговори +collapseAllCws: Скриване на съдържанието за всички отговори +renoted: Подсилено. +renoteMute: Заглушаване на подсилванията +sensitive: Деликатно +mute: Заглушаване +attachCancel: Премахване на прикачен файл +renote: Подсилване +selectAntenna: Избор на антена +selectList: Избор на списък +selectChannel: Избор на канал +settingGuide: Препоръчителни настройки +annotation: Коментари +all: Всичко +muteAndBlock: Заглушени и блокирани +blockedUsers: Блокирани потребители +mutedUsers: Заглушени потребители +noteDeleteConfirm: Сигурен ли си, че искаш да изтриеш тази публикация? +hiddenTags: Скрити хаштагове +home: Начало +newPasswordRetype: Повтори новата парола +remove: Изтриване +removed: Успешно изтриване +deleteAreYouSure: Сигурен ли си, че искаш да изтриеш "{x}"? +saved: Запазени +startMessaging: Започване на нов чат +uploadFromUrl: Качване от URL адрес +imageUrl: URL адрес на изображение +fromUrl: От URL адрес +messaging: Чат +avatar: Профилна снимка +instanceName: Име на сървъра +instanceDescription: Описание на сървъра +accept: Приемане +name: Име +antennas: Антени +enableLocalTimeline: Включване на местния инфопоток +enableGlobalTimeline: Включване на глобалния инфопоток +findOtherInstance: Намиране на друг сървър +removeMember: Премахване на член +isAdmin: Администратор +isModerator: Модератор +_preferencesBackups: + save: Запазване на промените + loadFile: Зареждане от файл + list: Създадени резервни копия + cannotSave: Неуспешно запазване + apply: Прилагане към това устройство + saveConfirm: Запазване на резервното копие като {name}? + createdAt: 'Създадено на: {date} {time}' + updatedAt: 'Обновено на: {date} {time}' + cannotLoad: Неуспешно зареждане + saveNew: Запазване на ново резервно копие + inputName: Моля, въведи име за това резервно копие + deleteConfirm: Изтриване на резервното копие {name}? + delete: Изтриване на резервното копие +_registry: + createKey: Създаване на ключ + domain: Домейн + keys: Ключове + key: Ключ +_menuDisplay: + hide: Скриване +_channel: + nameOnly: Само име + notesCount: '{n} Публикации' + nameAndDescription: Име и описание + create: Създаване на канал +_messaging: + groups: Групи + dms: Лични +_sfx: + antenna: Антени + chat: Чат + note: Нова публикация + notification: Известия +_weekday: + sunday: Неделя + monday: Понеделник + tuesday: Вторник + wednesday: Сряда + thursday: Четвъртък + friday: Петък + saturday: Събота +_antennaSources: + all: Всички публикации + homeTimeline: Публикации от последвани потребители + users: Публикации от конкретни потребители + userList: Публикации от конкретен списък с потребители + userGroup: Публикации от потребители в конкретна група + instances: Публикации от всички потребители на сървър +_visibility: + localOnly: Само местни + public: Публична + publicDescription: Публикацията ще бъде видима във всички публични инфопотоци + home: Скрита + specified: Директна + localOnlyDescription: Не е видима за отдалечени потребители + specifiedDescription: Видима само за определени потребители + followersDescription: Видима само за последователите ти и споменатите потребители + followers: Последователи + homeDescription: Публикуване само в началния инфопоток +_exportOrImport: + allNotes: Всички публикации +exploreFediverse: Разглеждане на Федивселената +notesAndReplies: Публикации и отговори +popularUsers: Популярни потребители +recentlyUpdatedUsers: Последно активни потребители +newPasswordIs: Новата парола е "{password}" +cacheClear: Изчистване на кеша +notFound: Не е намерено +popularTags: Популярни тагове +local: Местни +total: Общо +remote: Отдалечени +signinWith: Вписване чрез {x} +uiLanguage: Език на потребителския интерфейс +accountSettings: Настройки на акаунта +dashboard: Табло +openImageInNewTab: Отваряне на изображенията в нов раздел +youHaveNoGroups: Нямаш групи +existingAccount: Съществуващ акаунт +createAccount: Създаване на акаунт +category: Категория +tags: Тагове +accessibility: Достъпност +details: Подробности +install: Инсталиране +email: Ел. поща +emailAddress: Адрес на ел. поща +addItem: Добавяне на елемент +menu: Меню +visibility: Видимост +smtpHost: Хост +smtpUser: Потребителско име +invisibleNote: Невидима публикация +deletedNote: Изтрита публикация +description: Описание +preferencesBackups: Резервни копия +poll: Анкета +_relayStatus: + accepted: Прието +_feeds: + rss: RSS + atom: Atom + jsonFeed: JSON feed + copyFeed: Копиране на емисия +overview: Обзор +other: Други +channel: Канали +renotedCount: Брой получени подсилвания +no: Не +yes: Да +accountInfo: Информация за акаунта +send: Изпращане +renotesCount: Брой изпратени подсилвания +clearCache: Изчистване на кеша +closeAccount: Затваряне на акаунта +saveConfirm: Запазване на промените? +onlineUsersCount: '{n} потребители на линия' +nNotes: '{n} Публикации' +nUsers: '{n} Потребители' +developer: Разработчик +gallery: Галерия +popularPosts: Популярни страници +recentPosts: Последни страници +info: Относно +user: Потребител +offline: Извън линия +online: На линия +onlineStatus: Онлайн състояние +addDescription: Добавяне на описание +goBack: Назад +editCode: Редактиране на кода +publish: Публикувай +manageAccounts: Управление на акаунти +breakFollow: Премахване на последовател +learnMore: Научи повече +hashtags: Хаштагове +priority: Приоритет +hide: Скриване +document: Документация +saveAs: Запазване като… +copy: Копиране +emailServer: Ел. пощенски сървър +uninstall: Деинсталиране +author: Автор +smtpPass: Парола +accounts: Акаунти +userInfo: Информация за потребителя +isBot: Този акаунт е бот +removeAreYouSure: Сигурен ли си, че искаш да премахнеш "{x}"? +searchByGoogle: Търсене +size: Размер +tablet: Таблет +smartphone: Смартфон +deleteAccount: Изтриване на акаунта +numberOfPageCache: Брой кеширани страници +localOnly: Само местни +remoteOnly: Само отдалечени +beta: Бета +fast: Бърза +slow: Бавна +speed: Скорост +account: Акаунт +move: Преместване +migration: Прехвърляне +moveTo: Преместване на текущия акаунт в нов акаунт +moveToLabel: 'Акаунт, към който се местиш:' +moveAccount: Преместване на акаунта! +_gallery: + like: Харесване + liked: Харесани публикации + my: Моята галерия +withFiles: С прикачени файлове +unmute: Отмяна на заглушаването +renoteUnmute: Отмяна на заглушаването на подсилванията +unrenote: Отмяна на подсилването +flagAsCatDescription: Ще получиш котешки уши и ще говориш като котка! +editWidgets: Редактиране на джаджите +selectWidget: Избор на джаджа +customEmojis: Персонализирани Емоджи +perHour: За час +perDay: За ден +announcements: Оповестявания +manageGroups: Управление на групи +remoteUserCaution: Информацията от отдалечени потребители може да е непълна. +registeredDate: Присъединяване +nUsersRead: прочетено от {n} +attachFile: Прикачване на файлове +watch: Наблюдаване +unwatch: Спиране на наблюдаването +invite: Поканване +manageAntennas: Управление на антени +searchEmptyQuery: Моля, въведете термин за търсене. +_wordMute: + mutedNotes: Заглушени публикации +_poll: + totalVotes: '{n} гласа общо' + votesCount: '{n} гласа' + choiceN: Избор {n} + expiration: Приключване на анкетата + infinite: Никога +_postForm: + _placeholders: + b: Какво се случва около теб? +recentlyDiscoveredUsers: Новооткрити потребители +recentlyRegisteredUsers: Новоприсъединени потребители +inviteToGroup: Поканване в група +groupName: Име на групата +nothing: Няма нищо за гледане тук +chooseEmoji: Избор на емоджи +deleteAll: Изтриване на всички +newNoteRecived: Има нови публикации +useCw: Скриване на съдържание +abuseReports: Доклади +reportAbuse: Докладване +reportAbuseOf: Докладване на {name} +switchUi: Превключване на оформление +reloadToApplySetting: Тази настройка ще се приложи само след презареждане на страницата. + Презареждане сега? +apply: Прилагане +selectAccount: Избор на акаунт +markAllAsRead: Маркиране на всички като прочетени +switchAccount: Превключване на акаунт +unread: Непрочетени +filter: Филтриране +previewNoteText: Показване на преглед +muteThread: Заглушаване на нишката +ffVisibility: Видимост на Последвани/Последователи +navbar: Навигационна лента diff --git a/locales/ca-ES.yml b/locales/ca-ES.yml new file mode 100644 index 0000000..bfe498b --- /dev/null +++ b/locales/ca-ES.yml @@ -0,0 +1,2192 @@ +_lang_: "Català" +headlineIceshrimp: "Una xarxa social de codi obert, descentralitzada i gratuïta per + a sempre! 🚀" +introIceshrimp: "Benvinguts! Iceshrimp és una plataforma social de codi obert, descentralitzada + i gratuïta per a sempre! 🚀" +monthAndDay: "{day}/{month}" +search: "Cerca" +notifications: "Notificacions" +username: "Nom d'usuari" +password: "Contrasenya" +forgotPassword: "Contrasenya oblidada" +fetchingAsApObject: "Cercant en el Fediverse" +ok: "D'acord" +gotIt: "Ho he entès!" +cancel: "Cancel·la" +enterUsername: "Introdueix el teu nom d'usuari" +renotedBy: "Impulsat per {user}" +noNotes: "Cap publicació" +noNotifications: "Cap notificació" +instance: "Servidor" +settings: "Preferències" +basicSettings: "Configuració bàsica" +otherSettings: "Altres opcions" +openInWindow: "Obre en una finestra nova" +profile: "Perfil" +timeline: "Línia de temps" +noAccountDescription: "Aquest usuari encara no ha escrit la seva biografia." +login: "Inicia sessió" +loggingIn: "Iniciant sessió" +logout: "Tanca la sessió" +signup: "Registra'm" +uploading: "Carregant…" +save: "Desa" +users: "Usuaris" +addUser: "Afegeix un usuari" +favorite: "Afegeix als marcadors" +favorites: "Marcadors" +unfavorite: "Elimina dels marcadors" +favorited: "S'ha afegit el marcador." +alreadyFavorited: "Ja està afegida als marcadors." +cantFavorite: "No s'ha pogut afegir als marcadors." +pin: "Fixa al perfil" +unpin: "Deixa de fixar al perfil" +copyContent: "Copia el contingut" +copyLink: "Copia l'enllaç" +delete: "Elimina" +deleteAndEdit: "Elimina i edita" +deleteAndEditConfirm: "Segur que vols eliminar la publicació i editar-la? Perdràs + totes les reaccions, impulsos i respostes." +addToList: "Afegeix a la llista" +sendMessage: "Envia un missatge" +copyUsername: "Copia el nom d'usuari" +searchUser: "Cerca un usuari" +reply: "Respon" +loadMore: "Carrega'n més" +showMore: "Mostra'n més" +youGotNewFollower: "t'ha seguit" +receiveFollowRequest: "Sol·licitud de seguiment rebuda" +followRequestAccepted: "Sol·licitud de seguiment acceptada" +mention: "Menció" +mentions: "Mencions" +directNotes: "Missatges directes" +importAndExport: "Importa/exporta dades" +import: "Importa" +export: "Exporta" +files: "Fitxers" +download: "Baixa" +driveFileDeleteConfirm: "Segur que vols eliminar el fitxer «{name}»? S'eliminarà de + totes les notes que el continguin com a fitxer adjunt." +unfollowConfirm: "Segur que vols deixar de seguir a {name}?" +exportRequested: "Has sol·licitat una exportació. Això pot trigar una estona. S'afegirà + al teu Disc un cop completada." +importRequested: "Has sol·licitat una importació. Això pot trigar una estona." +lists: "Llistes" +noLists: "No teniu cap llista" +note: "Publicació" +notes: "Publicacions" +following: "Seguint" +followers: "Seguidors" +followsYou: "Et segueix" +createList: "Crea una llista" +manageLists: "Gestiona les llistes" +error: "Error" +somethingHappened: "S'ha produït un error" +retry: "Torna-ho a intentar" +pageLoadError: "S'ha produït un error en carregar la pàgina." +pageLoadErrorDescription: "Això normalment es deu a errors de xarxa o a la memòria + cau del navegador. Prova d'esborrar la memòria cau o espera una estona abans de + recarregar la pàgina." +serverIsDead: "Aquest servidor no respon. Espera una estona i torna-ho a provar." +youShouldUpgradeClient: "Per veure aquesta pàgina, actualitzeu-la per actualitzar + el vostre client." +enterListName: "Introdueix un nom per a la llista" +privacy: "Privadesa" +makeFollowManuallyApprove: "Les sol·licituds de seguiment requereixen aprovació" +defaultNoteVisibility: "Visibilitat per defecte" +follow: "Segueix" +followRequest: "Sol·licitud de Seguiment" +followRequests: "Sol·licituds de seguiment" +unfollow: "Deixa de seguir" +followRequestPending: "Sol·licituds de seguiment pendents" +enterEmoji: "Introdueix un emoji" +renote: "Impulsa" +unrenote: "Anul·la l'impuls" +renoted: "S'ha impulsat." +cantRenote: "Aquesta publicació no es pot impulsar." +cantReRenote: "No es pot impulsar un impuls." +quote: "Cita" +pinnedNote: "Publicació fixada" +pinned: "Fixa al perfil" +you: "Tu" +clickToShow: "Fes clic per a mostrar" +sensitive: "Sensible" +add: "Afegeix" +reaction: "Reaccions" +reactionSetting: "Reaccions a mostrar al selector de reaccions" +reactionSettingDescription2: "Arrossega per reordenar, fes clic per suprimir, prem + \"+\" per afegir." +rememberNoteVisibility: "Recorda la configuració de visibilitat de les notes" +attachCancel: "Elimina el fitxer adjunt" +markAsSensitive: "Marca com a sensible" +unmarkAsSensitive: "Desmarca com a sensible" +enterFileName: "Introdueix un nom de fitxer" +mute: "Silencia" +unmute: "Deixa de silenciar" +block: "Bloqueja" +unblock: "Desbloqueja" +suspend: "Suspèn" +unsuspend: "Treu la suspensió" +instances: "Servidors" +remove: "Elimina" +nsfw: "Sensible" +pinnedNotes: "Publicacions fixades" +userList: "Llistes" +smtpUser: "Nom d'usuari" +smtpPass: "Contrasenya" +user: "Usuari" +searchByGoogle: "Cercar" +file: "Fitxer" +_email: + _follow: + title: "Tens un nou seguidor" + _receiveFollowRequest: + title: Heu rebut una sol·licitud de seguiment +_mfm: + mention: "Menció" + quote: "Citar" + search: "Cercar" + dummy: Iceshrimp amplia el món del Fediverse + hashtag: Etiqueta + intro: MFM és un llenguatge de marques utilitzat a Iceshrimp, Misskey, Akkoma i + més que es pot utilitzar en publicacions i xats. Aquí podeu veure una llista de + tota la sintaxi MFM disponible. + hashtagDescription: Podeu especificar una etiqueta mitjançant un coixinet i un text. + url: URL + urlDescription: Es poden mostrar URLS. + link: Enllaç + linkDescription: Parts específiques del text es poden mostrar com a URL. + bold: Negreta + boldDescription: Ressalta les lletres fent-les més gruixudes. + smallDescription: Mostra contingut petit i prim. + small: Petit + centerDescription: Mostra el contingut centrat. + inlineCode: Codi (en línia) + inlineMathDescription: Mostra fórmules matemàtiques (KaTeX) en línia + blockCode: Codi (Bloc) + blockCodeDescription: Mostra el ressaltat de sintaxi per al codi de diverses línies + (programa) en un bloc. + inlineMath: Matemàtiques (en línia) + jellyDescription: Dóna al contingut una animació semblant a una gelatina. + bounceDescription: Ofereix al contingut una animació de rebot. + jumpDescription: Dóna al contingut una animació de salt. + shake: Animació (Shake) + shakeDescription: Dóna al contingut una animació tremolosa. + bounce: Animació (Bounce) + x3Description: Mostra contingut encara més gran. + x2Description: Mostra contingut més gran. + twitchDescription: Ofereix al contingut una animació fortament convulsa. + spin: Animació (Spin) + spinDescription: Dóna al contingut una animació giratòria. + x2: Gran + x3: Molt gran + x4: Increïblement gran + blur: Desenfocament + x4Description: Mostra contingut fins i tot més gran que gran que gran. + rainbowDescription: Fa que el contingut aparegui en colors de l'arc de Sant Martí. + sparkle: Brillantor + sparkleDescription: Dóna al contingut un efecte de partícula brillant. + rotate: Girar + rotateDescription: Gira el contingut en un angle especificat. + positionDescription: Mou el contingut en una quantitat especificada. + fontDescription: Estableix el tipus de lletra en què voleu mostrar el contingut. + position: Posició + rainbow: Arc de Sant Martí + jelly: Animació (Jelly) + tada: Animació (Tada) + tadaDescription: Dóna al contingut una animació tipus "Tada!". + jump: Animació (Jump) + twitch: Animació (Twitch) + blurDescription: Desenfoca el contingut. Es mostrarà clarament quan passeu el cursor + per sobre. + font: Tipus de lletra + cheatSheet: Full de trucs de MFM + mentionDescription: Podeu especificar un usuari mitjançant un arrova i un nom d'usuari. + center: Centre + inlineCodeDescription: Mostra el ressaltat de sintaxi en línia per al codi (de programa). + blockMath: Matemàtiques (Bloc) + blockMathDescription: Mostra fórmules matemàtiques (KaTeX) en un bloc + quoteDescription: Mostra el contingut com una cita. + emoji: Emoji personalitzat + emojiDescription: Un emoji personalitzat és pot mostrar envoltant el nom amb dos + punts. + searchDescription: Mostra un quadre de cerca amb el text introduït prèviament. + flip: Capgirar + flipDescription: Capgira el contingut horitzontalment o verticalment. + plainDescription: Desactiva els efectes de tots els MFM continguts en aquest efecte + MFM. + scale: Escala + foreground: Color de primer pla + background: Color de fons + backgroundDescription: Canvia el color de fons del text. + scaleDescription: Escala el contingut en una quantitat especificada. + foregroundDescription: Canvia el color de primer pla del text. + plain: Pla + stop: Parar MFM + play: Posar en marxa MFM + warn: MFM pot contenir animacions cridaneres o que es mouen ràpidament + alwaysPlay: Reprodueix automàticament tots els MFM animats + fade: Esvair + fadeDescription: Esvaeix el contingut cap a dintre i cap en fora. + crop: Retallar + advanced: MFM avançat + advancedDescription: Si està desactivat, només permet l'etiquetatge bàsic tret que + es reproduïnt un MFM animat + cropDescription: Retalla el contingut. +_theme: + keys: + mention: "Menció" + renote: "Impulsar" + fg: Text + navBg: Fons de la barra lateral + navFg: Text de la barra lateral + navHoverFg: Text de la barra lateral (Hover) + hashtag: Etiquetes + mentionMe: Mencions (Jo) + infoBg: Fons de l'informació + infoFg: Text informatiu + toastBg: Fons de notificació + listItemHoverBg: Fons de la llista d'elements (Hover) + driveFolderBg: Fons de la carpeta Disc + wallpaperOverlay: Superposició de fons de pantalla + badge: Distintiu + accentLighten: Accent (Lluminós) + accentDarken: Accent (enfosquit) + fgHighlighted: Text ressaltat + indicator: Indicador + focus: Centrar-se + panel: Panell + navIndicator: Indicador de la barra lateral + accent: Accent + header: Encapçalament + navActive: Text de la barra lateral (Active) + link: Enllaç + modalBg: Fons del modal + divider: Divisor + scrollbarHandle: Mànec de la barra de desplaçament + scrollbarHandleHover: Mànec de la barra de desplaçament (Hover) + dateLabelFg: Text de l'etiqueta de data + infoWarnBg: Fons d'advertència + cwBg: Fons del botó CW + cwFg: Text del botó CW + messageBg: Fons del xat + infoWarnFg: Text d'advertència + bg: Fons + shadow: Ombra + cwHoverBg: Fons del botó CW (Hover) + toastFg: Text de notificació + buttonHoverBg: Fons del botó (Hover) + inputBorder: Vora del camp d'entrada + buttonBg: Fons del botó + description: Descripció + installed: "{name} s'ha instal·lat" + installedThemes: Temes instal·lats + builtinThemes: Temes integrats + alreadyInstalled: Aquest tema ja està instal·lat + invalid: El format d'aquest tema no és vàlid + make: Fes un tema + defaultValue: 'Per defecte: {value}' + color: Color + refProp: Fes referència a una propietat + refConst: Fes referència a una constant + key: Clau + func: Funcions + funcKind: Tipus de funció + argument: Argument + basedProp: Propietat de referència + importInfo: Si introdueixes el codi de tema aquí, podeu importar-lo a l'editor de + temes + inputConstantName: Introdueix un nom per a aquesta constant + addConstant: Afegir una constant + code: Codi del tema + alpha: Opacitat + deleteConstantConfirm: De debò vols esborrar la constant {const}? + manage: Gestionar temes + explore: Explora Temes + darken: Enfosquir + base: Fundament + constant: Constant + lighten: Clar + install: Instal·lar un tema +_sfx: + note: "Publicació nova" + notification: "Notificacions" + antenna: Antenes + channel: Notificacions del canal + noteMy: Publicació propia + chat: Xat + chatBg: Fons del xat +_2fa: + step2Url: "També pots inserir aquest enllaç i utilitzes una aplicació d'escriptori:" + alreadyRegistered: Ja heu registrat un dispositiu d'autenticació de dos factors. + registerTOTP: Registrar un dispositiu nou + securityKeyInfo: A més de l'autenticació d'empremta digital o PIN, també podeu configurar + l'autenticació mitjançant claus de seguretat de maquinari compatibles amb FIDO2 + per protegir encara més el vostre compte. + step4: A partir d'ara, qualsevol intent d'inici de sessió futur demanarà aquest + token d'inici de sessió. + registerSecurityKey: Registrar una clau de seguretat o d'accés + step1: En primer lloc, instal·la una aplicació d'autenticació (com ara {a} o {b}) + al dispositiu. + step2: A continuació, escaneja el codi QR que es mostra en aquesta pantalla. + step3: Introdueix el token que t'ha proporcionat l'aplicació per finalitzar la configuració. + step3Title: Introduïu un codi d'autenticació + chromePasskeyNotSupported: Les claus de pas de Chrome actualment no s'admeten. + securityKeyName: Introduïu un nom de clau + removeKey: Suprimeix la clau de seguretat + removeKeyConfirm: Vols suprimir la clau {name}? + renewTOTP: Tornar a configurar l'aplicació d'autenticació + renewTOTPOk: Reconfigurar + renewTOTPCancel: Cancel·lar + step2Click: Fer clic en aquest codi QR us permetrà registrar 2FA a la vostra clau + de seguretat o aplicació d'autenticació del telèfon. + securityKeyNotSupported: El vostre navegador no admet claus de seguretat. + registerTOTPBeforeKey: Configureu una aplicació d'autenticació per registrar una + clau de seguretat o de passi. + tapSecurityKey: Si us plau, seguiu el vostre navegador per registrar la clau de + seguretat o d'accés + renewTOTPConfirm: Això farà que els codis de verificació de l'aplicació anterior + deixin de funcionar + whyTOTPOnlyRenew: L’aplicació d’autenticació no es pot eliminar sempre que es hi + hagi una clau de seguretat registrada. + token: Token 2FA +_widgets: + notifications: "Notificacions" + timeline: "Línia de temps" + unixClock: Rellotge d'UNIX + federation: Federació + trends: Tendència + clock: Rellotge + calendar: Calendari + activity: Activitat + photos: Fotos + rssTicker: Teletip RSS + onlineUsers: Usuaris en línia + memo: Notes adhesives + digitalClock: Rellotge digital + postForm: Formulari per publicar + slideshow: Presentació de diapositives + serverMetric: Estadístiques del servidor + userList: Llista d'usuaris + rss: Lector d'RSS + jobQueue: Cua de treball + _userList: + chooseList: Selecciona una llista + aiscript: Consola AiScript + button: Botó + serverInfo: Informació del servidor + meiliStatus: Estat del servidor + meiliSize: Mida de l'índex + meiliIndexCount: Publicacions indexades +_cw: + show: "Carregar més" + files: '{count} fitxers' + hide: Amaga + chars: '{count} caràcters' +_visibility: + followers: "Seguidors" + publicDescription: La teva publicació serà visible per a totes les línies de temps + públiques + localOnly: Només Local + specified: Directe + home: Sense llistar + homeDescription: Publica només a la línea de temps local + followersDescription: Fes visible només per als teus seguidors i usuaris mencionats + specifiedDescription: Fer visible només per a usuaris determinats + public: Públic + localOnlyDescription: No és visible per als usuaris remots +_profile: + username: "Nom d'usuari" + metadataEdit: Editar informació addicional + youCanIncludeHashtags: També pots incloure etiquetes al teu perfil. + metadata: Informació adicional + description: Perfil + metadataLabel: Etiqueta + metadataContent: Contingut + changeAvatar: Canvia l'avatar + changeBanner: Canvia el banner + locationDescription: Si primer introduïu la vostra ciutat, es mostrarà l'hora local + a altres usuaris. + name: Nom + metadataDescription: "Fent servir això, podràs mostrar camps d'informació addicionals + al vostre perfil. Podeu afegir una etiqueta {a} o una etiqueta {l} amb {rel} per + verificar l'enllaç al vostre perfil!" +_exportOrImport: + followingList: "Usuaris que segueixes" + muteList: "Silencia" + blockingList: "Bloqueja" + userLists: "Llistes" + excludeMutingUsers: Exclou els usuaris silenciats + allNotes: Totes les notes + excludeInactiveUsers: Exclou usuaris inactius +_pages: + script: + categories: + list: "Llistes" + flow: Control de flux + random: Aleatori + value: Valors + fn: Funcions + text: Operacions de text + convert: Transformacions + logical: Operació lògica + operation: Càlcul + comparison: Comparació + blocks: + _join: + arg1: "Llistes" + arg2: Separador + _randomPick: + arg1: "Llistes" + _dailyRandomPick: + arg1: "Llistes" + _seedRandomPick: + arg2: "Llistes" + arg1: Llavor + _pick: + arg1: "Llistes" + arg2: Posició + _listLen: + arg1: "Llistes" + add: Afegir + _subtract: + arg1: A + arg2: B + subtract: Restar + _round: + arg1: Número + eq: A i B són iguals + _mod: + arg2: B + arg1: A + round: Arrodoniment decimal + _and: + arg1: A + arg2: B + or: A O B + _or: + arg1: A + arg2: B + lt: < A és menor que B + _lt: + arg1: A + arg2: B + gt: '> A és més gran que B' + _gt: + arg1: A + arg2: B + seedRannum: Nombre aleatori (amb llavor) + _seedRannum: + arg1: Llavor + arg2: Valor mínim + arg3: Valor màxim + _eq: + arg1: A + arg2: B + ltEq: <= A és menor o igual que B + _multiply: + arg2: B + arg1: A + divide: Dividir + notEq: A i B són diferents + _notEq: + arg1: A + arg2: B + and: A I B + _ltEq: + arg2: B + arg1: A + gtEq: '>= A és més gran o igual que B' + _gtEq: + arg1: A + arg2: B + if: Branca + _if: + arg1: Si + arg2: Aleshores + arg3: Altrament + not: NO + random: Aleatori + _dailyRandom: + arg1: Probabilitat + dailyRannum: Nombre aleatori (canvia un cop al dia per a cada usuari) + _add: + arg1: A + arg2: B + _divide: + arg1: A + arg2: B + mod: Resta + _not: + arg1: NO + _random: + arg1: Probabilitat + rannum: Nombre aleatori + _rannum: + arg1: Valor mínim + arg2: Valor màxim + randomPick: Tria aleatòriament de la llista + dailyRandom: Aleatori (canvia un cop al dia per a cada usuari) + _dailyRannum: + arg2: Valor màxim + arg1: Valor mínim + dailyRandomPick: Tria aleatòriament d'una llista (Canvis un cop al dia per a + cada usuari) + seedRandom: Aleatori (amb llavor) + _seedRandom: + arg1: Llavor + arg2: Probabilitat + seedRandomPick: Tria aleatòriament de la llista (amb llavor) + multiply: Multiplicar + text: Text + _strPick: + arg1: Text + arg2: Ubicació de la cadena + strPick: Extreure cadena + strReplace: Cadena de substitució + _strReplace: + arg1: Text + arg3: Substitueix per + arg2: Text a substituir + strReverse: Voltejar text + _strReverse: + arg1: Text + join: Concatenació de textos + pick: Selecciona de la llista + listLen: Obtenir la longitud de la llista + stringToNumber: Text a número + number: Número + _stringToNumber: + arg1: Text + splitStrByLine: Dividir el text per salts de línia + _fn: + slots: Ranures + slots-info: Separa cada ranura amb un salt de línia + arg1: Sortida + aiScriptVar: Variable AiScript + fn: Funció + for: Repetir + _numberToString: + arg1: Número + _DRPWPM: + arg1: Llista de text + numberToString: Número a text + _splitStrByLine: + arg1: Text + ref: Variable + DRPWPM: Tria aleatòriament d'una llista ponderada (Canvis un cop al dia per + a cada usuari) + _for: + arg1: Nombre de vegades a repetir + arg2: Acció + strLen: Longitud del text + multiLineText: Text (multilínia) + _strLen: + arg1: Text + textList: Llista de text + _textList: + info: Separa cada ranura amb un salt de línia + types: + array: "Llistes" + stringArray: Llista de text + boolean: Bandera + string: Text + number: Número + emptySlot: Ranura buida + enviromentVariables: Variables d'entorn + pageVariables: Variables de pàgina + argVariables: Ranures d'entrada + thereIsEmptySlot: L'espai {slot} està buit! + typeError: L'espai {slot} accepta valors del tipus "{expect}", però el valor proporcionat + és del tipus "{actual}"! + newPage: Crea una pàgina nova + editPage: Edita aquesta pàgina + readPage: S'està veient la font d'aquesta pàgina + created: Pàgina creada correctament + updated: Pàgina editada correctament + invalidNameText: Assegurat que el títol de la pàgina no estigui buit + editThisPage: Edita aquesta pàgina + deleted: Pàgina suprimida correctament + pageSetting: Configuració de la pàgina + nameAlreadyExists: L'URL de la pàgina especificat ja existeix + invalidNameTitle: L'URL de la pàgina especificat no és vàlid + viewPage: Consulta la teva pàgina + like: M'agrada + viewSource: Veure la font + summary: Resum de la pàgina + alignCenter: Centrar elements + hideTitleWhenPinned: Amaga el títol de la pàgina quan estigui fixat al perfil + font: Tipus de lletra + fontSerif: Serif + fontSansSerif: Sans Serif + eyeCatchingImageSet: Estableix una miniatura + eyeCatchingImageRemove: Suprimeix la miniatura + chooseBlock: Afegeix un bloc + selectType: Selecciona un tipus + enterVariableName: Introduïu un nom de variable + blocks: + section: Secció + text: Text + textarea: Àrea de text + image: Imatges + if: Si + _if: + variable: Variable + post: Formulari de notes + _post: + text: Contingut + attachCanvasImage: Adjuntar imatge de llenç + canvasId: ID del llenç + _textInput: + name: Nom de la variable + text: Títol + default: Valor per defecte + textInput: Entrada de text + _textareaInput: + name: Nom de la variable + text: Títol + default: Valor per defecte + textareaInput: Entrada de text multilínia + numberInput: Entrada numèrica + _note: + id: ID de la publicació + idDescription: També podeu enganxar l'URL de la publicació aquí. + detailed: Vista detallada + switch: Canviar + canvas: Llenç + _canvas: + id: Identificador de llenç + width: Amplada + height: Alçada + note: Publicació incrustada + _counter: + name: Nom de la variable + text: Títol + inc: Pas + _button: + text: Títol + colored: De colors + action: Comportament quan es prem el botó + _action: + _dialog: + content: Contingut + resetRandom: Restableix la llavor aleatòria + pushEvent: Envia un esdeveniment + _pushEvent: + event: Nom de l'esdeveniment + message: Missatge que s'ha de mostrar quan s'activa + variable: Variable per enviar + no-variable: Cap + dialog: Mostra un diàleg + callAiScript: Invoca AiScript + _callAiScript: + functionName: Nom de la funció + _switch: + default: Valor per defecte + name: Nom de la variable + text: Títol + counter: Comptador + _numberInput: + name: Nom de la variable + text: Títol + default: Valor per defecte + button: Botó + _radioButton: + name: Nom de la variable + title: Títol + values: Llista d'opcions separades per salts de línia + default: Valor per defecte + radioButton: Elecció + variableNameIsAlreadyUsed: Aquest nom de variable ja està en ús + contentBlocks: Contingut + inputBlocks: Entrada + specialBlocks: Especial + variables: Variables + title: Títol + url: URL de la pàgina + unlike: Elimina m'agrada + my: Les meves pàgines + liked: Pàgines que m'han agradat + content: Bloc de pàgines + featured: Popular + inspector: Inspector + contents: Contingut +_notification: + youWereFollowed: "t'ha seguit" + _types: + follow: "Nous seguidors" + mention: "Menció" + renote: "Impulsos" + quote: "Citar" + reaction: "Reaccions" + all: Tots + reply: Respostes + pollEnded: S'acaben les enquestes + receiveFollowRequest: S'han rebut peticions de seguiment + followRequestAccepted: Sol·licituds de seguiment acceptades + groupInvited: Invitacions per a grups + app: Notificacions d'aplicacions enllaçades + pollVote: Votacions a les enquestes + _actions: + reply: "Respondre" + renote: "Impulsos" + followBack: t'ha tornat el seguiment + youGotQuote: "{name} t'ha citat" + fileUploaded: El fitxer s'ha penjat correctament + youGotMention: "{nom} t'ha esmentat" + youGotReply: "{name} t'ha respost" + youRenoted: Impuls de {name} + youGotPoll: '{name} ha votat a la teva enquesta' + youGotMessagingMessageFromUser: "{name} t'ha enviat un missatge de xat" + youGotMessagingMessageFromGroup: S'ha enviat un missatge de xat al grup {name} + youReceivedFollowRequest: Has rebut una sol·licitud de seguiment + yourFollowRequestAccepted: S'ha acceptat la vostra sol·licitud de seguiment + pollEnded: Es resultat de la enquesta ja està disponible + emptyPushNotificationMessage: Les notificacions push s'han actualitzat + youWereInvitedToGroup: "{userName} t'ha convidat a un grup" + reacted: Ha reaccionat a la teva publicació + renoted: Ha impulsat la teva publicació + voted: Ha votat a la teva enquesta +_deck: + _columns: + notifications: "Notificacions" + tl: "Línia de temps" + list: "Llistes" + mentions: "Mencions" + widgets: Ginys + main: Principal + antenna: Antena + direct: Missatges directes + channel: Canal + alwaysShowMainColumn: Mostra sempre la columna principal + columnAlign: Alinear columnes + introduction: Crea la interfície perfecta per a tu organitzant columnes lliurement! + swapRight: Canvia amb la columna de la dreta + swapUp: Canvia amb la columna de d'alt + swapDown: Canvia amb la columna de sota + stackLeft: Apilar amb la columna de l'esquerra + popRight: Treu a la dreta + profile: Espai de treball + newProfile: Nou espai de treball + deleteProfile: Suprimir l'espai de treball + introduction2: Feu clic al + a la dreta de la pantalla per afegir noves columnes + sempre que vulgueu. + widgetsIntroduction: Selecciona "Editar ginys" al menú de columnes i afegeix un + giny. + addColumn: Afegeix una columna + configureColumn: Configuració de columnes + swapLeft: Canvia amb la columna de l'esquerra + renameProfile: Canvia el nom de l'espai de treball + nameAlreadyExists: Aquest nom d'espai de treball ja existeix. +blockConfirm: Segur que vols bloquejar aquest compte? +unsuspendConfirm: Segur que vols treure la suspensió d'aquest compte? +unblockConfirm: Segur que vols treure el bloqueig d'aquest compte? +suspendConfirm: Segur que vols suspendre aquest compte? +selectList: Selecciona una llista +selectAntenna: Selecciona una antena +selectWidget: Selecciona un giny +editWidgets: Edita els ginys +editWidgetsExit: Fet +customEmojis: Emojis personalitzats +cacheRemoteFilesDescription: Quan aquesta opció està desactivada, els fitxers remots + es carreguen directament del servidor remot. Desactivar-la farà que baixi l'ús d'emmagatzematge, + però incrementa el tràfic, perquè les miniatures no es generaran. +flagAsBot: Marcar aquest compte com a bot 🤖 +flagAsBotDescription: Activa aquesta opció si aquest compte és controlat per un programa. + Si s'activa, això actuarà com una bandera per a altres desenvolupadors i ajuda a + prevenir cadenes de interaccions infinites amb altres bots a més d'ajustar els sistemes + interns de Iceshrimp per tractar aquest compte com un bot. +flagAsCat: Ets un gat? 🐱 +flagShowTimelineReplies: Mostra respostes a la línia de temps +flagAsCatDescription: Guanyaràs unes orelles de gat i parlares com un gat! +flagShowTimelineRepliesDescription: Si s'activa, es mostraran les respostes d'usuaris + a publicacions d'altres usuaris. +general: General +autoAcceptFollowed: Aprova automàticament les peticions de seguiment d'usuaris que + segueixes +accountMoved: "L'usuari s'ha mogut a un compte nou:" +addAccount: Afegeix un compte +loginFailed: No s'ha pogut iniciar sessió +showOnRemote: Obre la pàgina original +wallpaper: Fons de pantalla +setWallpaper: Estableix fons de pantalla +removeWallpaper: Elimina el fons de pantalla +followConfirm: Segur que vols seguir a {name}? +proxyAccount: Compte proxy +proxyAccountDescription: Un compte proxy es un compte que actua com un seguidor remot + per a usuaris sota determinades condicions. Per exemple, quant un usuari afegeix + un usuari remot a la llista, l'activitat de l'usuari remot no serà entregada al + servidor si cap usuari local el segueix, així el compte proxy el seguirà. +host: Amfitrió +selectUser: Selecciona un usuari +latestStatus: Últim estat +storageUsage: Ús del emmagatzematge +metadata: Metadades +monitor: Seguiment +software: Programari +version: Versió +jobQueue: Cua de feina +cpuAndMemory: CPU i memòria +network: Xarxa +disk: Disc +instanceInfo: Informació del servidor +statistics: Estadístiques +clearCachedFiles: Esborra la memòria cau +clearQueueConfirmText: Qualsevol publicació que continuï a la cua sense entregar no + será federada. Normalment aquesta operació no es necessària. +clearCachedFilesConfirm: Segur que vols esborrar els fitxers remots de la memòria + cau? +blockedUsers: Usuaris blocats +noUsers: No hi ha cap usuari +editProfile: Edita el perfil +noteDeleteConfirm: Segur que vols eliminar la publicació? +pinLimitExceeded: No pots fixar més notes +muteAndBlock: Silenciats i blocats +mutedUsers: Usuaris silenciats +done: Fet +preview: Vista prèvia +default: Per defecte +intro: La instal·lació de Iceshrimp ha acabat! Crea un compte d'usuari d'administració. +processing: Processant… +noCustomEmojis: No hi ha cap emoji +noJobs: No hi ha cap feina +federating: Federant +blocked: Bloquejat +subscribing: Subscrivint +publishing: Publicant +notResponding: Sense resposta +instanceUsers: Usuaris d'aquest servidor +instanceFollowing: Seguint al servidor +instanceFollowers: Seguidors del servidor +security: Seguretat +newPasswordRetype: Torna a entrar la nova contrasenya +more: Més +featured: Destacat +usernameOrUserId: Nom o ID d'usuari +noSuchUser: No s'ha trobat l'usuari +lookup: Cerca +attachFile: Afegeix un fitxer +currentPassword: Contrasenya actual +newPassword: Nova contrasenya +announcements: Anuncis +imageUrl: URL de la imatge +removed: S'ha eliminat correctament +removeAreYouSure: Segur que vols eliminar "{x}"? +deleteAreYouSure: Segur que vols eliminar "{x}"? +resetAreYouSure: Segur que vols restablir? +fromUrl: Des d'una URL +saved: S'ha desat +messaging: Xat +upload: Puja +keepOriginalUploading: Desa la imatge original +keepOriginalUploadingDescription: Desa la imatge original pujada tal com es. Si es + desactiva, es generarà una versió per mostrar en la web al pujar. +fromDrive: Des del Disc +uploadFromUrl: Puja des d'una adreça URL +uploadFromUrlDescription: Adreça URL del fitxer que vols pujar +uploadFromUrlRequested: Pujada demanada +noMoreHistory: No hi ha més historial +tos: Condicions d'ús +start: Comença +startMessaging: Comença una conversa +manageGroups: Gestiona els grups +nUsersRead: llegit per {n} +agreeTo: Estic d'acord amb {0} +activity: Activitat +home: Inici +remoteUserCaution: La informació dels usuaris remots pot estar incompleta. +themeForDarkMode: Tema a fer servir en mode fosc +light: Clar +registeredDate: Data de registre +dark: Fosc +lightThemes: Temes clars +location: Ubicació +theme: Temes +themeForLightMode: Tema a fer servir en mode clar +drive: Disc +selectFile: Tria un fitxer +selectFiles: Tria fitxers +darkThemes: Temes foscos +syncDeviceDarkMode: Sincronitza el mode fosc amb la configuració del teu dispositiu +fileName: Nom del fitxer +createFolder: Crea una carpeta +renameFolder: Canvia-li el nom a la carpeta +deleteFolder: Elimina la carpeta +selectFolder: Tria una carpeta +selectFolders: Tria carpetes +renameFile: Canvia el nom del fitxer +folderName: Nom de la carpeta +inputNewFolderName: Escriu un nom de carpeta nou +addFile: Afegeix un fitxer +emptyDrive: El teu Disc és buit +emptyFolder: Aquesta carpeta és buida +unableToDelete: No es pot eliminar +inputNewFileName: Escriu un nou nom per al fitxer +inputNewDescription: Escriu una descripció nova +circularReferenceFolder: La carpeta de destí és una subcarpeta de la carpeta que vols + moure. +hasChildFilesOrFolders: Aquesta carpeta no es pot eliminar perquè no és buida. +whenServerDisconnected: Quant es perd la conexió amb el servidor +disconnectedFromServer: S'ha perdut la conexió al servidor +reload: Torna a carregar +avatar: Avatar +banner: Bàner +doNothing: Ignora +reloadConfirm: Vols tornar a carregar la línea temporal? +watch: Veure +maintainerName: Administrador +maintainerEmail: Correu electrònic de l'administrador +instanceName: Nom del servidor +instanceDescription: Descripció del servidor +today: Avui +dayX: '{day}' +tosUrl: URL de les Condicions d'ús +thisYear: Any +thisMonth: Mes +integration: Integracions +driveCapacityPerRemoteAccount: Capacitat del Disc per usuari remot +inMb: En megabytes +iconUrl: Adreça URL de la icona +enableRegistration: Activa el registre d'usuaris nous +invite: Convidar +driveCapacityPerLocalAccount: Capacitat del Disc per usuari local +bannerUrl: Adreça URL del banner +backgroundImageUrl: Adreça URL del fons de pantalla +basicInfo: Informació bàsica +pinnedPages: Pàgines fixades +pinnedUsersDescription: Llista de noms d'usuaris per fixar a la pestanya "Explorar" + Un nom per línea. +pinnedPagesDescription: Introdueix la ruta a les pàgines que vols fixar a la página + principal d'aquest servidor, una ruta per línea. +pinnedUsers: Usuaris fixats +enableHcaptcha: Activa hCaptcha +hcaptchaSiteKey: Clau del lloc +hcaptchaSecretKey: Clau secreta +recaptcha: reCAPTCHA +enableGlobalTimeline: Activa la línia de temps global +disablingTimelinesInfo: Els Administradors i Moderadors sempre tenen accés a totes + les líneas temporals, inclòs si hi són desactivades. +showLess: Tanca +clearQueue: Esborra la cua +uploadFromUrlMayTakeTime: Pot trigar un temps fins que la pujada es completi. +noThankYou: No, gràcies +addInstance: Afegeix un servidor +emoji: Emojis +emojis: Emojis +emojiName: Nom del emoji +emojiUrl: URL de l'emoji +addEmoji: Afegeix +settingGuide: Configuració recomenada +searchWith: 'Cerca: {q}' +youHaveNoLists: No tens cap llista +flagSpeakAsCat: Parla com un gat +selectInstance: Selecciona un servidor +flagSpeakAsCatDescription: Les teves publicacions es transformaran en miols quan estiguis + en mode gat +recipient: Destinatari(s) +annotation: Comentaris +blockedInstances: Servidors bloquejats +blockedInstancesDescription: Llista les adreces dels servidors que vols bloquejar. + Els servidors de la llista no podrán comunicarse amb aquests servidors. +hiddenTags: Etiquetes amagades +hiddenTagsDescription: 'Enumereu les etiquetes (sense el #) que voleu ocultar de tendències + i explorar. Les etiquetes ocultes encara es poden descobrir per altres mitjans.' +noInstances: No hi ha cap servidor +defaultValueIs: 'Per defecte: {value}' +suspended: Suspès +all: Tot +changePassword: Canvia la contrasenya +clearQueueConfirmTitle: Segur que vols esborrar la cua? +retypedNotMatch: Els camps no coincideixen. +normal: Normal +monthX: '{month}' +enableRecaptcha: Activa reCAPTCHA +recaptchaSiteKey: Clau del lloc +recaptchaSecretKey: Clau secreta +avoidMultiCaptchaConfirm: Fent servir diferents sistemes de Captcha pot causar interferències + entre ells. Vols desactivar els altres sistemes que es troben activats? Si vols + deixar-los activats fes clic a cancelar. +antennas: Antenes +enableEmojiReactions: Activa reaccions amb emojis +blockThisInstance: Bloqueja aquest servidor +registration: Registra't +showEmojisInReactionNotifications: Mostra els emojis a les notificacions de les reaccions +renoteMute: Silencia els impulsos +renoteUnmute: Treu el silenci als impulsos +cacheRemoteFiles: Fitxers remots a la memòria cau +federation: Federació +registeredAt: Registrat a +latestRequestSentAt: Última petició enviada +latestRequestReceivedAt: Última petició rebuda +charts: Gràfics +perHour: Per hora +perDay: Per dia +stopActivityDelivery: Para d'enviar activitats +operations: Operacions +explore: Explora +messageRead: Llegit +images: Imatges +birthday: Aniversari +yearsOld: '{age} anys' +copyUrl: Copia l'adreça URL +rename: Renombrar +unwatch: Deixa de veure +accept: Accepta +reject: Rebutja +yearX: '{year}' +pages: Pàgines +disconnectService: Desconnectar +connectService: Connectar +enableLocalTimeline: Activa la línea de temps local +enableRecommendedTimeline: Activa la línea de temps de recomanacions +pinnedClipId: ID del clip que vols fixar +hcaptcha: hCaptcha +manageAntennas: Gestiona les Antenes +name: Nom +notesAndReplies: Notes i respostes +silence: Posa en silenci +withFiles: Amb fitxers +popularUsers: Usuaris populars +exploreUsersCount: Hi han {count} usuaris +exploreFediverse: Explora el Fesiverse +popularTags: Etiquetes populars +about: Sobre +recentlyUpdatedUsers: Usuaris actius fa poc +recentlyRegisteredUsers: Usuaris registrats fa poc +recentlyDiscoveredUsers: Nous suaris descoberts +administrator: Administrador +token: Token +registerSecurityKey: Registreu una clau de seguretat +securityKeyName: Nom clau +lastUsed: Feta servir per última vegada +unregister: Anul·lar el registre +passwordLessLogin: Identificació sense contrasenya +share: Comparteix +notFound: No s'ha trobat +newPasswordIs: La nova contrasenya és "{password}" +notFoundDescription: No es pot trobar cap pàgina que correspongui a aquesta adreça + URL. +uploadFolder: Carpeta per defecte per pujar arxius +cacheClear: Netejar la memòria cau +markAsReadAllNotifications: Marca totes les notificacions com llegides +markAsReadAllUnreadNotes: Marca totes les notes com a llegides +markAsReadAllTalkMessages: Marca tots els missatges com llegits +help: Ajuda +inputMessageHere: Escriu aquí el missatge +close: Tancar +group: Grup +groups: Grups +createGroup: Crea un grup +ownedGroups: Grups que et pertanyen +joinedGroups: Grups als que t'has unit +groupName: Nom del grup +members: Membres +transfer: Transferir +messagingWithUser: Conversa privada +title: Títol +text: Text +enable: Activar +next: Següent +retype: Torna a entrar +noteOf: Publicació de {user} +inviteToGroup: Invitar a un grup +quoteAttached: Cita +quoteQuestion: Adjuntar com a cita? +noMessagesYet: Encara no hi han missatges +signinRequired: Si us plau registrat o inicia sessió per continuar +invitations: Invitacions +invitationCode: Codi d'invitació +checking: Comprovant… +usernameInvalidFormat: Pots fer servir lletres en majúscules o minúscules, nombres + i guions baixos. +tooShort: Massa curt +tooLong: Massa llarg +weakPassword: Contrasenya amb seguretat feble +strongPassword: Contrasenya amb seguretat forta +passwordMatched: Coincidències +signinWith: Inicieu sessió com {x} +signinFailed: No es pot iniciar sessió. El nom d'usuari o la contrasenya són incorrectes. +or: O +language: Idioma +uiLanguage: Idioma de la interfície d'usuari +groupInvited: T'han invitat a un grup +aboutX: Sobre {x} +youHaveNoGroups: No tens grups +disableDrawer: No facis servir els menús amb estil de calaix +noHistory: No hi ha historial disponible +signinHistory: Historial d'inicis de sessió +disableAnimatedMfm: Desactiva les animacions amb MFM +doing: Processant… +category: Categoría +existingAccount: El compte ja existeix +regenerate: Regenerar +docSource: Font d'aquest document +createAccount: Crear compte +fontSize: Mida del text +noFollowRequests: No tens cap sol·licitud de seguiment per aprovar +openImageInNewTab: Obre les imatges en una pestanya nova +dashboard: Panell +local: Local +remote: Remot +total: Total +weekOverWeekChanges: Canvis d'ençà la passada setmana +dayOverDayChanges: Canvis d'ençà ahir +appearance: Aparença +clientSettings: Configuració del client +accountSettings: Configuració del compte +promotion: Promogut +promote: Promoure +numberOfDays: Nombre de dies +objectStorageBaseUrl: Adreça URL base +hideThisNote: Amaga aquesta publicació +showFeaturedNotesInTimeline: Mostra les notes destacades a les líneas de temps +objectStorage: Emmagatzematge d'objectes +useObjectStorage: Fes servir l'emmagatzema d'objectes +expandTweet: Amplia el tuit +themeEditor: Editor de temes +description: Descripció +leaveConfirm: Hi han canvis que no s'han desat. Els vols descartar? +manage: Administració +plugins: Afegits +preferencesBackups: Preferències de còpies de seguretat +undeck: Treure el Taulell +useBlurEffectForModal: Fes servir efectes de difuminació en les finestres modals +useFullReactionPicker: Fes servir el selector de reaccions a tamany complert +deck: Taulell +width: Amplada +generateAccessToken: Genera un token d'accés +medium: Mitja +small: Petit +permission: Permisos +enableAll: Activa tots +tokenRequested: Garantir accés al compte +pluginTokenRequestedDescription: Aquest afegit podrà fer servir els permisos configurats + aquí. +emailServer: Servidor de correu electrònic +notificationType: Tipus de notificació +edit: Editar +emailAddress: Adreça de Correu electrònic +smtpConfig: Configuració del servidor SMTP +smtpHost: Host +enableEmail: Activa la distribució de correu electrònic +smtpPort: Port +emailConfigInfo: Fet servir per confirmar les adreçats de correu electrònic al registrar-se + o si s'oblida la contrasenya +email: Correu electrònic +smtpSecure: Fes servir SSL/TLS implícit per connectar-se per SMTP +emptyToDisableSmtpAuth: Deixa el nom d'usuari i la contrasenya sense emplenar per + desactivar la verificació SMTP +smtpSecureInfo: Desactiva això quant facis servir STARTTLS +testEmail: Envia un correu electrònic de verificació +wordMute: Silenciar paraules +regexpError: Error a la Expressió Regular +regexpErrorDescription: 'Hi ha un error a la expressió regular a la línea {line} de + la teva {tab} de paraules silenciades:' +userSaysSomething: '{name} va dir alguna cosa' +instanceMute: Silenciar servidor +logs: Registres +copy: Copiar +delayed: Retardat +metrics: Mètriques +overview: Vista general +database: Base de dades +regenerateLoginToken: Regenera el token d'inici de sessió +reduceUiAnimation: Redueix les animacions de la UI +messagingWithGroup: Conversa en grup +invites: Invitacions +unavailable: No disponible +newMessageExists: Tens nous missatges +onlyOneFileCanBeAttached: Només pots adjuntar un fitxer per missatge +normalPassword: Contrasenya amb seguretat mitjana +passwordNotMatched: No hi han coincidències +useOsNativeEmojis: Fes servir els emojis per defecte del Sistema Operatiu +joinOrCreateGroup: Fes que et convidin a un grup o crea el teu propi. +objectStorageBaseUrlDesc: "Es l'adreça URL que serveix com a referència. Específica + la adreça URL del CDN o Proxy si fas servir.\nPer fer servir S3 'https://.s3.amazonaws.com' + i per GCS o serveis semblants 'https://storage.googleapis.com/', etc." +height: Alçada +large: Gran +notificationSetting: Preferències de notificacions +makeActive: Activar +notificationSettingDesc: Tria el tipus de notificació que es veure. +notifyAntenna: Notificar publicacions noves +withFileAntenna: Només notes amb fitxers +enableServiceworker: Activa les notificacions push per al teu navegador +antennaUsersDescription: Escriu un nom d'usuari per línea +antennaInstancesDescription: Escriu la adreça d'un servidor per línea +tags: Etiquetes +antennaSource: Font de la antena +antennaKeywords: Paraules claus a escoltar +antennaExcludeKeywords: Paraules clau a excluir +antennaKeywordsDescription: Separades amb espais per fer una condició AND i amb una + línea nova per fer una condició OR. +caseSensitive: Sensible a majúscules i minúscules +withReplies: Inclou respostes +connectedTo: Aquest(s) compte(s) estan connectats +silenceConfirm: Segur que vols posa en silenci aquest usuari? +unsilence: Desfés posar en silenci +unsilenceConfirm: Segur que vols treure el silenci a aquest usuari? +aboutIceshrimp: Sobre Iceshrimp +twoStepAuthentication: Autentificació de dos factors +moderator: Moderador +moderation: Moderació +available: Disponible +tapSecurityKey: Escriu la teva clau de seguretat +nUsersMentioned: Esmentat per {n} usuari(s) +securityKey: Clau de seguretat +resetPassword: Restablir contrasenya +describeFile: Afegeix una descripció +enterFileDescription: Entra una descripció +author: Autor +disableAll: Desactiva tots +userSaysSomethingReason: '{name} va dir {reason}' +display: Visualització +channel: Canals +create: Crear +useGlobalSetting: Fes servir els ajusts globals +useGlobalSettingDesc: Si s'activa, es faran servir els ajusts de notificacions del + teu compte. Si es desactiva , es poden fer configuracions individuals. +other: Altres +menu: Menú +addItem: Afegeix un element +divider: Divisor +relays: Relés +addRelay: Afegeix un Relé +inboxUrl: Adreça de la safata d'entrada +addedRelays: Relés afegits +serviceworkerInfo: Ha de estar activat per les notificacions push. +poll: Enquesta +deletedNote: Publicació esborrada +disablePlayer: Tancar el reproductor de vídeo +fileIdOrUrl: ID o adreça URL del fitxer +behavior: Comportament +regenerateLoginTokenDescription: Regenera el token que es fa servir de manera interna + durant l'inici de sessió. Normalment això no és necessari. Si es torna a genera + el token, es tancarà la sessió a tots els dispositius. +setMultipleBySeparatingWithSpace: Separa diferents entrades amb espais. +reportAbuseOf: Informa d'un abús de {name} +sample: Exemple +abuseReports: Informes +reportAbuse: Informe +reporter: Informador +reporterOrigin: Origen informador +forwardReport: Envia l'informe a un servidor remot +abuseReported: El teu informe ha sigut enviat. Moltes gràcies. +reporteeOrigin: Origen de l'informe +send: Enviar +abuseMarkAsResolved: Marcar l'informe com a resolt +visibility: Visibilitat +useCw: Amaga el contingut +enablePlayer: Obre el reproductor de vídeo +yourAccountSuspendedDescription: Aquest compte ha sigut suspès per no seguir els termes + de servei d'aquest servidor o quelcom similar. Contacte amb l'administrador si vols + conèixer la raó amb més detall. Si us plau no facis un compte nou. +invisibleNote: Publicació oculta +enableInfiniteScroll: Carregar més de forma automàtica +fillAbuseReportDescription: Si us plau omple els detalls sobre aquest informe. Si + es sobre una publicació en concret, si us plau, inclou l'adreça URL. +forwardReportIsAnonymous: Com a informador el servidor remot no veure el teu compte, + si no un compte anònim. +openInNewTab: Obrir en una pestanya nova +openInSideView: Obrir a la vista lateral +defaultNavigationBehaviour: Navegació per defecte +editTheseSettingsMayBreakAccount: Si edites aquestes configuracions pots fer mal bé + el teu compte. +userSilenced: Usuari silenciat. +instanceTicker: Informació de notes del servidor +waitingFor: Esperant a {x} +random: Aleatori +system: Sistema +switchUi: Canvia la disposició +createNewClip: Crear un clip nou +unclip: Treure clip +public: Públic +renotesCount: Nombre d'impulsos fets +sentReactionsCount: Nombre de reaccions fetes +receivedReactionsCount: Nombre de reaccions rebudes +pollVotesCount: Nombre de vots fets en enquestes +pollVotedCount: Nombre de vots rebuts en enquestes +yes: Sí +no: No +noCrawle: Rebutjar la indexació dels restrejadors +driveUsage: Espai fet servir al Disk +noCrawleDescription: No permetre que els buscadors guardin la informació de les pàgines + de perfil, notes, Pàgines, etc. +alwaysMarkSensitive: Marcar per defecte com a sensible +lockedAccountInfo: Si has configurat la visibilitat del compte per "Només seguidors" + les teves notes no seren visibles per a ningú més, inclús si has d'aprovar els teus + seguidors manualment. +disableShowingAnimatedImages: No reproduir les imatges animades +verificationEmailSent: S'ha enviat correu electrònic de verificació. Si us plau segueix + les instruccions per completar la verificació. +notSet: Sense especificar +emailVerified: El correu electrònic s'ha verificat +loadRawImages: Carregar les imatges originals en comptes de mostrar les miniatures +noteFavoritesCount: Nombre de notes afegides a favorits +useSystemFont: Fes servir la font per defecte del sistema +contact: Contacte +clips: Retalls +experimentalFeatures: Característiques experimentals +developer: Desenvolupador +makeExplorableDescription: Si desactives aquesta funció el teu compte no sortirà a + la secció "Explora". +showGapBetweenNotesInTimeline: Mostra un espai entre notes a la línea de temps +makeExplorable: Fes el compte visible a "Explora" +duplicate: Duplicar +left: Esquerra +wide: Ample +narrow: Estret +reloadToApplySetting: Aquesta configuració només sortirà efecte després de recarregar + la pàgina. Vols fer-ho ara? +needReloadToApply: Es requereix recarregar la pàgina perquè això surti efecte. +showTitlebar: Mostrar la barra de títol +onlineUsersCount: Hi han {n} usuaris connectats +nUsers: '{n} Usuaris' +nNotes: '{n} Notes' +sendErrorReports: Enviar informe d'error +clearCache: Netejar memòria cau +switchAccount: Canvia de compte +enabled: Activat +configure: Configurar +noBotProtectionWarning: La protecció contra bots no està configurada. +ads: Publicitat +ratio: Ràtio +global: Global +sent: Enviat +received: Rebut +whatIsNew: Mostra els canvis +usernameInfo: Un nom que identifica el vostre compte d'altres en aquest servidor. + Podeu utilitzar l'alfabet (a~z, A~Z), els dígits (0~9) o el guió baix (_). Els noms + d'usuari no es poden canviar més tard. +breakFollow: Suprimeix el seguidor +makeReactionsPublicDescription: Això farà que la llista de totes les vostres reaccions + passades sigui visible públicament. +hide: Amagar +leaveGroupConfirm: Estàs segur que vols deixar "{name}"? +voteConfirm: Vols confirmar el teu vot per a "{choice}"? +leaveGroup: Sortir del grup +rateLimitExceeded: S'ha excedit el límit proporcionat +cropImage: Retalla la imatge +cropImageAsk: Vols retallar aquesta imatge? +failedToFetchAccountInformation: No s'ha pogut obtenir la informació del compte +driveCapOverrideCaption: Restableix la capacitat per defecte introduint un valor de + 0 o inferior. +type: Tipus +label: Etiqueta +beta: Beta +navbar: Barra de navegació +adminCustomCssWarn: Aquesta configuració només s'ha d'utilitzar si sabeu què fa. La + introducció de valors inadequats pot fer que els clients de TOTS deixin de funcionar + amb normalitat. Assegureu-vos que el vostre CSS funcioni correctament provant-lo + a la configuració de l'usuari. +showUpdates: Mostra una finestra emergent quan Iceshrimp s'actualitzi +recommendedInstances: Servidors recomanats +recommendedInstancesDescription: Servidors recomanats separats per salts de línia + que apareixen a la línia de temps recomanada. +caption: Descripció Automàtica +splash: Pantalla de Benvinguda +swipeOnDesktop: Permet lliscar a l'estil del mòbil a l'escriptori +updateAvailable: Pot ser que hi hagi una actualització disponible! +logoImageUrl: URL de la imatge del logotip +showAdminUpdates: Indica que hi ha disponible una versió nova de Iceshrimp (només + per a administradors) +replayTutorial: Repetició del tutorial +migration: Migració +moveAccountDescription: Aquest procés és irreversible. Assegureu-vos que hàgiu configurat + un àlies per a aquest compte al vostre compte nou abans de moure's. Introduïu l'etiqueta + del compte amb el format @persona@servidor.com +moveToLabel: 'Compte al qual us moveu:' +moveAccount: Mou el compte! +moveFromDescription: Això establirà un àlies del vostre compte antic perquè pugueu + passar d'aquest compte a aquest actual. Feu això ABANS de moure's del vostre compte + anterior. Introduïu l'etiqueta del compte amb el format @persona@servidor.com +_sensitiveMediaDetection: + description: Redueix l'esforç de moderació del servidor mitjançant el reconeixement + automàtic dels mitjans sensibles mitjançant l'aprenentatge automàtic. Això augmentarà + lleugerament la càrrega al servidor. + setSensitiveFlagAutomaticallyDescription: Els resultats de la detecció interna es + conservaran encara que aquesta opció estigui desactivada. + analyzeVideos: Activa l'anàlisi de vídeos + analyzeVideosDescription: Analitza vídeos a més d'imatges. Això augmentarà lleugerament + la càrrega al servidor. + setSensitiveFlagAutomatically: Marcar com a sensible + sensitivity: Sensibilitat de detecció + sensitivityDescription: La reducció de la sensibilitat comportarà menys deteccions + errònies (falsos positius), mentre que augmentar-la comportarà menys deteccions + falses (falsos negatius). +_emailUnavailable: + used: Aquesta adreça de correu electrònic ja s'està utilitzant + format: El format d'aquesta adreça de correu electrònic no és vàlid + disposable: Les adreces de correu electrònic d'un sol ús no es poden utilitzar + mx: Aquest servidor de correu electrònic no és vàlid + smtp: Aquest servidor de correu electrònic no respon +_ffVisibility: + public: Públic + followers: Visible només per als seguidors + private: Privat +_signup: + emailAddressInfo: Introduïu la vostra adreça de correu electrònic. No es farà públic. + almostThere: Gairebé està + emailSent: S'ha enviat un correu electrònic de confirmació a la vostra adreça electrònica + ({email}). Feu clic a l'enllaç inclòs per completar la creació del compte. +_accountDelete: + started: S'ha iniciat la supressió. + accountDelete: Suprimeix el compte + mayTakeTime: Com que la supressió del compte és un procés que requereix molts recursos, + pot ser que trigui algun temps a completar-se en funció de la quantitat de contingut + que hàgiu creat i de quants fitxers hàgiu penjat. + sendEmail: Un cop s'hagi completat la supressió del compte, s'enviarà un correu + electrònic a l'adreça de correu electrònic registrada en aquest compte. + inProgress: La supressió del compte està en curs + requestAccountDelete: Sol·licitar la supressió del compte +_ad: + back: Enrera + reduceFrequencyOfThisAd: Mostrar aquest anunci menys +_gallery: + my: La meva Galeria + liked: Notes que m'han agradat + unlike: Elimina m'agrada + like: M'agrada +_forgotPassword: + contactAdmin: Aquest servidor no admet l'ús d'adreces de correu electrònic; poseu-vos + en contacte amb l'administrador del servidor per restablir la contrasenya. + ifNoEmail: Si no heu utilitzat cap correu electrònic durant el registre, poseu-vos + en contacte amb l'administrador del servidor. + enterEmail: Introduïu l'adreça de correu electrònic que heu utilitzat per registrar-vos. + A continuació, se li enviarà un enllaç amb el qual podeu restablir la vostra contrasenya. +_plugin: + install: Instal·leu connectors + installWarn: Si us plau, no instal·leu connectors que no siguin fiables. + manage: Gestionar els connectors +_preferencesBackups: + saveNew: Desa una còpia de seguretat nova + apply: Aplicar a aquest dispositiu + loadFile: Carrega des del fitxer + save: Desa els canvis + nameAlreadyExists: Ja existeix una còpia de seguretat anomenada "{name}". Introduïu + un nom diferent. + renameConfirm: Canviar el nom d'aquesta còpia de seguretat de "{old}" a "{new}"? + noBackups: No existeixen còpies de seguretat. Podeu fer una còpia de seguretat de + la configuració del vostre client en aquest servidor utilitzant "Crea una còpia + de seguretat nova". + deleteConfirm: Vols suprimir la còpia de seguretat anomanada {name}? + updatedAt: 'Actualitzat el: {time} {date}' + createdAt: 'Creat el: {time} {date}' + cannotLoad: No s'ha pogut carregar + inputName: Introduïu un nom per a aquesta còpia de seguretat + saveConfirm: Deseu la còpia de seguretat com a {name}? + invalidFile: Format de fitxer no vàlid + applyConfirm: Realment voleu aplicar la còpia de seguretat "{name}" a aquest dispositiu? + La configuració existent d'aquest dispositiu es sobreescriurà. + list: Còpies de seguretat creades + cannotSave: S'ha produït un error en desar + delete: Esborrar còpia de seguretat +_registry: + domain: Domini + createKey: Crea la clau + scope: Àmbit + key: Clau + keys: Claus +silenced: Silenciat +objectStorageUseSSL: Fes servir SSL +yourAccountSuspendedTitle: Aquest compte està suspès +i18nInfo: Iceshrimp està sent traduït a diversos idiomes per voluntaris. Pots ajudar + {link}. +manageAccessTokens: Administrar tokens d'accés +accountInfo: Informació del compte +pageLikedCount: Nombre de m'agrada rebuts a Pàgines +center: Centre +registry: Registre +closeAccount: Tancar el compte +currentVersion: Versió actual +latestVersion: Versió més nova +newVersionOfClientAvailable: Aquesta és la versió del client més nova disponible. +usageAmount: Ús +capacity: Capacitat +editCode: Editar codi +apply: Aplicar +repliesCount: Nombre de contestacions fetes +repliedCount: Nombre de respostes rebudes +renotedCount: Nombre d'impulsos rebuts +followingCount: Nombre de comptes seguits +followersCount: Nombre de seguidors +goBack: Enrera +quitFullView: Sortí de la vista complerta +addDescription: Afegeix una descripció +notSpecifiedMentionWarning: Aquesta publicació conté mencions a usuaris no inclosos + com a destinataris +info: Sobre +hideOnlineStatus: Amagar l'estat de conexió +onlineStatus: Estat de conexió +online: En línea +offline: Desconectat +notRecommended: No recomanat +botProtection: Protecció contra Bots +instanceBlocking: Gestió de la federació +selectAccount: Seleccionar un compte +disabled: Desactivat +quickAction: Accions ràpides +administration: Administració +switch: Canviar +gallery: Galeria +popularPosts: Pàgines populars +shareWithNote: Comparteix amb una publicació +expiration: Data límit +memo: Recordatori +priority: Prioritat +high: Alta +middle: Mitjana +low: Baixa +emailNotConfiguredWarning: L'adreça de correu electrònic no està definida. +instanceSecurity: Seguretat del servidor +privateMode: Mode Privat +allowedInstances: Llista de servidors permesos +allowedInstancesDescription: Llista de Hosts amb qui federar, cadascún separat per + una línia nova (només s'aplica en mode privat). +previewNoteText: Mostra la vista prèvia +customCss: CSS personalitzat +recommended: Recomanat +seperateRenoteQuote: Botons d'impuls i de citació separats +searchResult: Resultats de la cerca +hashtags: Etiquetes +troubleshooting: Resolució de problemes +learnMore: Més informació +iceshrimpUpdated: Iceshrimp s'ha actualitzat! +translate: Tradueix +translatedFrom: Traduït per {x} +aiChanMode: Ai-chan a la interfície d'usuari clàssica +keepCw: Mantenir els avisos de contingut +pubSub: Comptes Pub/Sub +lastCommunication: Última comunicació +breakFollowConfirm: Confirmes que vols eliminar el seguidor? +itsOn: Activat +itsOff: Desactivat +emailRequiredForSignup: Requereix una adreça de correu electrònic per registrar-te +unread: Sense llegir +controlPanel: Tauler de control +manageAccounts: Gestionar comptes +makeReactionsPublic: Estableix l'historial de reaccions com a públic +classic: Centrat +muteThread: Silenciar el fil +ffVisibility: Visibilitat dels Seguiments/Seguidors +incorrectPassword: Contrasenya incorrecta. +clickToFinishEmailVerification: Feu clic a [{ok}] per completar la verificació del + correu electrònic. +overridedDeviceKind: Tipus de dispositiu +smartphone: Telèfon intel·ligent +tablet: Tauleta +auto: Automàtic +recentNHours: Últimes {n} hores +recentNDays: Últims {n} dies +noEmailServerWarning: El servidor de correu electrònic no està configurat. +check: Comprovar +fast: Ràpida +sensitiveMediaDetection: Detecció de mitjans sensobles +remoteOnly: Només remotes +failedToUpload: S'ha produït un error en la càrrega +cannotUploadBecauseInappropriate: Aquest fitxer no s'ha pogut carregar perquè s'han + detectat parts d'aquest com a potencialment sensible. +cannotUploadBecauseNoFreeSpace: La pujada ha fallat a causa de la manca d'espai al + Disc. +enableAutoSensitive: Marcar automàticament com sensible +moveTo: Mou el compte actual al compte nou +customKaTeXMacro: Macros KaTeX personalitzats +_aboutIceshrimp: + contributors: Col·laboradors principals + allContributors: Tots els col·laboradors + donate: Fes una donació a Iceshrimp + source: Desenvolupament de l'Iceshrimp + translation: Traduccions + about: Iceshrimp és una bifurcació de Iceshrimp feta per ThatOneCalculator, que + està en desenvolupament des del 2022. + morePatrons: També agraïm el suport de molts altres ajudants que no figuren aquí. + Gràcies! 🥰 + patrons: Mecenes de Iceshrimp + patronsList: Llistats cronològicament, no per la quantitat donada. Fes una donació + amb l'enllaç de dalt per veure el teu nom aquí! + donateTitle: T'agrada Iceshrimp? + pleaseDonateToIceshrimp: Penseu en fer una donació a Iceshrimp per donar suport + al seu desenvolupament. + pleaseDonateToHost: Penseu també en fer una donació a la vostre instància, {host}, + per ajudar-lo a suportar els costos de funcionament. + donateHost: Fes una donació a {host} + sponsors: Patrocinadors de Calckey + chatroom: Sala de xat + documentation: Documentació + roadmap: Full de ruta + changelog: Registre de canvis +unknown: Desconegut +pageLikesCount: Nombre de pàgines amb M'agrada +youAreRunningUpToDateClient: Estás fent servir la versió del client més nova. +unlikeConfirm: Vols treure el teu m'agrada? +fullView: Vista complerta +desktop: Escritori +notesCount: Nombre de notes +confirmToUnclipAlreadyClippedNote: Aquesta publicació ja és al clip "{name}". La vols + treure d'aquest clip? +driveFilesCount: Nombre de fitxers al Disk +silencedInstances: Servidors silenciats +silenceThisInstance: Silencia el servidor +silencedInstancesDescription: Llista amb els noms dels servidors que vols silenciar. + Els comptes als servidors silenciats seran tractades com "Silenciades", només poden + fer sol·licituds de seguiments, i no poden mencionar comptes locals si no les segueixen. + Això no afectarà els servidors bloquejats. +objectStorageEndpointDesc: Deixa això buit si fas servir AWS, S3, d'una altre manera + específica un "endpoint" com a '' o ':', depend del proveïdor + que facis servir. +objectStorageRegionDesc: Especifica una regió com a 'xx-east-1'. Si el teu proveïdor + no distingeix entre regions, deixa això en buit o pots escriure 'us-east-1'. +userPagePinTip: Pots mostrar publicacions aquí escollint "Fixar al perfil" dintre + del menú de cada publicació. +userInfo: Informació d'usuari +hideOnlineStatusDescription: Amagant el teu estat en línea redueix la comoditat d'ús + d'algunes característiques com ara la recerca. +active: Actiu +accounts: Comptes +postToGallery: Crea una publicació nova a la galeria +secureMode: Mode segur (Recuperació Autoritzada) +customCssWarn: Aquesta configuració només s'ha d'utilitzar si sabeu què fa. La introducció + de valors indeguts pot provocar que el client deixi de funcionar amb normalitat. +squareAvatars: Mostra avatars quadrats +secureModeInfo: Quan es faci una solicitut d'altres servidors no contestar sense una + prova. +privateModeInfo: Quan està activat, només els servidors a la llista poden federar + amb el vostre servidor. Totes les publicacions s'amagaran al públic. +useBlurEffect: Utilitzeu efectes de desenfocament a la interfície d'usuari +accountDeletionInProgress: La supressió del compte està en curs +unmuteThread: Desfés el silenci al fil +deleteAccountConfirm: Això suprimirà el vostre compte de manera irreversible. Procedir? +requireAdminForView: Heu d'iniciar sessió amb un compte d'administrador per veure-ho. +enableAutoSensitiveDescription: Permet la detecció i el marcatge automàtics dels mitjans + sensible mitjançant l'aprenentatge automàtic sempre que sigui possible. Fins i tot + si aquesta opció està desactivada, és possible que estigui habilitada a tot el servidor. +localOnly: Només local +customKaTeXMacroDescription: "Configura macros per escriure expressions matemàtiques + fàcilment! La notació s'ajusta a les definicions de l'ordre LaTeX i s'escriu com + a \\newcommand{\\ name}{content} o \\newcommand{\\name}[nombre d'arguments]{content}. + Per exemple, \\newcommand{\\add}[2]{#1 + #2} ampliarà \\add{3}{foo} a 3 + foo. Els + claudàtors que envolten el nom de la macro es poden canviar per claudàtors rodons + o quadrats. Això afecta els claudàtors utilitzats per als arguments. Es pot definir + una (i només una) macro per línia, i no podeu trencar la línia al mig de la definició. + Les línies no vàlides simplement s'ignoren. Només s'admeten funcions de substitució + de cadenes senzilles; La sintaxi avançada, com ara la ramificació condicional, no + es pot utilitzar aquí." +objectStorageRegion: Regió +objectStoragePrefix: Prefix +objectStoragePrefixDesc: Els fitxers es guardaran dins de carpetes amb aquest prefix. +objectStorageEndpoint: Extrem +newNoteRecived: Hi han notes noves +sounds: Sons +listen: Escoltar +none: Res +showInPage: Mostrar a la página +popout: Apareixa +volume: Volum +objectStorageUseSSLDesc: Desactiva això si no fas servir HTTPS per les connexions + API +objectStorageUseProxy: Connectar-se mitjançant un Proxy +objectStorageUseProxyDesc: Desactiva això si no faràs servir un servidor Proxy per + conexions amb l'API +objectStorageSetPublicRead: Fixar com a "public-read" al pujar +serverLogs: Registres del servidor +deleteAll: Esborrar tot +showFixedPostForm: Mostrar el formulari de notes al principi de la línia de temps +unableToProcess: Aquesta operació no es pot acabar +recentUsed: Fet servir fa poc +install: Instal·lar +masterVolume: Volum principal +uninstall: Desinstal·lar +installedApps: Aplicacions autoritzades +nothing: No hi a res per veure +installedDate: Data d'autorització +details: Detalls +chooseEmoji: Selecciona un emoji +removeAllFollowingDescription: Fent això deixes de seguir tots els comptes de {host}. + Si us plau fes servir això sí, per exemple, el servidor deixa d'existir. +userSuspended: Aquest usuari ha sigut suspès. +lastUsedDate: Data d'últim ús +state: Estat +sort: Ordenar +ascendingOrder: Ascendent +descendingOrder: Descendent +scratchpad: Bloc de notes +scratchpadDescription: El bloc de notes proporciona un entorn per experiments amb + AiScript. Pots escriure, executar i comprovar els resultats interactuant amb Iceshrimp. +output: Sortida +script: Script +disablePagesScript: Desactivar AiScript a les pàgines +updateRemoteUser: Actualitzar la informació de l'usuari remot +deleteAllFiles: Esborrar tots els fitxers +deleteAllFilesConfirm: Segur que vols esborrar tots els fitxers? +removeAllFollowing: Deixar de seguir a tots els usuaris que segueixes +accentColor: Color principal +textColor: Color del text +value: Valor +sendErrorReportsDescription: "Quan està activat, quan es produeixi un problema la + informació detallada d'errors es compartirà amb Iceshrimp, ajudant a millorar la + qualitat de Iceshrimp.\nAixò inclourà informació com la versió del vostre sistema + operatiu, quin navegador utilitzeu, la vostra activitat a Iceshrimp, etc." +myTheme: El meu tema +backgroundColor: Color de fons +saveAs: Desar com… +advanced: Avançat +invalidValue: Valor invàlid. +createdAt: Data de creació +updatedAt: Data d'actualització +saveConfirm: Desa canvis? +deleteConfirm: De veritat ho vols esborrar? +receiveAnnouncementFromInstance: Rep notificacions d'aquest servidor +emailNotification: Notificacions per correu electrònic +publish: Publicar +inChannelSearch: Buscar al canal +useReactionPickerForContextMenu: Obrir el selector de reaccions al fer click esquerra +typingUsers: L'{users} està escrivint +oneDay: Un dia +instanceDefaultLightTheme: Tema de llum predeterminat per a tot el servidor +instanceDefaultDarkTheme: Tema fosc predeterminat per tot el servidor +instanceDefaultThemeDescription: Introdueix el codi JSON del tema. +mutePeriod: Durada del silenci +indefinitely: Permanentment +tenMinutes: 10 minuts +oneHour: Una hora +oneWeek: Una setmana +reflectMayTakeTime: Pot trigar una mica a reflectir-se. +thereIsUnresolvedAbuseReportWarning: Hi ha informes sense resoldre. +driveCapOverrideLabel: Canvieu la capacitat del disc per a aquest usuari +isSystemAccount: Aquest compte és creat i operat automàticament pel sistema. Si us + plau, no modereu, editeu, suprimiu o modifiqueu aquest compte de cap forma, o podria + trencar el vostre servidor. +typeToConfirm: Introduïu {x} per confirmar +deleteAccount: Suprimeix el compte +document: Documentació +sendPushNotificationReadMessage: Suprimeix les notificacions push un cop s'hagin llegit + les notificacions o missatges rellevants +sendPushNotificationReadMessageCaption: Es mostrarà una notificació amb el text "{emptyPushNotificationMessage}" + durant un breu temps. Això pot augmentar l'ús de la bateria del vostre dispositiu, + si escau. +showAds: Mostrar publicitat +enterSendsMessage: Pren retorn al formulari del missatge per enviar (quant no s'activa + es Ctrl + Return) +customMOTD: MOTD personalitzat (missatges de la pantalla de benvinguda) +customMOTDDescription: Missatges personalitzats per al MOTD (pantalla de benvinguda) + separats per salts de línia, es mostraran aleatòriament cada vegada que un usuari + carrega/recarrega la pàgina. +customSplashIcons: Icones personalitzades de la pantalla de benvinguda (urls) +customSplashIconsDescription: Les URLS de les icones personalitzades a la pantalla + de benvinguda separades per salts de línia. Es mostraran aleatòriament cada vegada + que un usuari carrega/recarrega la pàgina. Si us plau, assegureu-vos que les imatges + estiguin en una URL estàtica, preferiblement amb imatges amb la de 192 x 192. +moveFrom: Mou a aquest compte des d'un compte anterior +moveFromLabel: 'Compte des del qual us moveu:' +migrationConfirm: "Esteu absolutament segur que voleu migrar el vostre compte a {account}? + Un cop ho feu, no podreu revertir-ho i no podreu tornar a utilitzar el vostre compte + amb normalitat.\nA més, assegureu-vos d'haver configurat aquest compte actual com + el compte del qual us moveu." +defaultReaction: Reacció d'emoji predeterminada per a notes sortints i entrants +enableCustomKaTeXMacro: Activa les macros KaTeX personalitzades +noteId: ID de la publicació +_nsfw: + respect: Amaga els mitjans sensibles + ignore: No amagueu els mitjans sensibles + force: Amaga tots els mitjans +inUse: Utilitzat +ffVisibilityDescription: Et permet configurar qui pot veure a qui segueixes i qui + et segueix. +continueThread: Continuar el fil +reverse: Revés +objectStorageBucket: Cubell +objectStorageBucketDesc: Si us plau específica el nom del cubell que faràs servir + al teu proveïdor. +clip: Retall +createNew: Crear una nova +optional: Opcional +jumpToSpecifiedDate: Vés a una data concreta +showingPastTimeline: Ara es mostra un línea de temps antiga +clear: Netejar +markAllAsRead: Marcar tot com a llegit +recentPosts: Pàgines recents +noMaintainerInformationWarning: La informació de l'administrador no està configurada. +resolved: Resolt +unresolved: Sense resoldre +filter: Filtre +slow: Lenta +useDrawerReactionPickerForMobile: Mostra el selector de reaccions com a calaix al + mòbil +showLocalPosts: 'Mostra les notes locals a:' +homeTimeline: Línea de temps Inicial +socialTimeline: Línea de temps Social +themeColor: Color del Teletip del servidor +size: Mida +numberOfColumn: Nombre de columnes +numberOfPageCache: Nombre de pàgines emmagatzemades a la memòria cau +numberOfPageCacheDescription: L'augment d'aquest nombre millorarà la comoditat dels + usuaris, però provocarà més càrrega del servidor i utilitzarà més memòria. +logoutConfirm: Vols tancar la sessió? +lastActiveDate: Data d'últim ús +statusbar: Barra d'estat +pleaseSelect: Selecciona una opció +colored: Color +refreshInterval: "Interval d'actualització" +speed: Velocitat +cannotUploadBecauseExceedsFileSizeLimit: Aquest fitxer no s'ha pogut carregar perquè + supera la mida màxima permesa. +activeEmailValidationDescription: Permet una validació més estricta de les adreces + de correu electrònic, que inclou la comprovació d'adreces d'un sol ús i si realment + es pot comunicar amb elles. Quan no està marcat, només es valida el format del correu + electrònic. +shuffle: Barrejar +account: Compte +move: Moure +pushNotification: Notificacions push +subscribePushNotification: Activar les notificacions push +unsubscribePushNotification: Desactivar les notificacions push +pushNotificationAlreadySubscribed: Les notificacions push ja estan activades +pushNotificationNotSupported: El vostre navegador o servidor no admet notificacions + push +license: Llicència +indexPosts: Índex de notes +indexFrom: Índex a partir de l'ID de Publicacions +indexFromDescription: Deixeu en blanc per indexar cada publicació +indexNotice: Ara indexant. Això probablement trigarà una estona, si us plau, no reinicieu + el servidor durant almenys una hora. +_instanceTicker: + none: No mostrar mai + remote: Mostra per a usuaris remots + always: Mostra sempre +_serverDisconnectedBehavior: + nothing: No fer res + quiet: Mostra un avís discret + reload: Torna a carregar automàticament + dialog: Mostra el diàleg d'avís +_channel: + create: Crea un canal + edit: Edita el canal + setBanner: Establir bàner + removeBanner: Suprimeix el bàner + featured: Tendència + owned: Propietari + usersCount: '{n} Participants' + following: Seguit per + notesCount: '{n} Notes' + nameAndDescription: Nom i descripció + nameOnly: Només nom +_instanceMute: + instanceMuteDescription: Això silenciara les publicacions o els impulsos dels servidors + indicats, incloses les dels usuaris que responguin a un usuari des d'un servidor + silenciat. + title: Amaga les publicacions dels servidors a la llista. + instanceMuteDescription2: Separar amb noves línies + heading: Llista de servidors que cal silenciar +_ago: + future: Futur + justNow: Ara mateix + minutesAgo: "Fa {n}m {n2}s" + hoursAgo: "Fa {n}h {n2}m" + daysAgo: "Fa {n}d {n2}h" + secondsAgo: Fa {n}s + weeksAgo: "Fa {n}set {n2}d" + monthsAgo: "Fa {n}me {n2}set" + yearsAgo: "Fa {n}a {n2}me" +_time: + second: Segon(s) + minute: Minut(s) + hour: Hora(s) + day: Dia(s) +_tutorial: + step5_4: La línea de temps Local {icon} és on pots veure les publicacions de tots + els altres usuaris d'aquest servidor. + step5_2: El teu servidor té activades {timelines} diferents. + step5_3: La línea de temps d'inici {icon} es on pots veure les publicacions dels + comptes que segueixes. + step5_6: La línia de temps de Recomanats {icon} és on pots veure les publicacions + dels servidors que recomanen els administradors. + step5_7: La línia de temps Global {icon} és on pots veure les publicacions de tots + els servidors connectats. + step6_1: Aleshores, què és aquest lloc? + step6_4: Ara ves, explora i diverteix-te! + step1_2: Anem a fer la configuració. Estaràs en funcionament en un tres i no res! + title: Com utilitzar Iceshrimp + step1_1: Benvingut! + step2_1: En primer lloc, empleneu el vostre perfil. + step4_1: Anem a treure't allà fora. + step5_5: La línea de temps Social {icon} és una combinació de les línies de temps + d'Inici i Local. + step6_3: Cada servidor funciona de diferents maneres, i no tots els servidors executen + Iceshrimp. Aquest sí que sí! És una mica complicat, però ho aconseguiràs en poc + temps. + step2_2: Proporcionar informació sobre qui sou facilitarà que altres puguin saber + si volen veure les vostres notes o seguir-vos. + step3_1: Ara toca seguir a algunes persones! + step3_2: "Les teves líneas de temps d'inici i social es basen en qui seguiu, així + que proveu de seguir un parell de comptes per començar.\nFeu clic al cercle més + situat a la part superior dreta d'un perfil per seguir-los." + step4_2: A algunes persones els agrada fer una publicació de {introduction} o un + senzill "Hola món!" + step5_1: Línies de temps, línies de temps a tot arreu! + step6_2: Bé, no només t'has unit a Iceshrimp. T'has unit a un portal al Fediverse, + una xarxa interconnectada de milers de servidors. +_permissions: + "read:account": Consulta la informació del teu compte + "read:blocks": Consulta la teva llista d'usuaris bloquejats + "write:account": Editar la informació del compte + "read:drive": Accedir als fitxers i carpetes del Disc + "read:messaging": Consulta els teus xats + "write:following": Segueix o deixa de seguir altres comptes + "write:mutes": Editar la teva llista d'usuaris silenciats + "read:notifications": Consulta les teves notificacions + "write:notifications": Gestiona les teves notificacions + "write:user-groups": Editar o suprimir grups d'usuaris + "write:blocks": Editar la llista d'usuaris bloquejats + "write:notes": Redactar o suprimir notes + "write:channels": Editar els teus canals + "read:gallery-likes": Consulta la llista de notes que t'agraden de la galeria + "write:drive": Editar o suprimir fitxers i carpetes del Disc + "read:favorites": Consulta la teva llista d'adreces d'interès + "write:favorites": Editeu la teva llista d'adreces d'interès + "write:messaging": Escriu o suprimeix missatges de xat + "read:mutes": Consulta la teva llista d'usuaris silenciats + "write:reactions": Edita les teves reaccions + "write:votes": Vota en una enquesta + "write:pages": Edita o suprimeix la teva pàgina + "write:page-likes": Editar les pàgines que t'agraden + "read:user-groups": Consulta els teus grups d'usuaris + "read:channels": Consulta els teus canals + "read:gallery": Consulta la teva galeria + "write:gallery": Edita la teva galeria + "write:gallery-likes": Edita la llista de notes que t'agraden de la galeria + "read:following": Consulta la informació sobre a qui segueixes + "read:reactions": Consulta les teves reaccions + "read:pages": Consulta la teva pàgina + "read:page-likes": Veure les pàgines que t'agraden +_poll: + noOnlyOneChoice: Calen almenys dues opcions + canMultipleVote: Permet seleccionar diverses opcions + expiration: Finalitzar l'enquesta + after: Acaba després... + duration: Durada + votesCount: '{n} vots' + totalVotes: '{n} vots en total' + showResult: Veure resultats + choiceN: Opció {n} + noMore: No es poden afegir més opcions + infinite: Mai + at: Acaba el... + deadlineDate: Data de finalització + deadlineTime: Temps + remainingHours: Queden {h} hora(s) {m} minut(s) + remainingDays: Queden {d} dia(s) {h} hores + remainingMinutes: Queden {m} minut(s) {s} segons + voted: Votat + closed: S'ha acabat + remainingSeconds: Queden {s} segons + vote: Vota +_postForm: + _placeholders: + d: Què vols dir? + e: Comença a escriure... + f: Esperant que escriguis... + b: Què passa al teu voltant? + c: En què penses? + a: Què et portes entre mans? + quotePlaceholder: Cita aquesta publicació... + replyPlaceholder: Respon a aquesta publicació... + channelPlaceholder: Publica en un canal... +_charts: + federation: Federació + usersIncDec: Diferència en el nombre d'usuaris + apRequest: Sol·licituds + usersTotal: Nombre total d'usuaris + activeUsers: Usuaris actius + notesIncDec: Diferència en el nombre de notes + localNotesIncDec: Diferència en el nombre de notes locals + remoteNotesIncDec: Diferència en el nombre de notes remotes + notesTotal: Nombre total de notes + filesIncDec: Diferència en el nombre de fitxers + filesTotal: Nombre total de fitxers + storageUsageTotal: Ús total d'emmagatzematge + storageUsageIncDec: Diferència en l'ús d'emmagatzematge +_instanceCharts: + requests: Sol·licituds + users: Diferència en el nombre d'usuaris + usersTotal: Nombre acumulat d'usuaris + notes: Diferència en el nombre de notes + ffTotal: Nombre acumulat d'usuaris que segueixes/et segueixen + cacheSize: Diferència en la mida de la memòria cau + cacheSizeTotal: Mida total acumulada de la memòria cau + files: Diferència en el nombre de fitxers + filesTotal: Nombre acumulat de fitxers + notesTotal: Nombre acumulat de notes + ff: "Diferència en el nombre d'usuaris que segueixes/que et segueixen " +_timelines: + home: Inici + local: Local + recommended: Recomanat + social: Social + global: Global +_menuDisplay: + hide: Amagar + top: Superior + sideFull: Costat + sideIcon: Costat (Icones) +_wordMute: + muteWords: Paraules silenciades + muteWordsDescription: Separeu amb espais per a una condició AND o amb salts de línia + per a una condició OR. + soft: Suau + hard: Dur + muteWordsDescription2: Envolta les paraules clau amb barres inclinades per utilitzar + expressions regulars. + softDescription: Amaga les notes que compleixen les condicions establertes de la + línia de temps. + hardDescription: Evita que les notes que compleixin les condicions establertes s'afegeixin + a la línia de temps. A més, aquestes notes no s'afegiran a la línia de temps encara + que es modifiquin les condicions. + mutedNotes: Notes silenciades +_auth: + shareAccessAsk: Estàs segur que vols autoritzar aquesta aplicació per accedir al + teu compte? + shareAccess: Vols autoritzar "{name}" per accedir a aquest compte? + permissionAsk: 'Aquesta aplicació sol·licita els següents permisos:' + callback: Tornant a l'aplicació + denied: Accés denegat + pleaseGoBack: Si us plau, torneu a l'aplicació + copyAsk: "Posa el següent codi d'autorització a l'aplicació:" + allPermissions: Accés complet al compte +_weekday: + wednesday: Dimecres + saturday: Dissabte + monday: Dilluns + tuesday: Dimarts + friday: Divendres + sunday: Diumenge + thursday: Dijous +_messaging: + groups: Grups + dms: Privat +_antennaSources: + all: Totes les notes + homeTimeline: Publicacions dels usuaris que segueixes + users: Notes d'usuaris concrets + userGroup: Notes d'usuaris d'un grup determinat + userList: Notes d'una llista determinada d'usuaris + instances: Publicacions de tots els usuaris d'un servidor +_relayStatus: + requesting: Pendent + accepted: Acceptat + rejected: Rebutjat +deleted: Eliminat +editNote: Edita la nota +edited: 'Editat a {date} {time}' +findOtherInstance: Cercar un altre servidor +signupsDisabled: Actualment, les inscripcions en aquest servidor estan desactivades, + però sempre podeu registrar-vos en un altre servidor. Si teniu un codi d'invitació + per a aquest servidor, introduïu-lo a continuació. +userSaysSomethingReasonQuote: '{name} ha citat una publicació que conté {reason}' +userSaysSomethingReasonReply: '{name} ha respost a una publicació que conté {reason}' +userSaysSomethingReasonRenote: '{name} ha impulsat una publicació que conté {reason}' +highlightCw: Ressalta el contingut de les publicacions advertides +apps: Aplicacions +sendModMail: Envia avís de moderació +preventAiLearning: Evita l'indexació dels bots +preventAiLearningDescription: Sol·liciteu que els models de llenguatge d'IA de tercers + no estudiïn el contingut que pengeu, com ara publicacions i imatges. +pwa: Instal·lar PWA +noGraze: Si us plau, desactiva l'extensió del navegador "Graze for Mastodon", ja que + interfereix amb Iceshrimp. +accessibility: Accessibilitat +jumpToReply: Vés a la resposta +newer: Més nou +older: Més antic +silencedWarning: S'està mostrant aquesta pàgina per què aquest usuari és d'un servidor + que l'administrador a silenciat, així que pot ser spam. +jumpToPrevious: Vés a l'anterior +cw: Avís de contingut +antennasDesc: "Les antenes mostren publicacions noves que coincideixen amb els criteris + establerts!\nS'hi pot accedir des de la pàgina de línies de temps." +expandOnNoteClick: Obre la publicació amb un clic +expandOnNoteClickDesc: Si està desactivat, encara pots obrir les publicacions al menú + del botó dret o fent clic a la marca de temps. +channelFederationWarn: Els canals encara no es federen amb altres servidors +searchPlaceholder: Cerca al Fediverse +listsDesc: Les llistes et permeten crear línies de temps amb usuaris específics. Es + pot accedir des de la pàgina de línies de temps. +clipsDesc: Els clips són com marcadors categoritzats que es poden compartir. Podeu + crear clips des del menú de publicacions individuals. +selectChannel: Selecciona un canal +isLocked: Aquest compte té les següents aprovacions +isPatron: Mecenes de Calkey +isBot: Aquest compte és un bot +isModerator: Moderador +isAdmin: Administrador +_filters: + fromDomain: Des del domini + notesBefore: Publicacions anteriors + notesAfter: Publicacions posteriors + followingOnly: Només seguint + followersOnly: Només seguidors + withFile: Amb arxiu + fromUser: De l'usuari + _dialog: + learnMore: Veure la sintaxi del filtre + wordFilters: Filtrar per el text de la publicació + inFilters: Filtra per marcador i/o estat preferit + miscFilters: Filtra per seguiment i/o tipus de publicació + userDomain: Filtra per autor, usuaris esmentats, usuari contestat o domini de + l'instancia + postDate: Filtra per data de publicació + exclusivity: 'Tingues en compte que abans:filtre és excloïen, mentres que després: + filtre és inclusiu.' + word: Paraula + phrase: Frase literal que conté (arbitràriament) caràcters + attachmentType: Filtra per tipus d'ajunt(s) + info: Nomenclatura + title: Sintaxi del filtre de cerca + matchOptions: Canvia la sensibilitat de majúscules i minúscules i/o habilita la + coincidència de paraules senceres +image: Imatge +video: Vídeo +audio: Àudio +_dialog: + charactersExceeded: "S'han superat el màxim de caràcters! Actual: {current}/Límit: + {max}" + charactersBelow: 'No hi ha caràcters suficients! Corrent: {current}/Limit: {min}' +removeReaction: Elimina la teva reacció +reactionPickerSkinTone: To de pell d'emoji preferit +alt: ALT +_skinTones: + light: Clar + mediumLight: Clar Mitx + medium: Mitx + mediumDark: Fosc Mitx + dark: Fosc + yellow: Groc +swipeOnMobile: Permet lliscar entre pàgines +enableIdenticonGeneration: Habilitar la generació d'Identicon +enableServerMachineStats: Habilitar les estadístiques del maquinari del servidor +showPopup: Notificar els usuaris amb una finestra emergent +showWithSparkles: Mostra amb espurnes +youHaveUnreadAnnouncements: Tens anuncis sense llegir +xl: XL +donationLink: Enllaç a la pàgina de donacions +neverShow: No tornis a mostrar +remindMeLater: Potser després +removeMember: Elimina el membre +removeQuote: Elimina la cita +removeRecipient: Elimina el destinatari +verifiedLink: Enllaç verificat +_feeds: + rss: RSS + atom: Atom + jsonFeed: Feed JSON + copyFeed: Copiar feed +collapseAllCws: Ocultar el contingut de totes les respostes +searchNotLoggedIn_2: No obstant això, pots cercar fent servir etiquetes, i cercar + usuaris. +searchEmptyQuery: Introdueix un terme de cerca. +antennaTimelineHint: Les Antenes mostren les publicacions en ordre d'entrada, que + no necessàriament ha de ser cronològic. +expandAllCws: Mostrar el contingut de totes les respostes +cannotChangeScopeWhenEditing: No pots canviar la visibilitat d'aquesta publicació + mentre edites +openInMainColumn: Obrir a la columna principal +searchNotLoggedIn_1: Has d'estar autenticat per poder utilitzar la cerca de text complet. diff --git a/locales/cs-CZ.yml b/locales/cs-CZ.yml new file mode 100644 index 0000000..373cfa5 --- /dev/null +++ b/locales/cs-CZ.yml @@ -0,0 +1,1009 @@ +_lang_: "Čeština" +headlineIceshrimp: "Síť propojená poznámkami" +introIceshrimp: "Vítejte! Iceshrimp je otevřený a decentralizovaný microblogový servis.\n\ + \"Poznámkami\" můžete sdílet co se zrovna děje se všemi ve Vašem okolí. \U0001F4E1\ + \nPomocí \"reakcí\" můžete sdílet své názory a pocity na ostatní poznámky. \U0001F44D\ + \nPojďte objevovat nový svět! \U0001F680" +monthAndDay: "{day}. {month}." +search: "Vyhledávání" +notifications: "Oznámení" +username: "Uživatelské jméno" +password: "Heslo" +forgotPassword: "Zapomenuté heslo" +fetchingAsApObject: "Načítám data z Fediversu" +ok: "Potvrdit" +gotIt: "Rozumím!" +cancel: "Zrušit" +enterUsername: "Zadej uživatelské jméno" +renotedBy: "{user} přeposla/a" +noNotes: "Žádné poznámky" +noNotifications: "Žádná oznámení" +instance: "Instance" +settings: "Nastavení" +basicSettings: "Obecná nastavení" +otherSettings: "Rozšířená nastavení" +openInWindow: "Otevřít v novém okně" +profile: "Váš profil" +timeline: "Časová osa" +noAccountDescription: "Tento uživatel zatím nenapsal svou biografii." +login: "Přihlásit se" +loggingIn: "Probíhá přihlašování" +logout: "Odhlásit" +signup: "Registrace" +uploading: "Nahrávám" +save: "Uložit" +users: "Uživatelé" +addUser: "Přidat uživatele" +favorite: "Oblíbené" +favorites: "Oblíbené" +unfavorite: "Odebrat z oblízených" +favorited: "Přidáno do oblíbených" +alreadyFavorited: "Už je mezi oblíbenými" +cantFavorite: "Nepodařilo se přidat mezi oblíbené." +pin: "Připnout" +unpin: "Odepnout" +copyContent: "Zkopírovat obsah" +copyLink: "Kopírovat odkaz" +delete: "Smazat" +deleteAndEdit: "Smazat a upravit" +deleteAndEditConfirm: "Jste si jistí že chcete smazat tuto poznámku a editovat ji?\ + \ Ztratíte tím všechny reakce, sdílení a odpovědi na ni." +addToList: "Přidat do seznamu" +sendMessage: "Odeslat zprávu" +copyUsername: "Kopírovat uživatelské jméno" +searchUser: "Vyhledat uživatele" +reply: "Odpovědět" +loadMore: "Zobrazit více" +showMore: "Zobrazit více" +showLess: "Zavřít" +youGotNewFollower: "Máte nového následovníka" +receiveFollowRequest: "Žádost o sledování přijata" +followRequestAccepted: "Žádost o sledování přijata" +mention: "Zmínění" +mentions: "Zmínění" +importAndExport: "Import a export" +import: "Importovat" +export: "Exportovat" +files: "Soubor(ů)" +download: "Stáhnout" +driveFileDeleteConfirm: "Opravdu chcete smazat soubor \"{name}\"? Soubor bude odstraněn\ + \ ze všech příspěvků, které ji obsahují jako přílohu." +unfollowConfirm: "Jste si jisti že už nechcete sledovat {name}?" +exportRequested: "Požádali jste o export. To může chvíli trvat. Přidáme ho na váš\ + \ Disk až bude dokončen." +importRequested: "Požádali jste o export. To může chvilku trvat." +lists: "Seznamy" +noLists: "Nemáte žádné seznamy" +note: "Poznámka" +notes: "Poznámky" +following: "Sledovaní" +followers: "Sledující" +followsYou: "Sledují vás" +createList: "Vytvořit seznam" +manageLists: "Spravovat seznam" +error: "Chyba" +somethingHappened: "Jejda. Něco se nepovedlo." +retry: "Opakovat" +pageLoadError: "Nepodařilo se načíst stránku" +serverIsDead: "Server neodpovídá. Počkejte chvíli a zkuste to znovu." +youShouldUpgradeClient: "Pro zobrazení této stránky obnovte stránku pro aktualizaci\ + \ klienta." +enterListName: "Jméno seznamu" +privacy: "Soukromí" +makeFollowManuallyApprove: "Žádosti o sledování vyžadují potvrzení" +defaultNoteVisibility: "Výchozí viditelnost" +follow: "Sledovaní" +followRequest: "Odeslat žádost o sledování" +followRequests: "Žádosti o sledování" +unfollow: "Přestat sledovat" +followRequestPending: "Čekající žádosti o sledování" +enterEmoji: "Vložte emoji" +renote: "Přeposlat" +unrenote: "Zrušit přeposlání" +renoted: "Přeposláno" +cantRenote: "Tento příspěvek nelze přeposlat." +cantReRenote: "Odpověď nemůže být odstraněna." +quote: "Citovat" +pinnedNote: "Připnutá poznámka" +pinned: "Připnout" +you: "Vy" +clickToShow: "Klikněte pro zobrazení" +sensitive: "NSFW" +add: "Přidat" +reaction: "Reakce" +reactionSettingDescription2: "Přetažením změníte pořadí, kliknutím smažete, zmáčkněte\ + \ \"+\" k přidání" +rememberNoteVisibility: "Zapamatovat nastavení zobrazení poznámky" +attachCancel: "Odstranit přílohu" +markAsSensitive: "Označit jako NSFW" +unmarkAsSensitive: "Odznačit jako NSFW" +enterFileName: "Zadejte název souboru" +mute: "Ztlumit" +unmute: "Odmlčet" +block: "Zablokovat" +unblock: "Odblokovat" +suspend: "Zmrazit" +unsuspend: "Odmrazit" +blockConfirm: "Jste si jistí že chcete zablokovat tento účet?" +unblockConfirm: "Jste si jistí že chcete odblokovat tento účet?" +suspendConfirm: "Jste si jistí že chcete suspendovat tenhle účet?" +unsuspendConfirm: "Jste si jistí že chcete obnovit tenhle účet?" +selectList: "Vybrat seznam" +selectAntenna: "Vyberte Anténu" +selectWidget: "Zvolte widget" +editWidgets: "Upravit widget" +editWidgetsExit: "Hotovo" +customEmojis: "Vlastní emoji" +emoji: "Emoji" +emojis: "Emoji" +emojiName: "Jméno emoji" +emojiUrl: "URL obrázku" +addEmoji: "Přidat emoji" +settingGuide: "Doporučené nastavení" +cacheRemoteFiles: "Ukládání vzdálených souborů do mezipaměti" +cacheRemoteFilesDescription: "Zakázání tohoto nastavení způsobí, že vzdálené soubory\ + \ budou odkazovány přímo, místo aby byly ukládány do mezipaměti. Tím se ušetří úložiště\ + \ na serveru, ale zvýší se provoz, protože se negenerují miniatury." +flagAsBot: "Tento účet je bot" +flagAsBotDescription: "Pokud je tento účet kontrolován programem zaškrtněte tuto možnost.\ + \ To označí tento účet jako bot pro ostatní vývojáře a zabrání tak nekonečným interakcím\ + \ s ostatními boty a upraví Iceshrimp systém aby se choval k tomuhle účtu jako bot." +flagAsCat: "Tenhle účet je kočka" +flagAsCatDescription: "Vyberte tuto možnost aby tento účet byl označen jako kočka." +flagShowTimelineReplies: "Zobrazovat odpovědi na časové ose" +flagShowTimelineRepliesDescription: "Je-li zapnuto, zobrazí odpovědi uživatelů na\ + \ poznámky jiných uživatelů na vaší časové ose." +autoAcceptFollowed: "Automaticky akceptovat následování od účtů které sledujete" +addAccount: "Přidat účet" +loginFailed: "Přihlášení se nezdařilo." +showOnRemote: "Více na původním profilu" +general: "Obecně" +wallpaper: "Obrázek na pozadí" +setWallpaper: "Nastavení obrázku na pozadí" +removeWallpaper: "Odstranit pozadí" +searchWith: "Hledat: {q}" +youHaveNoLists: "Nemáte žádné seznamy" +followConfirm: "Jste si jisti, že chcete sledovat {name}?" +proxyAccount: "Proxy účet" +proxyAccountDescription: "Proxy účet je účet, který za určitých podmínek sleduje uživatele\ + \ na dálku vaším jménem. Například když uživatel zařadí vzdáleného uživatele do\ + \ seznamu, pokud nikdo nesleduje uživatele na seznamu, aktivita nebude doručena\ + \ instanci, takže místo toho bude uživatele sledovat účet proxy." +host: "Hostitel" +selectUser: "Vyberte uživatele" +recipient: "Pro" +annotation: "Komentáře" +federation: "Federace" +instances: "Instance" +registeredAt: "Registrován" +latestRequestSentAt: "Poslední požadavek poslán" +latestRequestReceivedAt: "Poslední požadavek přijat" +latestStatus: "Poslední status" +storageUsage: "Využití úložiště" +charts: "Grafy" +perHour: "za hodinu" +perDay: "za den" +stopActivityDelivery: "Přestat zasílat aktivitu" +blockThisInstance: "Blokovat tuto instanci" +operations: "Operace" +software: "Software" +version: "Verze" +metadata: "Metadata" +monitor: "Monitorovat" +jobQueue: "Fronta úloh" +cpuAndMemory: "CPU a paměť" +network: "Síť" +disk: "Disk" +instanceInfo: "Informace o instanci" +statistics: "Statistiky" +clearQueue: "Vyčistit frontu" +clearQueueConfirmTitle: "Jste si jisti že zrušit všechny úlohy ve frontě?" +clearCachedFiles: "Vyprázdnit mezipaměť" +blockedInstances: "Blokované instance" +noUsers: "Žádní uživatelé" +editProfile: "Upravit můj profil" +pinLimitExceeded: "Nemůžete připnout další poznámky." +intro: "Instalace Iceshrimp byla dokončena! Prosím vytvořte admina." +done: "Hotovo" +processing: "Zpracovávám" +preview: "Náhled" +default: "Výchozí" +noCustomEmojis: "Bez Emoji" +blocked: "Blokováno" +suspended: "Suspendováno" +all: "Vše" +subscribing: "Odebíráte" +publishing: "Publikuji" +notResponding: "Neodpovídá" +instanceFollowing: "Následovníci na instanci" +instanceFollowers: "Následovníci na instanci" +instanceUsers: "Uživatelé této instance" +changePassword: "Změnit heslo" +security: "Zabezpečení" +retypedNotMatch: "Zadané údaje se neshodují." +currentPassword: "Současné heslo" +newPassword: "Nové heslo" +newPasswordRetype: "Nové heslo (znovu)" +attachFile: "Přiložit soubor" +more: "Více!" +featured: "Oblíbené poznámky" +usernameOrUserId: "Uživatelské jméno nebo uživatelské id" +noSuchUser: "Uživatel nebyl nalezen" +announcements: "Oznámení" +imageUrl: "URL obrázku" +remove: "Smazat" +removed: "Smazáno" +removeAreYouSure: "Jste si jistí že chcete smazat \"{x}\"?" +deleteAreYouSure: "Jste si jistí že chcete smazat \"{x}\"?" +resetAreYouSure: "Opravdu resetovat?" +saved: "Uloženo" +messaging: "Zprávy" +upload: "Nahrát soubory" +fromDrive: "Z disku" +fromUrl: "Z URL" +uploadFromUrl: "Nahrát z URL adresy" +uploadFromUrlDescription: "URL adresa souboru, který chcete nahrát" +uploadFromUrlMayTakeTime: "Může trvat nějakou dobu, dokud nebude dokončeno nahrávání." +explore: "Objevovat" +messageRead: "Přečtené" +noMoreHistory: "To je vše" +startMessaging: "Zahájit chat" +nUsersRead: "přečteno {n} uživateli" +agreeTo: "Souhlasím s {0}" +tos: "Podmínky užívání" +start: "Začít" +home: "Domů" +remoteUserCaution: "Tyto informace nemusí být aktuální jelikož uživatel je ze vzdálené\ + \ instance." +activity: "Aktivita" +images: "Obrázky" +birthday: "Datum narození" +yearsOld: "{age} let" +registeredDate: "Datum registrace" +location: "Lokace" +theme: "Vzhled" +themeForLightMode: "Vzhled pro použití ve světlém režimu" +themeForDarkMode: "Vzhled k použití v tmavém režimu" +light: "Světlý" +dark: "Tmavý" +lightThemes: "Světlý vzhled" +darkThemes: "Tmavý vzhled" +syncDeviceDarkMode: "Synchronizovat tmavý vzhled s nastavením Vašeho systému" +drive: "Úložiště" +fileName: "Název souboru" +selectFile: "Vybrat soubor" +selectFiles: "Vybrat soubory" +selectFolder: "Vyberte složku" +selectFolders: "Vyberte složky" +renameFile: "Přejmenovat soubor" +folderName: "Název složky" +createFolder: "Vytvořit složku" +renameFolder: "Přejmenovat složku" +deleteFolder: "Odstranit složku" +addFile: "Přidat soubor" +emptyFolder: "Tato složka je prázdná" +unableToDelete: "Nelze smazat" +inputNewFileName: "Zadejte nový název" +inputNewFolderName: "Zadejte název nové složky" +copyUrl: "Kopírovat URL" +rename: "Přejmenovat" +avatar: "Avatar" +banner: "Baner" +nsfw: "NSFW" +disconnectedFromServer: "Spojení bylo přerušeno" +reload: "Aktualizovat" +doNothing: "Ignorovat" +watch: "Sledovat" +unwatch: "Přestat sledovat" +accept: "Souhlasím" +reject: "Odmítnout" +normal: "Normální" +instanceName: "Název instance" +instanceDescription: "Popis instance" +maintainerName: "Správce" +maintainerEmail: "E-mailová adresa správce" +tosUrl: "URL pro smluvní podmínky" +thisYear: "Tento rok" +thisMonth: "Tento měsíc" +today: "Dnes" +dayX: "{day}" +monthX: "{month}" +yearX: "{year}" +pages: "Stránky" +integration: "Integrace" +connectService: "Připojit" +disconnectService: "Odpojit" +enableLocalTimeline: "Povolit lokální čas" +enableGlobalTimeline: "Povolit globální čas" +registration: "Registrace" +enableRegistration: "Povolit registraci novým uživatelům" +invite: "Pozvat" +inMb: "V megabajtech" +iconUrl: "Favicon URL" +bannerUrl: "Baner URL" +backgroundImageUrl: "Adresa URL obrázku pozadí" +basicInfo: "Základní informace" +pinnedUsers: "Připnutí uživatelé" +pinnedNotes: "Připnutá poznámka" +hcaptcha: "hCaptcha" +enableHcaptcha: "Aktivovat hCaptchu" +hcaptchaSiteKey: "Klíč stránky" +hcaptchaSecretKey: "Tajný Klíč (Secret Key)" +recaptcha: "reCAPTCHA" +enableRecaptcha: "Zapnout ReCAPTCHu" +recaptchaSiteKey: "Klíč stránky" +recaptchaSecretKey: "Tajný Klíč (Secret Key)" +antennas: "Antény" +manageAntennas: "Spravovat Antény" +name: "Jméno" +antennaSource: "Zdroj Antény" +enableServiceworker: "Povolit ServiceWorker" +caseSensitive: "Rozlišuje malá a velká písmena" +connectedTo: "Následující účty jsou připojeny" +notesAndReplies: "Poznámky a odpovědi" +withFiles: "Včetně souborů" +popularUsers: "Populární uživatelé" +recentlyUpdatedUsers: "Nedávno aktívni uživatelé" +popularTags: "Populární tagy" +userList: "Seznamy" +about: "Informace" +aboutIceshrimp: "O Iceshrimp" +administrator: "Administrátor" +token: "Token" +twoStepAuthentication: "Dvoufaktorová autentikace" +moderator: "Moderátor" +nUsersMentioned: "{n} uživatelů zmínilo" +securityKey: "Bezpečnostní klíč" +securityKeyName: "Název klíče" +registerSecurityKey: "Registrovat bezpečnostní klíč" +lastUsed: "Naposledy použito" +unregister: "Odstranit" +resetPassword: "Resetovat heslo" +newPasswordIs: "Nové heslo je \"{password}\"" +reduceUiAnimation: "Snížit UI animace" +share: "Sdílet" +notFound: "Nenalezeno" +notFoundDescription: "Nebyla nalezená žádná stránka korespondující se zadanou URL." +uploadFolder: "Výchozí lokace pro upload" +cacheClear: "Vymazat cache" +markAsReadAllNotifications: "Označit všechna oznámení za přečtená" +markAsReadAllUnreadNotes: "Označit všechny příspěvky za přečtené" +markAsReadAllTalkMessages: "Označit všechny zprávy za přečtené" +help: "Nápověda" +inputMessageHere: "Sem zadejte zprávu" +close: "Zavřít" +group: "Skupina" +groups: "Skupiny" +createGroup: "Vytvořit skupinu" +ownedGroups: "Vlastněné skupiny" +joinedGroups: "Členství ve skupinách" +invites: "Pozvat" +groupName: "Název skupiny" +members: "Členové" +transfer: "Převod" +title: "Titulek" +text: "Text" +enable: "Povolit" +next: "Další" +retype: "Zadejte znovu" +noteOf: "{user} poznámky" +inviteToGroup: "Pozvat do skupiny" +quoteAttached: "Citace" +quoteQuestion: "Přiložit jako citaci?" +noMessagesYet: "Zatím tu nejsou žádné zprávy" +newMessageExists: "Máte novou zprávu" +onlyOneFileCanBeAttached: "Ke zprávě můžete přiložit jenom jeden soubor" +signinRequired: "Přihlašte se, prosím" +invitations: "Pozvat" +invitationCode: "Kód pozvánky" +checking: "Ověřuji" +available: "K dispozici" +unavailable: "Není k dispozici" +usernameInvalidFormat: "Písmena, čísla a _ jsou povolená." +tooShort: "Příliš krátké" +tooLong: "Příliš dlouhé" +weakPassword: "Slabé heslo" +normalPassword: "Dobré heslo" +strongPassword: "Silné heslo" +passwordMatched: "Hesla se schodují" +passwordNotMatched: "Hesla se neschodují" +signinWith: "Přihlásit se s {x}" +signinFailed: "Nelze se přihlásit. Zkontrolujte prosím své uživatelské jméno a heslo." +tapSecurityKey: "Ťukněte na bezpečnostní klíč" +or: "Nebo" +language: "Jazyk" +uiLanguage: "Jazyk uživatelského rozhraní" +groupInvited: "Pozvat do skupiny" +aboutX: "O {x}" +useOsNativeEmojis: "Použití nativních emoji operačního systému" +youHaveNoGroups: "Nemáte žádné skupiny" +joinOrCreateGroup: "Můžete požádat o pozvání do stávající skupiny nebo vytvořit novou." +noHistory: "Žádná historie" +signinHistory: "Historie přihlášení" +category: "Kategorie" +tags: "Štítky" +createAccount: "Vytvořit účet" +existingAccount: "Existující účet" +regenerate: "Obnovit" +fontSize: "Velikost písma" +openImageInNewTab: "Otevřít obrázek v novém panelu" +dashboard: "Přehled" +local: "Lokální" +remote: "Vzdálené" +total: "Celkem" +weekOverWeekChanges: "Týdně" +dayOverDayChanges: "Denně" +appearance: "Vzhled" +clientSettings: "Nastavení klienta" +accountSettings: "Nastavení účtu" +promotion: "Propagace" +promote: "Propagovat" +numberOfDays: "Počet dní" +objectStorageBaseUrl: "Base URL" +objectStorageBucket: "Bucket" +objectStoragePrefix: "Předpona" +objectStorageEndpoint: "Endpoint" +objectStorageRegion: "Región" +objectStorageUseSSL: "Použít SSL" +deleteAll: "Smazat vše" +showFixedPostForm: "Zobrazit formulář pro nové příspěvky nad časovou osou" +listen: "Poslouchat" +showInPage: "Zobrazit na stránce" +popout: "Pop-out" +volume: "Hlasitost" +masterVolume: "Celková hlasitost" +details: "Detaily" +chooseEmoji: "Vybrat emotikon" +unableToProcess: "Operace nebyla dokončena." +recentUsed: "Naposledy použité" +install: "Nainstalovat" +uninstall: "Odinstalovat" +installedApps: "Autorizované aplikace" +nothing: "Nic nebylo nalezeno" +lastUsedDate: "Poslední použití" +state: "Stav" +sort: "Seřadit" +ascendingOrder: "Vzestupně" +descendingOrder: "Sestupně" +scratchpad: "Zápisník" +output: "Výstup" +script: "Skript" +updateRemoteUser: "Aktualizovat informace o vzdáleném účtu" +deleteAllFiles: "Smazat všechny soubory" +deleteAllFilesConfirm: "Jste si jistí že chcete smazat všechny soubory?" +userSuspended: "Tomuto uživateli byl pozastaven účet." +menu: "Menu" +divider: "Dělící čára" +addItem: "Přidat položku" +relays: "Relay" +addRelay: "Přidat Relay" +inboxUrl: "Inbox URL" +deletedNote: "Odstraněné příspěvky" +invisibleNote: "Skryté příspěvky" +description: "Popis" +author: "Autor" +manage: "Administrace" +width: "Šířka" +height: "Výška" +large: "Velké" +medium: "Střední" +small: "Malé" +generateAccessToken: "Vygenerovat přístupový token" +permission: "Oprávnění" +enableAll: "Povolit vše" +disableAll: "Vypnout vše" +notificationType: "Typy oznámení" +edit: "Upravit" +emailServer: "Mailový server" +enableEmail: "Zapnout email dystribuci" +email: "Email" +emailAddress: "Emailová adresa" +smtpConfig: "Konfigurace SMTP serveru" +smtpHost: "Hostitel" +smtpPort: "Port" +smtpUser: "Uživatelské jméno" +smtpPass: "Heslo" +smtpSecureInfo: "Toto vypněte pokud používáte STARTTLS" +testEmail: "Otestovat doručení emailů" +makeActive: "Aktivovat" +display: "Zobrazit" +copy: "Kopírovat" +metrics: "Metriky" +overview: "Shrnutí" +logs: "Logy" +delayed: "Prodleva" +database: "Databáze" +channel: "Kanály" +create: "Vytvořit" +notificationSetting: "Nastavení oznámení" +useGlobalSetting: "Použít globální nastavení" +other: "Ostatní" +fileIdOrUrl: "ID nebo URL souboru" +behavior: "Chování" +sample: "Ukázka" +send: "Odeslat" +openInNewTab: "Otevřít v nové kartě" +random: "Náhodně" +system: "Systém" +desktop: "Plocha" +clip: "Oříznout" +createNew: "Vytvořit nový" +optional: "Volitelné" +yes: "Ano" +no: "Ne" +notSet: "Není nastaveno" +emailVerified: "Váš e-mail byl ověřen" +contact: "Kontakt" +useSystemFont: "Použít výchozí font systému" +clips: "Oříznout" +experimentalFeatures: "Experimentální funkce" +developer: "Vývojář" +duplicate: "Duplikovat" +left: "Vlevo" +center: "Uprostřed" +wide: "Široké" +narrow: "Úzké" +clearCache: "Vyprázdnit mezipaměť" +nUsers: "{n} užívatelů" +nNotes: "{n} poznámek" +myTheme: "Moje vzhledy" +backgroundColor: "Pozadí" +accentColor: "Akcent" +textColor: "Barva textu" +saveAs: "Uložit jako…" +advanced: "Pokročilé" +value: "Hodnota" +createdAt: "Vytvořeno" +updatedAt: "Upraveno" +saveConfirm: "Uložit změny?" +deleteConfirm: "Opravdu smazat?" +invalidValue: "Neplatná hodnota." +registry: "Registr" +info: "Informace" +unknown: "Neznámý" +onlineStatus: "Online status" +hideOnlineStatus: "Skrýt Váš online status" +hideOnlineStatusDescription: "Skrytí vašeho online stavu může snížit funkcionalitu\ + \ některých funkcí, například vyhledávání." +online: "Online" +active: "Aktivní" +offline: "Offline" +notRecommended: "Nedoporučuje se" +botProtection: "Bot ochrana" +instanceBlocking: "Blokované instance" +selectAccount: "Vybrat účet" +switchAccount: "Přepnout účet" +enabled: "Zapnuto" +disabled: "Vypnuto" +quickAction: "Rychlé akce" +user: "Uživatelé" +administration: "Administrace" +accounts: "Účty" +switch: "Přepnout" +configure: "Nastavit" +gallery: "Galerie" +recentPosts: "Poslední příspěvky" +ads: "Reklamy" +memo: "Memo" +priority: "Priorita" +high: "Vysoká" +middle: "Střední" +low: "Nízká" +emailNotConfiguredWarning: "E-mailová adresa není nastavena." +ratio: "Poměr" +global: "Globální" +sent: "Odeslat" +hashtags: "Hashtagy" +troubleshooting: "Poradce při potížích" +whatIsNew: "Zobrazit změny" +translate: "Přeložit" +hide: "Skrýt" +smartphone: "Telefon" +tablet: "Tablet" +auto: "Auto" +size: "Velikost" +numberOfColumn: "Počet sloupců" +searchByGoogle: "Vyhledávání" +indefinitely: "Navždy" +tenMinutes: "10 minut" +oneHour: "1 hodina" +oneDay: "1 den" +oneWeek: "1 týden" +reflectMayTakeTime: "Může trvat nějakou dobu, než se projeví změny." +cropImage: "Oříznout obrázek" +file: "Soubor(ů)" +recentNHours: "Posledních {n} hodin" +recentNDays: "Posledních {n} dnů" +recommended: "Doporučeno" +deleteAccount: "Odstranit účet" +document: "Dokumentace" +logoutConfirm: "Opravdu se chcete odhlásit?" +pleaseSelect: "Vybrat možnost" +reverse: "Otočit" +colored: "Barevné" +type: "Typ" +speed: "Rychlost" +slow: "Pomalá" +fast: "Rychlá" +account: "Účty" +_ad: + back: "Zpět" +_gallery: + my: "Moje galerie" +_email: + _follow: + title: "Máte nového následovníka" +_plugin: + install: "Instalovat plugin" + manage: "Správce pluginů" +_preferencesBackups: + list: "Vytvořit backup" + loadFile: "Načíst ze souboru" + save: "Uložit změny" +_registry: + scope: "Rozsah" + key: "Klíč" + keys: "Klíče" + domain: "Doména" + createKey: "Vytvořit klíč" +_aboutIceshrimp: + allContributors: "Všichni přispěvatelé" + source: "Zdrojový kód" +_mfm: + mention: "Zmínění" + hashtag: "Hashtag" + link: "Odkaz" + bold: "Tučně" + quote: "Citovat" + emoji: "Vlastní emoji" + search: "Vyhledávání" + flip: "Otočit" + tada: "Animace (tadá)" + blur: "Rozmazání" + font: "Font" + rainbow: "Duha" +_channel: + featured: "Trendy" +_menuDisplay: + top: "Nahoru" + hide: "Skrýt" +_theme: + install: "Nainstalovat vzhled" + manage: "Správa vzhledů" + code: "Kód vzhledu" + description: "Popis" + installedThemes: "Nainstalované vzhledy" + constant: "Konstanta" + defaultValue: "Výchozí hodnota" + color: "Barva" + key: "Klíč" + func: "Funkce " + keys: + shadow: "Stín" + header: "Nadpis" + link: "Odkaz" + hashtag: "Hashtag" + mention: "Zmínění" + renote: "Přeposlat" + divider: "Dělící čára" +_sfx: + note: "Poznámky" + notification: "Oznámení" + chat: "Zprávy" +_ago: + future: "Budoucí" + justNow: "Teď" +_time: + second: "Sekund" + minute: "Minut" + hour: "Hodin" +_2fa: + registerTOTP: "Přidat zařízení" + registerSecurityKey: "Přidat bezpečnostní klíč" +_weekday: + sunday: "Neděle" + monday: "Pondělí" + tuesday: "Úterý" + wednesday: "Středa" + thursday: "Čtvrtek" + friday: "Pátek" + saturday: "Sobota" +_widgets: + notifications: "Oznámení" + timeline: "Časová osa" + calendar: "Kalendář" + trends: "Trendy" + clock: "Hodiny" + rss: "RSS čtečka" + activity: "Aktivita" + photos: "Fotky" + digitalClock: "Digitální hodiny" + federation: "Federace" + slideshow: "Prezentace" + button: "Tlačítko" + onlineUsers: "Online uživatelé" + jobQueue: "Fronta úloh" + aiscript: "AiScript conzole" + aichan: "Ai" +_cw: + hide: "Skrýt" + show: "Zobrazit více" +_poll: + noMore: "Více už přidat nemůžete" + infinite: "Nikdy" + deadlineDate: "Datum ukončení" + deadlineTime: "Hodin" + duration: "Trvání" +_visibility: + home: "Domů" + followers: "Sledující" +_postForm: + _placeholders: + f: "Čekám, až něco napíšete..." +_profile: + name: "Jméno" + username: "Uživatelské jméno" + description: "O mně" + youCanIncludeHashtags: "V popisku o Vás můžete použít i hastagy." + metadata: "Doplňující informace" + metadataContent: "Obsah" +_exportOrImport: + allNotes: "Všechny poznámky" + followingList: "Sledovaní" + muteList: "Ztlumit" + blockingList: "Zablokovat" + userLists: "Seznamy" +_charts: + federation: "Federace" + apRequest: "Požadavek" + usersTotal: "Celkem uživatelů" + activeUsers: "Aktivní uživatelé" + notesTotal: "Celkový počet poznámek" +_timelines: + home: "Domů" + global: "Globální" +_pages: + newPage: "Vytvořit novou stránku" + editPage: "Upravit stránku" + created: "Stránka byla úspěšně vytvořena" + updated: "Stránka byla úspěšně aktualizována" + deleted: "Stránka byla úspěšně smazána" + pageSetting: "Nastavení stránky" + invalidNameText: "Ujistěte se že jméno stránky je vyplněno" + contents: "Obsah" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + chooseBlock: "Přidat blok" + selectType: "Vyberte typ" + contentBlocks: "Obsah" + inputBlocks: "Vstup" + specialBlocks: "Speciální" + blocks: + text: "Text" + textarea: "Textové pole" + section: "Sekce" + image: "Obrázky" + button: "Tlačítko" + if: "Pokud" + _if: + variable: "Proměnná" + _post: + text: "Obsah" + canvasId: "Canvas ID" + _textInput: + name: "Jméno proměnné" + text: "Titulek" + default: "Výchozí hodnota" + _textareaInput: + name: "Jméno proměnné" + text: "Titulek" + default: "Výchozí hodnota" + _numberInput: + name: "Jméno proměnné" + text: "Titulek" + default: "Výchozí hodnota" + canvas: "Canvas" + _canvas: + id: "Canvas ID" + width: "Šířka" + height: "Výška" + _switch: + name: "Jméno proměnné" + text: "Titulek" + default: "Výchozí hodnota" + _counter: + name: "Jméno proměnné" + text: "Titulek" + inc: "Krok" + _button: + text: "Titulek" + colored: "Barevné" + _action: + _dialog: + content: "Obsah" + _radioButton: + name: "Jméno proměnné" + default: "Výchozí hodnota" + script: + categories: + list: "Seznamy" + blocks: + text: "Text" + _strLen: + arg1: "Text" + _strPick: + arg1: "Text" + _strReplace: + arg1: "Text" + _strReverse: + arg1: "Text" + _join: + arg1: "Seznamy" + _subtract: + arg1: "A" + arg2: "B" + _multiply: + arg1: "A" + arg2: "B" + _divide: + arg1: "A" + arg2: "B" + _mod: + arg1: "A" + arg2: "B" + round: "Zaokrouhlení zlomku" + _round: + arg1: "Číselná hodnota" + eq: "A a B jsou stejné" + _eq: + arg1: "A" + arg2: "B" + notEq: "A a B jsou odlišné" + _notEq: + arg1: "A" + arg2: "B" + _and: + arg1: "A" + arg2: "B" + _or: + arg1: "A" + arg2: "B" + _lt: + arg1: "A" + arg2: "B" + _gt: + arg1: "A" + arg2: "B" + _ltEq: + arg1: "A" + arg2: "B" + _gtEq: + arg1: "A" + arg2: "B" + if: "Větev" + _if: + arg1: "Pokud" + arg2: "Potom" + arg3: "Nebo" + random: "Náhodně" + _random: + arg1: "Pravděpodobnost" + rannum: "Náhodné číslo" + _rannum: + arg1: "Minimální hodnota" + arg2: "Maximální hodnota" + _randomPick: + arg1: "Seznamy" + _dailyRandom: + arg1: "Pravděpodobnost" + _dailyRannum: + arg1: "Minimální hodnota" + arg2: "Maximální hodnota" + _dailyRandomPick: + arg1: "Seznamy" + _seedRandom: + arg2: "Pravděpodobnost" + _seedRannum: + arg2: "Minimální hodnota" + arg3: "Maximální hodnota" + _seedRandomPick: + arg2: "Seznamy" + _pick: + arg1: "Seznamy" + _listLen: + arg1: "Seznamy" + number: "Číselná hodnota" + _stringToNumber: + arg1: "Text" + _numberToString: + arg1: "Číselná hodnota" + _splitStrByLine: + arg1: "Text" + types: + string: "Text" + number: "Číselná hodnota" + array: "Seznamy" +_notification: + youWereFollowed: "Máte nového následovníka" + youWereInvitedToGroup: "Pozvat do skupiny" + _types: + all: "Vše" + follow: "Sledovaní" + mention: "Zmínění" + reply: "Odpovědi" + renote: "Přeposlat" + quote: "Citovat" + reaction: "Reakce" + _actions: + reply: "Odpovědět" + renote: "Přeposlat" +_deck: + _columns: + notifications: "Oznámení" + tl: "Časová osa" + antenna: "Antény" + list: "Seznamy" + mentions: "Zmínění" +noteDeleteConfirm: Chcete opravdu smazat tento příspěvek? +defaultValueIs: 'Výchozí: {value}' +lookup: Hledat +keepOriginalUploading: Ponechat originální obrázek +uploadFromUrlRequested: Vyžádáno nahrání souboru +manageGroups: Spravovat skupiny +reloadConfirm: Znovu načíst časovou osu? +driveCapacityPerRemoteAccount: Místo na disku pro vzdálené uživatele +silenceThisInstance: Ztlumit tuto instance +silencedInstances: Ztlumené instance +blockedInstancesDescription: Zadejte seznam domén instancí, jež chcete blokovat. Uvedené + instance nebudou moci s touto instancí komunikovat. +hiddenTags: Skryté hashtagy +noInstances: Nejsou zde žádné instance +silenced: Ztlumené +disablingTimelinesInfo: Administrátoři a moderátoři budou vždy mít přístup ke všem + časovým osám, i pokud jsou vypnuté. +deleted: Vymazáno +editNote: Upravit poznámku +edited: 'Upraveno dne {date} {time}' +silencedInstancesDescription: Vypište hostnames instancí, které chcete ztlumit. Účty + v uvedených instancích jsou považovány za "ztlumené", mohou pouze zadávat požadavky + na sledování a nemohou zmiňovat místní účty, pokud nejsou sledovány. Na blokované + instance toto nebude mít vliv. +hiddenTagsDescription: 'Vypište hashtagy (bez #), které chcete skrýt před trendy a + prozkoumat. Skryté hashtagy jsou stále zjistitelné jinými způsoby. Blokované případy + nejsou ovlivněny, i když jsou zde uvedeny.' +circularReferenceFolder: Cílová složka je podsložka přesouvané složky. +whenServerDisconnected: Při ztrátě spojení se serverem +pinnedUsersDescription: Uveďte uživatelská jména uživatelů připnutých na stránce "Procházet", + jedno na řádek. +pinnedPagesDescription: Zadejte cesty ke stránkám, které chcete připnout na horní + stránku této instance, oddělené zlomy řádků. +pageLoadErrorDescription: Toto je obvykle způsobeno chybami sítě nebo mezipaměti prohlížeče. + Zkuste vymazat mezipaměť a po chvíli čekání to zkuste znovu. +emptyDrive: Váš disk je prázdný +inputNewDescription: Zadejte nový popisek +hasChildFilesOrFolders: Složka nemůže být smazána, protože není prázdná. +noThankYou: Ne, děkuji +addInstance: Přidat instance +selectInstance: Vybrat si instance +blockedUsers: Zablokovaní uživatelé +muteAndBlock: Ztlumení a blokace +noJobs: Žádné úlohy +federating: Federace +clearQueueConfirmText: Nedoručené příspěvky, které zůstanou ve frontě, nebudou federovány. + Obvykle tato operace není potřeba. +clearCachedFilesConfirm: Chcete opravdu vymazat mezipaměť všech vzdálených souborů? +accountMoved: 'Uživatel/ka se přesunul/a na nový účet:' +keepOriginalUploadingDescription: Ponechá originálně nahraný obrázek tak, jak je. + Pokud vypnuto, verze pro zobrazení na webu bude vygenerována při nahrání. +mutedUsers: Ztlumení uživatelé +enableRecommendedTimeline: Povolit doporučenou časovou osu +driveCapacityPerLocalAccount: Místo na disku pro místní uživatele +pinnedPages: Připnuté Stránky +directNotes: Přímé zprávy +enableEmojiReactions: Povolit reakce pomocí emoji +showEmojisInReactionNotifications: Zobrazit emotikony v oznámeních o reakcích +reactionSetting: Reakce, které se mají zobrazit v seznamu reakcí +renoteMute: Ztlumit přeposílání +renoteUnmute: Zrušit ztlumení přeposílání +flagSpeakAsCat: Mluvit jako kočka +flagSpeakAsCatDescription: Vaše příspěvky budou v kočičím režimu nyanifikovány. diff --git a/locales/da-DK.yml b/locales/da-DK.yml new file mode 100644 index 0000000..2c7c8ca --- /dev/null +++ b/locales/da-DK.yml @@ -0,0 +1,236 @@ +_lang_: "Dansk" +monthAndDay: '{month}/{day}' +search: Søge +notifications: Notifikationer +username: Brugernavn +password: Adgangskode +forgotPassword: Glemt adgangskode +fetchingAsApObject: Henter fra Fediverset +ok: OK +gotIt: Forstået! +cancel: Annullere +enterUsername: Indtast brugernavn +instance: Instans +renotedBy: Forstærket fra {user} +noNotes: Ingen opslag +otherSettings: Andre Indstillinger +profile: Profil +timeline: Tidslinje +signup: Registrere +logout: Log Ud +login: Log ind +uploading: Uploader... +save: Gem +users: Brugere +favorited: Tilsat til bogmærker. +unfavorite: Fjerne fra bogmærker +alreadyFavorited: Allerede inden i bogmærker. +pin: Fastgøre til profil +unpin: Løse fra profil +delete: Slet +addToList: Tilsæt til liste +deleteAndEdit: Slet og ændre +reply: Svar +loadMore: Indlæs mere +receiveFollowRequest: Følgeanmodning er blevet sendt +import: Importere +export: Eksportere +driveFileDeleteConfirm: Er du sikker på at du vil slette filen "{name}"? Denne vil + blive slettet fra alle tilknyttede opslage. +unfollowConfirm: Er du sikker på at du vil ikke følge {name} længere? +privacy: Privatlivs +enterListName: Indtast navnen for denne list +makeFollowManuallyApprove: Følgeanmodninger kræver godkendelse +unrenote: Fratag forstærkelse +renote: Forstærk +add: Tilsæt +reactionSetting: Reaktioner til at vise i reaktion-vælgeren +reactionSettingDescription2: Bevæg til at flytte om på, tryk til at slette og indtast + "+" til at tilsætte. +rememberNoteVisibility: Husk opslagsynlidhedsindstillinger +emojis: Emoji +flagShowTimelineReplies: Vis svare i tidslinjen +flagAsCatDescription: Du kommer til at få katøre og tale som en kat! +showOnRemote: Vis på fjerninstans +general: Generelt +accountMoved: 'Bruger har flyttet til et nyt konto:' +settings: Indstillinger +basicSettings: Primær Indstillinger +openInWindow: Åben i vindue +noAccountDescription: Denne bruger har ikke skrevet deres bio endnu. +loggingIn: Logger ind +cantFavorite: Kunne ikke tilsætte til bogmærker. +copyUsername: Kopi brugernavn +copyContent: Kopi indholdet +copyLink: Kopi link +searchUser: Søg for en bruger +files: Filer +noLists: Du har ingen liste +lists: Lister +reaction: Reaktioner +sensitive: NSFW +emoji: Emoji +cacheRemoteFilesDescription: Når denne indstilling er deaktiveret, fremmed filer bliver + indlæset direkte fra denne fjerneinstans. Hvis du deaktivere dette så vil det formindske + brugte opbevaringsplads men det vil også få netværktraffic til at stige fordi miniaturebilleder + vil ikke blive skabt. +flagAsBot: Markere denne konto som en robot +flagShowTimelineRepliesDescription: Vis svare af brugere til opslage af andre brugere + i tidslinjen hvis den bliver tændt. +loginFailed: Kunne ikke logge ind +silenceThisInstance: Nedtone denne instans +deleteAndEditConfirm: Er du sikker på at du vil slet denne opslag og ændre det? Du + vil tabe alle reaktioner, forstærkninger og svarer indenfor denne opslag. +editNote: Ændre note +deleted: Slettet +edited: 'Ændret den {date} {time}' +sendMessage: Send en besked +youShouldUpgradeClient: Til at vise denne side, vær sød at refresh til at opdatere + din brugerenhed. +defaultNoteVisibility: Standard synlighed +follow: Følge +followRequest: Følge +followRequests: Følgeanmodninger +unfollow: Følge ikke længere +followRequestPending: Følgeanmodning ventes på +enterEmoji: Indtast en emoji +renoted: Forstærket. +cantRenote: Denne opslag kunne ikke forstærkes. +cantReRenote: En forstærkelse kan ikke forstærkes. +quote: Citere +pinnedNote: Fastgjort opslag +pinned: Fastgøre til profil +you: Dig +clickToShow: Tryk til at vise +unblock: Blokere ikke længere +suspend: Suspendere +unsuspend: Suspendere ikke længere +blockConfirm: Er du sikker på at du vil blokere denne konto? +unblockConfirm: Er du sikker på at du vil ikke blokere denne konto endnu længere? +suspendConfirm: Er du sikker på at du vil suspendere denne konto? +selectAntenna: Vælg en antenne +selectWidget: Vælg en widget +editWidgets: Ændre widgettere +customEmojis: Brugerdefineret emoji +emojiName: Emoji navn +operations: Operationer +software: Software +metadata: Metadata +version: Version +monitor: Vagt +jobQueue: Jobkø +statistics: Statistik +cpuAndMemory: CPU og hukommelse +network: Netværk +disk: Disk +instanceInfo: Instans information +noThankYou: Nej tak +noNotifications: Intet notifikationer +addUser: Indsæt en bruger +addInstance: Indsæt en instans +favorite: Indsæt til bogmærker +favorites: Bogmærker +showMore: Vis mere +showLess: Luk +youGotNewFollower: følgte dig +followRequestAccepted: Følgeanmodning accepteret +mention: Nævne +mentions: Nævnene +directNotes: Direkt beskeder +importAndExport: Importere/Eksporter data +download: Download +exportRequested: Du har bedt om en eksport. Det vil tage noget tid. Den vil blive + tilsæt til din Drev når den er færdig. +importRequested: Du har bedt om en eksport. Det vil tage noget tid. +note: Opslag +notes: Opslage +following: Følger +followers: Følgere +followsYou: Følger dig +createList: Skab en list +manageLists: Administrere lister +error: Fejl +somethingHappened: En fejl har opstået +retry: Gentage +pageLoadError: En fejl har opstået ved indlæsning af siden. +pageLoadErrorDescription: Dette er normalt på grund af netværksproblemer eller din + browser's cache. Prøv at ryd cachen og så gentage efter et styk tid. +serverIsDead: Serveren svarer ikke. Vær sød at vente et styk tid og prøv igen. +editWidgetsExit: Færdig +headlineIceshrimp: En åben-kildekode, decentraliseret social-media platform som er frit + forevigt! 🚀 +introIceshrimp: Velkommen! Iceshrimp er en åbent-kildekode, decentraliseret social-media + platform som er frit forevigt!🚀 +enableEmojiReactions: Aktivere emoji reaktioner +unsuspendConfirm: Er du sikker på at du vil ikke suspendere denne konto endnu længere? +selectList: Vælg en list +showEmojisInReactionNotifications: Vis emoji i reaktion notifikationer +attachCancel: Fjern tilknyttelse +markAsSensitive: Markere som NSFW +unmarkAsSensitive: Markere ikke som NSFW længere +enterFileName: Indtast filnavn +mute: Nedtone +unmute: Nedtone ikke længere +renoteMute: Nedtone forstærkninger +renoteUnmute: Nedtone forstærkninger ikke længere +block: Blokere +cacheRemoteFiles: Cachere fremmed filer +flagAsBotDescription: Aktivere denne valgmulighed hvis denne konto er kontrolleret + af en komputerprogram. Hvis den et tændt så vil det signalere til andre udviklere + som arbejder på komputer-kontrolleret social-media kontoer og det vil også adjustere + Iceshrimp's indresystemer til at behandle denne konto som en robot. +flagAsCat: Er du en kat? 😺 +flagSpeakAsCat: Tale som en kat +emojiUrl: Emoji URL +addEmoji: Tilsæt +settingGuide: Anbefalet indstillinger +flagSpeakAsCatDescription: Din opslage vil blive nyaniferet når du er i kat-mode +autoAcceptFollowed: Automatisk godkende følgeanmodninger fra brugere som du selv følger +addAccount: Tilsæt konto +wallpaper: Baggrund +setWallpaper: Sæt baggrund +removeWallpaper: Fjern baggrund +host: Host +selectUser: Vælg en bruger +searchWith: 'Søge: {q}' +youHaveNoLists: Du har ingen liste +followConfirm: Er du sikker på at du vil gerne følge {name}? +proxyAccount: Proxykonto +proxyAccountDescription: En proxykonto er en konto som virker som en fremmed følger + for bruger under særlige konditioner. For eksempel, når en bruger tilsætter en fjernbruger + til denne list, vil denne fjernbruger's aktivitet ikke blive leveret til den instans + hvis ingen lokalebruger følger fjernbrugeren, så denne proxykonto vil følge den + istedetfor. +instances: Instanser +registeredAt: Registreret på +latestRequestSentAt: Sidste anmodning sendt +latestRequestReceivedAt: Sidste anmodning modtaget +selectInstance: Vælg en instans +recipient: Recipient(er) +annotation: Kommentarer +federation: Føderation +latestStatus: Senest status +storageUsage: Opbevaringspladsbrug +charts: Grafer +perHour: Hver time +perDay: Hver dag +stopActivityDelivery: Stop med at sende aktiviteter +blockThisInstance: Blokere denne instans +muteAndBlock: Mutes og blokeringer +mutedUsers: Mutede brugere +newer: nyere +older: ældre +silencedInstances: Nedtonede servere +clearQueue: Ryd kø +clearQueueConfirmTitle: Er du sikker på, at du ønsker at rydde køen? +clearCachedFiles: Ryd cache +clearCachedFilesConfirm: Er du sikker på, at du ønsker at slette alle cachede eksterne + filer? +blockedInstances: Blokerede servere +blockedInstancesDescription: Listen af navne på servere, du ønsker at blokere. Servere + på listen vil ikke længere kunne kommunikere med denne server. +hiddenTags: Skjulte hashtags +clearQueueConfirmText: De indlæg i denne kø, der ikke allerede er leveret, vil ikke + blive federeret. Denne operation er almindeligvis ikke påkrævet. +jumpToPrevious: Spring til tidligere +cw: Advarsel om indhold diff --git a/locales/de-DE.yml b/locales/de-DE.yml new file mode 100644 index 0000000..89d3fd5 --- /dev/null +++ b/locales/de-DE.yml @@ -0,0 +1,2238 @@ +_lang_: "Deutsch" +headlineIceshrimp: "Eine dezentralisierte Open-Source Social Media Plattform, die + für immer gratis bleibt! 🚀" +introIceshrimp: "Willkommen! Iceshrimp ist eine dezentralisierte Open-Source Social + Media Plattform, die für immer gratis bleibt!🚀" +monthAndDay: "{month}/{day}" +search: "Suchen" +notifications: "Benachrichtigungen" +username: "Nutzername" +password: "Passwort" +forgotPassword: "Passwort vergessen" +fetchingAsApObject: "Wird aus dem Fediverse angefragt" +ok: "OK" +gotIt: "Verstanden!" +cancel: "Abbrechen" +enterUsername: "Nutzername eingeben" +renotedBy: "Geteilt von {user}" +noNotes: "Keine Beiträge" +noNotifications: "Keine Benachrichtigungen" +instance: "Server" +settings: "Einstellungen" +basicSettings: "Grundeinstellungen" +otherSettings: "Weitere Einstellungen" +openInWindow: "In einem Fenster öffnen" +profile: "Profil" +timeline: "Timelines" +noAccountDescription: "Dieser Nutzer hat seine Profilbeschreibung noch nicht ausgefüllt." +login: "Login" +loggingIn: "Du wirst angemeldet" +logout: "Logout" +signup: "Registrieren" +uploading: "Wird hochgeladen …" +save: "Speichern" +users: "Nutzer" +addUser: "Nutzer hinzufügen" +favorite: "Zu den Lesezeichen hinzufügen" +favorites: "Lesezeichen" +unfavorite: "Aus den Lesezeichen entfernen" +favorited: "Zu den Lesezeichen hinzugefügt." +alreadyFavorited: "Bereits zu den Lesezeichen hinzugefügt." +cantFavorite: "Hinzufügen zu den Lesezeichen fehlgeschlagen." +pin: "An dein Profil anheften" +unpin: "Von deinem Profil lösen" +copyContent: "Inhalt kopieren" +copyLink: "Link kopieren" +delete: "Löschen" +deleteAndEdit: "Löschen und Bearbeiten" +deleteAndEditConfirm: "Möchtest du diesen Beitrag wirklich löschen und bearbeiten? + Alle Rückmeldungen, Renotes und Antworten dieses Beitrages werden verloren gehen." +addToList: "Zu Liste hinzufügen" +sendMessage: "Eine Mitteilung senden" +copyUsername: "Nutzernamen kopieren" +searchUser: "Nach einem Nutzer suchen" +reply: "Antworten" +loadMore: "Mehr laden" +showMore: "Mehr anzeigen" +showLess: "Schließen" +youGotNewFollower: "folgt dir" +receiveFollowRequest: "Follow-Anfrage erhalten" +followRequestAccepted: "Follow-Anfrage akzeptiert" +mention: "Erwähnung" +mentions: "Erwähnungen" +directNotes: "Direktmitteilungen" +importAndExport: "Daten Im- und Export" +import: "Import" +export: "Export" +files: "Dateien" +download: "Herunterladen" +driveFileDeleteConfirm: "Möchtest du die Datei \"{name}\" wirklich löschen? Es wird + aus allen Beiträgen entfernt, die die Datei als Anhang enthalten." +unfollowConfirm: "Bist du dir sicher, daß du {name} nicht mehr folgen möchtest?" +exportRequested: "Du hast einen Export angefragt. Dies kann etwas Zeit in Anspruch + nehmen. Sobald der Export abgeschlossen ist, wird er deinem Laufwerk hinzugefügt." +importRequested: "Du hast einen Import angefragt. Dies kann etwas Zeit in Anspruch + nehmen." +lists: "Listen" +noLists: "Du hast keine Listen angelegt" +note: "Beitrag" +notes: "Beiträge" +following: "Folge ich" +followers: "Folgen mir" +followsYou: "Folgt dir" +createList: "Liste erstellen" +manageLists: "Listen verwalten" +error: "Fehler" +somethingHappened: "Ein Fehler ist aufgetreten" +retry: "Wiederholen" +pageLoadError: "Beim Laden der Seite ist ein Fehler aufgetreten." +pageLoadErrorDescription: "Dies wird in der Regel durch Netzwerkfehler oder den Cache + des Browsers verursacht. Versuchen Sie den Cache zu leeren, oder eine Weile zu warten + und die Seite neuzuladen." +serverIsDead: "Der Server antwortet nicht. Bitte warte einen Moment und versuche es + dann erneut." +youShouldUpgradeClient: "Bitte aktualisiere diese Seite, um eine neuere Version deines + Clients zu verwenden." +enterListName: "Gib einen Namen für die Liste ein" +privacy: "Privatsphäre" +makeFollowManuallyApprove: "Folgeanfragen bedürfen der Genehmigung" +defaultNoteVisibility: "Standard-Sichtbarkeit" +follow: "Folge ich" +followRequest: "Follow anfragen" +followRequests: "Follow-Anfragen" +unfollow: "Nicht mehr folgen" +followRequestPending: "Follow-Anfrage ausstehend" +enterEmoji: "Ein Emoji eingeben" +renote: "Boost" +unrenote: "Boost zurücknehmen" +renoted: "Geboostet." +cantRenote: "Dieser Beitrag kann nicht geboostet werden." +cantReRenote: "Ein Boost kann nicht geboostet werden." +quote: "Zitieren" +pinnedNote: "Angepinnter Beitrag" +pinned: "An das Profil anheften" +you: "Du" +clickToShow: "Zum Anzeigen anklicken" +sensitive: "Sensibler Inhalt" +add: "Hinzufügen" +reaction: "Reaktionen" +reactionSetting: "Reaktionen, die in der Reaktionsauswahl angezeigt werden sollen" +reactionSettingDescription2: "Ziehen Sie, um neu zu ordnen,\nklicken Sie, um zu löschen,\n + drücken Sie \"+\", um hinzuzufügen." +rememberNoteVisibility: "Einstellungen für die Sichtbarkeit von Beiträgen speichern" +attachCancel: "Anhang entfernen" +markAsSensitive: "Als sensiblen Inhalt markieren" +accountMoved: "Der Nutzer ist zu einem neuen Konto umgezogen:" +unmarkAsSensitive: "NSFW Kennzeichnung aufheben" +enterFileName: "Dateiname eingeben" +mute: "Stummschalten" +unmute: "Stummschaltung aufheben" +block: "Blockieren" +unblock: "Blockierung aufheben" +suspend: "Suspendieren" +unsuspend: "Suspendierung aufheben" +blockConfirm: "Sind Sie sicher, dass Sie dieses Konto sperren wollen?" +unblockConfirm: "Sind Sie sicher, dass Sie die Sperrung dieses Kontos aufheben wollen?" +suspendConfirm: "Sind Sie sicher, dass Sie dieses Konto sperren wollen?" +unsuspendConfirm: "Sind Sie sicher, dass Sie dieses Konto entsperren wollen?" +selectList: "Wählen Sie eine Liste aus" +selectAntenna: "News-Picker auswählen" +selectWidget: "Ein Widget auswählen" +editWidgets: "Widgets bearbeiten" +editWidgetsExit: "Erledigt" +customEmojis: "Benutzerdefinierte Emoji" +emoji: "Emoji" +emojis: "Emoji" +emojiName: "Emoji-Name" +emojiUrl: "Emoji-URL" +addEmoji: "Emoji hinzufügen" +settingGuide: "Empfohlene Einstellungen" +cacheRemoteFiles: "Cache für entfernte Dateien" +cacheRemoteFilesDescription: "Ist diese Einstellung deaktiviert, so werden Dateien + von anderen Servern direkt von dort geladen. Hierdurch wird Speicherplatz auf diesem + Server eingespart, aber durch die fehlende Generierung von Vorschaubildern wird + mehr Bandbreite benötigt." +flagAsBot: "Dieses Nutzerkonto als Bot 🤖 kennzeichnen" +flagAsBotDescription: "Aktiviere diese Option, falls dieses Nutzerkonto durch ein + Programm gesteuert wird. Falls aktiviert, agiert es als Flag für andere Entwickler + zur Verhinderung von endlosen Kettenreaktionen mit anderen Bots und lässt Iceshrimps + interne Systeme dieses Nutzerkonto als Bot behandeln." +flagAsCat: "Bist du eine Katze? 😺" +flagAsCatDescription: "Du bekommst Katzenohren und sprichst wie eine Katze!" +flagShowTimelineReplies: "Antworten in der Timeline anzeigen" +flagShowTimelineRepliesDescription: "Zeigt Antworten von Nutzern auf Beiträge anderer + Nutzer in der Timeline an, wenn diese Funktion aktiviert ist." +autoAcceptFollowed: "Automatisches Genehmigen von Folgeanfragen von Benutzern, denen + Sie folgen" +addAccount: "Nutzerkonto hinzufügen" +loginFailed: "Anmeldung fehlgeschlagen" +showOnRemote: "Zur Ansicht auf dem Herkunftsserver" +general: "Allgemein" +wallpaper: "Hintergrundbild" +setWallpaper: "Hintergrundbild festlegen" +removeWallpaper: "Hintergrundbild entfernen" +searchWith: "Suchen: {q}" +youHaveNoLists: "Sie haben keine Listen" +followConfirm: "Sind Sie sicher, dass Sie {name} folgen möchten?" +proxyAccount: "Proxy-Konto" +proxyAccountDescription: "Ein Proxy-Konto ist ein Nutzerkonto, das sich für Nutzer + unter bestimmten Konditionen wie ein Follower von einem anderen Server verhält. + Zum Beispiel wird die Aktivität eines Nutzers von einem anderen Server nicht an + diesen Server übermittelt, falls es keinen Nutzer von diesem Server gibt, der diesem + Nutzer von einem anderen Server folgt. In diesem Fall folgt stattdessen das Proxy-Nutzerkonto." +host: "Host" +selectUser: "Wählen Sie einen Nutzer" +recipient: "Empfänger" +annotation: "Anmerkungen" +federation: "Föderation" +instances: "Server" +registeredAt: "Registriert unter" +latestRequestSentAt: "Letzte Anfrage gesendet" +latestRequestReceivedAt: "Letzte erhaltene Anfrage" +latestStatus: "Aktueller Stand" +storageUsage: "Nutzung des Speichers" +charts: "Diagramme" +perHour: "Pro Stunde" +perDay: "Pro Tag" +stopActivityDelivery: "Sendeaktivitäten einstellen" +blockThisInstance: "Diesen Server blockieren" +operations: "Tätigkeiten" +software: "Software" +version: "Version" +metadata: "Metadaten" +monitor: "Überwachung" +jobQueue: "Auftragswarteschlange" +cpuAndMemory: "CPU und Speicher" +network: "Netzwerk" +disk: "Festplatte" +instanceInfo: "Serverinformationen" +statistics: "Statistiken" +clearQueue: "Warteschlange löschen" +clearQueueConfirmTitle: "Sind Sie sicher, dass Sie die Warteschlange löschen wollen?" +clearQueueConfirmText: "Nicht zugestellte Beiträge, die in der Warteschlange verbleiben, + werden nicht föderiert. Normalerweise ist dieser Vorgang nicht erforderlich." +clearCachedFiles: "Cache leeren" +clearCachedFilesConfirm: "Sind Sie sicher, dass Sie alle im Cache zwischengespeicherten + Dateien löschen wollen?" +blockedInstances: "Blockierte Server" +blockedInstancesDescription: "Geben Sie die Hostnamen der Server, getrennt durch einen + Zeilenumbruch, an, die Sie blockieren möchten. Aufgelistete (blockierte) Server + können nicht mehr mit diesem Server kommunizieren." +muteAndBlock: "Stummschaltungen und Blockierungen" +mutedUsers: "Stummgeschaltete Nutzer" +blockedUsers: "Blockierte Nutzer" +noUsers: "Es sind keine Nutzer vorhanden" +editProfile: "Profil bearbeiten" +noteDeleteConfirm: "Sind Sie sicher, dass Sie diesen Beitrag löschen wollen?" +pinLimitExceeded: "Sie können keine weiteren Beiträge anpinnen" +intro: "Die Installation von Iceshrimp ist abgeschlossen! Bitte erstellen Sie einen + Admin-Benutzer." +done: "Erledigt" +processing: "In Bearbeitung..." +preview: "Vorschau" +default: "Standard" +defaultValueIs: "Der Standardwert ist: {value}" +noCustomEmojis: "Es gibt keine benutzerdefinierten Emoji" +noJobs: "Keine Jobs vorhanden" +federating: "Eine Verbindung zum Server wird hergestellt" +blocked: "Blockiert" +suspended: "suspendiert" +all: "Alles" +subscribing: "Registrieren" +publishing: "Veröffentlichen" +notResponding: "Antwortet nicht" +instanceFollowing: "Folgen auf dem Server" +instanceFollowers: "Follower des Servers" +instanceUsers: "Nutzer dieses Servers" +changePassword: "Passwort ändern" +security: "Sicherheit" +retypedNotMatch: "Die Eingaben stimmen nicht überein." +currentPassword: "Aktuelles Passwort" +newPassword: "Neues Passwort" +newPasswordRetype: "Neues Passwort bestätigen" +attachFile: "Dateien anhängen" +more: "Mehr" +featured: "Besonderheiten" +usernameOrUserId: "Nutzername oder Nutzer-ID" +noSuchUser: "Nutzer nicht gefunden" +lookup: "Suche nach" +announcements: "Bekanntmachungen" +imageUrl: "Bild-URL" +remove: "Löschen" +removed: "Erfolgreich gelöscht" +removeAreYouSure: "Sind Sie sicher, dass Sie \"{x}\" entfernen wollen?" +deleteAreYouSure: "Sind Sie sicher, dass Sie \"{x}\" löschen wollen?" +resetAreYouSure: "Wirklich zurücksetzen?" +saved: "Gespeichert" +messaging: "Chat" +upload: "Hochladen" +keepOriginalUploading: "Originalbild behalten" +keepOriginalUploadingDescription: "Speichert das ursprünglich hochgeladene Bild so, + wie es ist. Wenn diese Option deaktiviert ist, wird beim Hochladen eine Version + für die Anzeige im Web erstellt." +fromDrive: "Vom Laufwerk" +fromUrl: "Von einer URL" +uploadFromUrl: "Von einer URL hochladen" +uploadFromUrlDescription: "URL der Datei, die Sie hochladen wollen" +uploadFromUrlRequested: "Upload angefordert" +uploadFromUrlMayTakeTime: "Es kann einige Zeit dauern, bis das Hochladen abgeschlossen + ist." +explore: "Erkunden" +messageRead: "Gelesen" +noMoreHistory: "Es gibt keine weitere Historie" +startMessaging: "Einen neuen Chat beginnen" +nUsersRead: "Gelesen von {n}" +agreeTo: "Ich stimme {0} zu" +tos: "Nutzungsbedingungen" +start: "Beginnen Sie" +home: "Home" +remoteUserCaution: "Informationen von Nutzern anderer Server sind möglicherweise unvollständig." +activity: "Aktivität" +images: "Bilder" +birthday: "Geburtstag" +yearsOld: "{age} Jahre alt" +registeredDate: "Registriert am" +location: "Ort" +theme: "Farbverwaltung" +themeForLightMode: "Farbkombination zur Verwendung im hellen Modus" +themeForDarkMode: "Farbkombination zur Verwendung im dunklen Modus" +light: "Hell" +dark: "Dunkel" +lightThemes: "Helle Farbkombinationen" +darkThemes: "Dunkle Farbkombinationen" +syncDeviceDarkMode: "Einstellung deines Geräts übernehmen" +drive: "Cloud-Drive" +fileName: "Dateiname" +selectFile: "Datei auswählen" +selectFiles: "Dateien auswählen" +selectFolder: "Ordner auswählen" +selectFolders: "Ordner auswählen" +renameFile: "Datei umbenennen" +folderName: "Ordnername" +createFolder: "Ordner erstellen" +renameFolder: "Ordner umbenennen" +deleteFolder: "Ordner löschen" +addFile: "Datei hinzufügen" +emptyDrive: "Deine Cloud-Drive ist leer" +emptyFolder: "Dieser Ordner ist leer" +unableToDelete: "Nicht löschbar" +inputNewFileName: "Gib einen neuen Dateinamen ein" +inputNewDescription: "Gib eine neue Beschreibung ein" +inputNewFolderName: "Gib einen neuen Ordnernamen ein" +circularReferenceFolder: "Der Zielordner ist ein Unterorder des Ordners, den du verschieben + möchtest." +hasChildFilesOrFolders: "Dieser Ordner kann nicht gelöscht werden, da er nicht leer + ist." +copyUrl: "URL kopieren" +rename: "Umbenennen" +avatar: "Profilbild" +banner: "Banner" +nsfw: "NSFW" +whenServerDisconnected: "Bei Verbindungsverlust zum Server" +disconnectedFromServer: "Die Verbindung zum Server wurde getrennt" +reload: "Aktualisieren" +doNothing: "Ignorieren" +reloadConfirm: "Seite neu laden?" +watch: "Beobachten" +unwatch: "Nicht mehr beobachten" +accept: "Akzeptieren" +reject: "Ablehnen" +normal: "Normal" +instanceName: "Server-Name" +instanceDescription: "Server-Beschreibung" +maintainerName: "Betreiber" +maintainerEmail: "Betreiber-Email" +tosUrl: "URL der Nutzungsbedingungen" +thisYear: "Jahr" +thisMonth: "Monat" +today: "Heute" +dayX: "{day}" +monthX: "{month}" +yearX: "{year}" +pages: "Nutzer-Seiten" +integration: "Integration" +connectService: "Verbinden" +disconnectService: "Trennen" +enableLocalTimeline: "Local-Timeline aktivieren" +enableGlobalTimeline: "Global-Timeline aktivieren" +disablingTimelinesInfo: "Administratoren und Moderatoren haben immer Zugriff auf alle + Timelines, auch wenn diese deaktiviert sind." +registration: "Registrieren" +enableRegistration: "Registration neuer Nutzer erlauben" +invite: "Einladen" +driveCapacityPerLocalAccount: "Cloud-Drive-Kapazität pro lokalem Nutzerkonto" +driveCapacityPerRemoteAccount: "Laufwerkskapazität pro Remote-Nutzer" +inMb: "In Megabytes" +iconUrl: "Icon-URL (favicon etc)" +bannerUrl: "Banner-URL" +backgroundImageUrl: "Hintergrundbild-URL" +basicInfo: "Grundlegende Informationen" +pinnedUsers: "Angeheftete Nutzer" +pinnedUsersDescription: "Gib durch Leerzeichen getrennte Nutzer an, die an die \"\ + Erkunden\"-Seite angeheftet werden sollen." +pinnedPages: "Angeheftete Nutzer-Seiten" +pinnedPagesDescription: "Geben Sie die Dateipfade, getrennt durch Zeilenumbrüche, + derjenigen Seiten ein, die Sie an die obere Seitenbegrenzung des Servers anpinnen + möchten." +pinnedClipId: "ID des anzuheftenden Clips" +pinnedNotes: "Angeheftete Beiträge" +hcaptcha: "hCaptcha" +enableHcaptcha: "hCaptcha aktivieren" +hcaptchaSiteKey: "Site key" +hcaptchaSecretKey: "Secret key" +recaptcha: "reCAPTCHA" +enableRecaptcha: "reCAPTCHA aktivieren" +recaptchaSiteKey: "Site key" +recaptchaSecretKey: "Secret key" +avoidMultiCaptchaConfirm: "Das Verwenden von mehreren Captcha-Systemen kann zu Störungen + führen. Sollen die anderen Systeme deaktiviert werden? Durch Abbrechen können mehrere + Systeme aktiviert bleiben." +antennas: "News-Picker" +manageAntennas: "News-Picker verwalten" +name: "Name" +antennaSource: "Quellen der News-Picker" +antennaKeywords: "Zu beobachtende Schlüsselwörter" +antennaExcludeKeywords: "Zu ignorierende Schlüsselwörter" +antennaKeywordsDescription: "Zum Nutzen einer \"UND\"-Verknüpfung Einträge mit Leerzeichen + trennen, zum Nutzen einer \"ODER\"-Verknüpfung Einträge mit einem Zeilenumbruch + trennen." +notifyAntenna: "Über neue Beiträge benachrichtigen" +withFileAntenna: "Nur Beiträge mit Dateien" +enableServiceworker: "Push-Benachrichtigungen im Browser aktivieren" +antennaUsersDescription: "Nutzernamen getrennt durch Zeilenumbrüche angeben" +caseSensitive: "Groß-/Kleinschreibung unterscheiden" +withReplies: "Antworten beinhalten" +connectedTo: "Mit folgenden Nutzerkonten verknüpft" +notesAndReplies: "Beiträge und Antworten" +withFiles: "Beiträge mit Anhängen" +silence: "stummschalten" +silenceConfirm: "Sind Sie sicher, dass Sie diesen Benutzer Stummschalten möchten?" +unsilence: "Stummschaltung aufheben" +unsilenceConfirm: "Sind Sie sicher, dass Sie die Stummschaltung dieses Benutzers rückgängig + machen wollen?" +popularUsers: "Beliebte Nutzer" +recentlyUpdatedUsers: "Vor kurzem aktive Nutzer" +recentlyRegisteredUsers: "Vor kurzem registrierte Nutzer" +recentlyDiscoveredUsers: "Vor kurzem gefundene Nutzer" +exploreUsersCount: "Es gibt {count} Nutzer" +exploreFediverse: "Das Fediverse erkunden" +popularTags: "Beliebte Schlagwörter" +userList: "Liste" +about: "Über" +aboutIceshrimp: "Über Iceshrimp" +administrator: "Administrator" +token: "Token" +twoStepAuthentication: "Zwei-Faktor-Authentifizierung" +moderator: "Moderator" +moderation: "Moderation" +nUsersMentioned: "Von {n} Nutzern erwähnt" +securityKey: "Sicherheitsschlüssel" +securityKeyName: "Schlüsselname" +registerSecurityKey: "Sicherheitsschlüssel registrieren" +lastUsed: "Zuletzt benutzt" +unregister: "Deaktivieren" +passwordLessLogin: "Passwortloses Anmelden einrichten" +resetPassword: "Passwort zurücksetzen" +newPasswordIs: "Das neue Passwort ist „{password}“" +reduceUiAnimation: "Animationen der Benutzeroberfläche reduzieren" +share: "Teilen" +notFound: "Nicht gefunden" +notFoundDescription: "Es konnte keine Seite unter dieser URL gefunden werden." +uploadFolder: "Standardordner für Uploads" +cacheClear: "Cache leeren" +markAsReadAllNotifications: "Alle Benachrichtigungen als gelesen markieren" +markAsReadAllUnreadNotes: "Alle Beiträge als gelesen markieren" +markAsReadAllTalkMessages: "Alle Chats als gelesen markieren" +help: "Hilfe" +inputMessageHere: "Hier Beitrag eingeben" +close: "Schließen" +group: "Gruppe" +groups: "Gruppen" +createGroup: "Gruppe erstellen" +ownedGroups: "Meine Gruppen" +joinedGroups: "Beigetretene Gruppen" +invites: "Einladungen" +groupName: "Gruppenname" +members: "Mitglieder" +transfer: "Übertragen" +messagingWithUser: "Privatchat" +messagingWithGroup: "Gruppenchat" +title: "Titel" +text: "Text" +enable: "Aktivieren" +next: "Weiter" +retype: "Erneut eingeben" +noteOf: "Beitrag von {user}" +inviteToGroup: "Zu Gruppe einladen" +quoteAttached: "Zitat" +quoteQuestion: "Als Zitat anhängen?" +noMessagesYet: "Noch keine Beiträge vorhanden" +newMessageExists: "Du hast eine neue Nachricht" +onlyOneFileCanBeAttached: "Es kann pro Beitrag nur eine Datei angehängt werden" +signinRequired: "Bitte registriere oder melde dich an, um fortzufahren" +invitations: "Einladungen" +invitationCode: "Einladungscode" +checking: "Wird überprüft …" +available: "Verfügbar" +unavailable: "Unverfügbar" +usernameInvalidFormat: "Du kannst Klein- und Großbuchstaben, Zahlen sowie Unterstriche + verwenden." +tooShort: "Zu kurz" +tooLong: "Zu lang" +weakPassword: "Schwaches Passwort" +normalPassword: "Durchschnittliches Passwort" +strongPassword: "Starkes Passwort" +passwordMatched: "Stimmt überein" +passwordNotMatched: "Stimmt nicht überein" +signinWith: "Mit {x} anmelden" +signinFailed: "Anmeldung fehlgeschlagen. Überprüfe Nutzername und Passswort." +tapSecurityKey: "Tippe deinen Sicherheitsschlüssel an" +or: "Oder" +language: "Sprache" +uiLanguage: "Sprache der Benutzeroberfläche" +groupInvited: "Du wurdest in eine Gruppe eingeladen" +aboutX: "Über {x}" +useOsNativeEmojis: "Eingebaute Emojis des Betriebssystems benutzen" +disableDrawer: "Keine ausfahrbaren Menüs verwenden" +youHaveNoGroups: "Keine Gruppen vorhanden" +joinOrCreateGroup: "Lass dich zu einer Gruppe einladen oder erstelle deine eigene." +noHistory: "Kein Verlauf gefunden" +signinHistory: "Anmeldungsverlauf" +disableAnimatedMfm: "MFM, die Animationen enthalten, deaktivieren" +doing: "In Bearbeitung …" +category: "Kategorie" +tags: "Schlagwörter" +docSource: "Quellcode dieses Dokuments" +createAccount: "Nutzerkonto erstellen" +existingAccount: "Bestehendes Nutzerkonto" +regenerate: "Regenerieren" +fontSize: "Schriftgröße" +noFollowRequests: "Keine ausstehenden Follow-Anfragen vorhanden" +openImageInNewTab: "Bilder in neuem Tab öffnen" +dashboard: "Dashboard" +local: "Lokal" +remote: "Fremd" +total: "Gesamt" +weekOverWeekChanges: "Veränderung zu letzter Woche" +dayOverDayChanges: "Veränderung zu Gestern" +appearance: "Aussehen" +clientSettings: "Client-Einstellungen" +accountSettings: "Nutzerkonto-Einstellungen" +promotion: "geworben" +promote: "Werben" +numberOfDays: "Anzahl der Tage" +hideThisNote: "Diesen Beitrag verstecken" +showFeaturedNotesInTimeline: "Beliebte Beiträge in der Timeline anzeigen" +objectStorage: "Objektspeicher" +useObjectStorage: "Object Storage verwenden" +objectStorageBaseUrl: "Basis-URL" +objectStorageBaseUrlDesc: "Die als Referenz verwendete URL. Verwendest du einen CDN + oder Proxy, gib dessen URL an. \nFür S3 verwende 'https://.s3.amazonaws.com'. + Für GCS o.ä. verwende 'https://storage.googleapis.com/'." +objectStorageBucket: "Eimer" +objectStorageBucketDesc: "Bitte gib den Namen des Buckets an, der bei deinem Anbieter + verwendet wird." +objectStoragePrefix: "Prefix" +objectStoragePrefixDesc: "Dateien werden in Ordnern unter diesem Prefix gespeichert." +objectStorageEndpoint: "Limit" +objectStorageEndpointDesc: "Im Falle von S3 leerlassen, für andere Anbieter den relevanten + Endpoint im Format „“ oder „:“ angeben." +objectStorageRegion: "Region" +objectStorageRegionDesc: "Gib eine Region wie z.B. „xx-east-1“ an. Falls dein Anbieter + nicht zwischen Regionen unterscheidet, lass dieses Feld leer oder gib „us-east-1“ + an." +objectStorageUseSSL: "SSL verwenden" +objectStorageUseSSLDesc: "Deaktiviere dies, falls du für API-Verbindungen kein HTTPS + verwenden wirst" +objectStorageUseProxy: "Über Proxy verbinden" +objectStorageUseProxyDesc: "Deaktiviere dies, falls du keinen Proxy für den Objektspeicher + verwenden wirst" +objectStorageSetPublicRead: "Bei Upload auf \"public-read\" stellen" +serverLogs: "Serverprotokolle" +deleteAll: "Alle löschen" +showFixedPostForm: "Bereich zum Schreiben neuer Beiträge am Anfang der Timeline anzeigen" +newNoteRecived: "Es gibt neue Beiträge" +sounds: "Töne" +listen: "Anhören" +none: "Nichts" +showInPage: "In einer Seite anzeigen" +popout: "Pop-Up" +volume: "Lautstärke" +masterVolume: "Gesamtlautstärke" +details: "Details" +chooseEmoji: "Emoji auswählen" +unableToProcess: "Der Vorgang konnte nicht abgeschlossen werden" +recentUsed: "Vor kurzem verwendet" +install: "Installieren" +uninstall: "Uninstallieren" +installedApps: "Authorisierte Anwendungen" +nothing: "Hier gibt es nichts zu sehen" +installedDate: "Authorisiert am" +lastUsedDate: "Zuletzt verwendet am" +state: "Status" +sort: "Sortieren" +ascendingOrder: "Aufsteigende Reihenfolge" +descendingOrder: "Absteigende Reihenfolge" +scratchpad: "Testumgebung" +scratchpadDescription: "Die Testumgebung bietet einen Bereich für AiScript-Experimente. + Dort kannst du AiScript schreiben, ausführen sowie dessen Auswirkungen auf Iceshrimp + überprüfen." +output: "Ausgabe" +script: "Skript" +disablePagesScript: "AiScript auf Seiten deaktivieren" +updateRemoteUser: "Nutzerinformationen aktualisieren" +deleteAllFiles: "Alle Dateien löschen" +deleteAllFilesConfirm: "Möchtest du wirklich alle Dateien löschen?" +removeAllFollowing: "Allen gefolgten Nutzern entfolgen" +removeAllFollowingDescription: "Wenn Sie dies ausführen, werden alle Konten von {host} + entfolgt. Bitte führen Sie dies aus, wenn der Server beispielsweise nicht mehr existiert." +userSuspended: "Dieser Nutzer wurde gesperrt." +userSilenced: "Dieser Nutzer wurde instanzweit stummgeschaltet." +yourAccountSuspendedTitle: "Dieses Nutzerkonto ist gesperrt" +yourAccountSuspendedDescription: "Dieses Nutzerkonto wurde gesperrt, da es gegen die + Nutzungsbedingungen dieses Servers verstoßen hat. Trete mit dem Betreiber in Kontakt, + falls du weitere Details erfahren möchtest. Bitte erstelle kein neues Nutzerkonto." +menu: "Menü" +divider: "Trenner" +addItem: "Element hinzufügen" +relays: "Relays" +addRelay: "Relay hinzufügen" +inboxUrl: "inbox-URL" +addedRelays: "Hinzugefügte Relays" +serviceworkerInfo: "Muss für Push-Benachrichtigungen aktiviert sein." +deletedNote: "Gelöschter Beitrag" +invisibleNote: "Privater Beitrag" +enableInfiniteScroll: "Automatisch mehr laden" +visibility: "Sichtbarkeit" +poll: "Umfrage" +useCw: "Inhaltswarnung verwenden" +enablePlayer: "Video-Player öffnen" +disablePlayer: "Video-Player schließen" +expandTweet: "Tweet ausklappen" +themeEditor: "Farbkombinations-Editor" +description: "Beschreibung" +describeFile: "Beschreibung hinzufügen" +enterFileDescription: "Beschreibung eingeben" +author: "Autor" +leaveConfirm: "Es gibt unspeicherte Änderungen. Möchtest du diese verwerfen?" +manage: "Verwaltung" +plugins: "Plugins" +preferencesBackups: "Einstellungsbackups" +deck: "Deck" +undeck: "Deck verlassen" +useBlurEffectForModal: "Weichzeichnungseffekt für Modals verwenden" +useFullReactionPicker: "Vollständige Reaktionsauswahl verwenden" +width: "Breite" +height: "Höhe" +large: "Groß" +medium: "Mittel" +small: "Klein" +generateAccessToken: "Zugriffstoken generieren" +permission: "Berechtigungen" +enableAll: "Alle aktivieren" +disableAll: "Alle deaktivieren" +tokenRequested: "Zugriff zum Nutzerkonto gewähren" +pluginTokenRequestedDescription: "Dieses Plugin wird die hier konfigurierten Berechtigungen + verwenden können." +notificationType: "Art der Benachrichtigung" +edit: "Bearbeiten" +emailServer: "Email-Server" +enableEmail: "Email-Versand aktivieren" +emailConfigInfo: "Zur Email-Bestätigung bei Registrierung oder zum Zurücksetzen des + Passworts verwendet" +email: "Email" +emailAddress: "Email-Adresse" +smtpConfig: "SMTP-Server Konfiguration" +smtpHost: "Host" +smtpPort: "Port" +smtpUser: "Nutzername" +smtpPass: "Passwort" +emptyToDisableSmtpAuth: "Nutzername und Passwort leer lassen, um SMTP-Verifizierung + zu deaktivieren" +smtpSecure: "Für SMTP-Verbindungen implizit SSL/TLS verwenden" +smtpSecureInfo: "Schalte dies aus, falls du STARTTLS verwendest" +testEmail: "Emailversand testen" +wordMute: "Wortfilter" +regexpError: "Fehler in einem regulären Ausdruck" +regexpErrorDescription: "Im regulären Ausdruck deines {tab}en Wortfilters ist ein + Fehler aufgetreten:" +instanceMute: "Server-Stummschaltungen" +userSaysSomething: "{name} hat etwas gesagt" +makeActive: "Aktivieren" +display: "Anzeigeart" +copy: "Kopieren" +metrics: "Metriken" +overview: "Übersicht" +logs: "Protokolle" +delayed: "Verzögert" +database: "Datenbank" +channel: "Channels" +create: "Erstellen" +notificationSetting: "Benachrichtigungseinstellungen" +notificationSettingDesc: "Wähle die Art der anzuzeigenden Benachrichtigungen." +useGlobalSetting: "Globale Einstellung verwenden" +useGlobalSettingDesc: "Ist diese Option aktiviert, werden die Benachrichtigungseinstellungen + deines Nutzerkontos verwendet. Durch ausschalten dieser Option können individuelle + Einstellungen vorgenommen werden." +other: "Anderes" +regenerateLoginToken: "Anmeldetoken regenerieren" +regenerateLoginTokenDescription: "Den zur Anmeldung intern verwendeten Token regenerieren. + Normalerweise wird dies nicht benötigt. Bei Regeneration werden alle Geräte ausgeloggt." +setMultipleBySeparatingWithSpace: "Trenne Elemente durch ein Leerzeichen um mehrere + Einstellungen zu kofigurieren." +fileIdOrUrl: "Datei-ID oder URL" +behavior: "Verhalten" +sample: "Beispiel" +abuseReports: "Meldungen" +reportAbuse: "Melden" +reportAbuseOf: "{name} melden" +fillAbuseReportDescription: "Bitte gib zusätzliche Informationen zu dieser Meldung + an. Falls es sich um einen ungewöhnlichen Beitrag handelt, gib bitte dessen URL + an." +abuseReported: "Deine Meldung wurde versendet. Vielen Dank." +reporter: "Melder" +reporteeOrigin: "Herkunft des Gemeldeten" +reporterOrigin: "Herkunft des Meldenden" +forwardReport: "Meldung auch an den mit-beteiligten Server weiterleiten" +forwardReportIsAnonymous: "Anstelle deines Nutzerkontos wird ein anonymes Systemkonto + als Hinweisgeber auf dem mit-beteiligten Server angezeigt." +send: "Senden" +abuseMarkAsResolved: "Meldung als gelöst markieren" +openInNewTab: "In neuem Tab öffnen" +openInSideView: "In Seitenansicht öffnen" +defaultNavigationBehaviour: "Standardnavigationsverhalten" +editTheseSettingsMayBreakAccount: "Bei Bearbeitung dieser Einstellungen besteht die + Gefahr, dein Nutzerkonto zu beschädigen." +instanceTicker: "Zeige zu einem Beitrag den Herkunfts-Server an" +waitingFor: "Warte auf {x}" +random: "Zufällig" +system: "System" +switchUi: "Layout wechseln" +desktop: "Desktop" +clip: "Clip erstellen" +createNew: "Neu erstellen" +optional: "optional" +createNewClip: "Neuen Clip erstellen" +unclip: "Aus Clip entfernen" +confirmToUnclipAlreadyClippedNote: "Dieser Beitrag ist bereits im \"{name}\" Clip + enthalten. Möchtest du ihn aus diesem Clip entfernen?" +public: "Öffentlich" +i18nInfo: "Iceshrimp wird durch freiwillige Helfer in viele verschiedene Sprachen + übersetzt. Auf {link} kannst du mithelfen." +manageAccessTokens: "Zugriffstokens verwalten" +accountInfo: "Nutzerkonto-Informationen" +notesCount: "Anzahl der Beiträge" +repliesCount: "Anzahl gesendeter Antworten" +renotesCount: "Anzahl getätigter Renotes" +repliedCount: "Anzahl erhaltener Antworten" +renotedCount: "Anzahl erhaltener Renotes" +followingCount: "Anzahl gefolgter Nutzer" +followersCount: "Anzahl an Followern" +sentReactionsCount: "Anzahl gesendeter Reaktionen" +receivedReactionsCount: "Anzahl erhaltener Reaktionen" +pollVotesCount: "Anzahl gesendeter Antworten auf Umfragen" +pollVotedCount: "Anzahl erhaltener Antworten auf Umfragen" +yes: "Ja" +no: "Nein" +driveFilesCount: "Anzahl der Dateien in Cloud-Drive" +driveUsage: "Cloud-Drive-Auslastung" +noCrawle: "Crawler-Indexierung ablehnen" +noCrawleDescription: "Suchmaschinen bitten, die eigene Profilseite, Beiträge, Nutzer-Seiten + usw. nicht zu indexieren." +lockedAccountInfo: "Auch wenn du Follow-Anfragen auf manuelle Bestätigung setzt, wird + jeder deiner Posts öffentlich sichtbar sein, sofern du ihre Sichtbarkeit nicht auf + \"Nur Follower\" setzt." +alwaysMarkSensitive: "Medien standardmäßig als sensiblen Inhalt markieren" +loadRawImages: "Anstatt Vorschaubilder immer Originalbilder anzeigen" +disableShowingAnimatedImages: "Animierte Bilder nicht abspielen" +verificationEmailSent: "Eine Bestätigungsmail wurde an deine Email-Adresse versendet. + Besuche den dort enthaltenen Link, um die Verifizierung abzuschließen." +notSet: "Nicht konfiguriert" +emailVerified: "Email-Adresse bestätigt" +noteFavoritesCount: "Anzahl der favorisierten Beiträge" +pageLikesCount: "Anzahl an als \"Gefällt mir\" markierter Nutzer-Seiten" +pageLikedCount: "Anzahl erhaltener \"Gefällt mir\" auf Nutzer-Seiten" +contact: "Kontakt" +useSystemFont: "Standardschriftart des Systems verwenden" +clips: "Clips" +experimentalFeatures: "Experimentelle Funktionalitäten" +developer: "Entwickler" +makeExplorable: "Nutzerkonto in „Erkunden“ sichtbar machen" +makeExplorableDescription: "Wenn diese Option deaktiviert ist, ist dein Nutzerkonto + nicht im „Erkunden“-Bereich sichtbar." +showGapBetweenNotesInTimeline: "Abstände zwischen Beiträgen in der Timeline anzeigen" +duplicate: "Duplizieren" +left: "Links" +center: "Mittig" +wide: "Breit" +narrow: "Schmal" +reloadToApplySetting: "Diese Einstellung tritt nach einer Aktualisierung der Seite + in Kraft. Jetzt aktualisieren?" +needReloadToApply: "Diese Einstellung tritt nach einer Aktualisierung der Seite in + Kraft." +showTitlebar: "Titelleiste anzeigen" +clearCache: "Cache leeren" +onlineUsersCount: "{n} Nutzer sind online" +nUsers: "{n} Nutzer" +nNotes: "{n} Beiträge" +sendErrorReports: "Fehlerberichte senden" +sendErrorReportsDescription: "Ist diese Option aktiviert, so werden beim Auftreten + von Fehlern detaillierte Fehlerinformationen an Iceshrimp weitergegeben, was zur + Verbesserung der Qualität von Iceshrimp beiträgt.\nEnthalten in diesen Informationen + sind u.a. die Version deines Betriebssystems, welchen Browser du verwendest und + ein Verlauf deiner Aktivitäten innerhalb Iceshrimp." +myTheme: "Meine Farbkombination" +backgroundColor: "Hintergrundfarbe" +accentColor: "Akzentfarbe" +textColor: "Textfarbe" +saveAs: "Speichern als …" +advanced: "Fortgeschritten" +value: "Wert" +createdAt: "Erstellt am" +updatedAt: "Zuletzt geändert am" +saveConfirm: "Änderungen speichern?" +deleteConfirm: "Wirklich löschen?" +invalidValue: "Dieser Wert ist ungültig." +registry: "Registry" +closeAccount: "Nutzerkonto schließen" +currentVersion: "Momentane Version" +latestVersion: "Neuste Version" +youAreRunningUpToDateClient: "Du verwendest die neuste Version deines Clients." +newVersionOfClientAvailable: "Eine neuere Version deines Clients ist verfügbar." +usageAmount: "Verwendung" +capacity: "Kapazität" +inUse: "Verwendet" +editCode: "Code bearbeiten" +apply: "Anwenden" +receiveAnnouncementFromInstance: "Benachrichtigungen von diesem Server empfangen" +emailNotification: "Email-Benachrichtigungen" +publish: "Veröffentlichen" +inChannelSearch: "In Kanal suchen" +useReactionPickerForContextMenu: "Reaktionsauswahl durch Rechtsklick öffnen" +typingUsers: "{users} ist/sind am schreiben" +jumpToSpecifiedDate: "Zu bestimmtem Datum springen" +showingPastTimeline: "Es wird eine alte Timeline angezeigt" +clear: "Leeren" +markAllAsRead: "Alle als gelesen markieren" +goBack: "Zurück" +unlikeConfirm: "\"Gefällt mir\" wirklich entfernen?" +fullView: "Vollansicht" +quitFullView: "Vollansicht verlassen" +addDescription: "Beschreibung hinzufügen" +userPagePinTip: "Um Beiträge hier erscheinen zu lassen, drücke \"An dein Profil anheften\"\ + \ im Menü individueller Beiträge." +notSpecifiedMentionWarning: "Dieser Beitrag enthält Erwähnungen von Nutzern, die nicht + als Empfänger ausgewählt sind" +info: "Über" +userInfo: "Nutzerinformation" +unknown: "Unbekannt" +onlineStatus: "Onlinestatus" +hideOnlineStatus: "Onlinestatus verbergen" +hideOnlineStatusDescription: "Das Verbergen deines Onlinestatuses reduziert die Nützlichkeit + von Funktionen wie der Suche." +online: "Online" +active: "Aktiv" +offline: "Offline" +notRecommended: "Nicht empfohlen" +botProtection: "Schutz vor Bots" +instanceBlocking: "Verbundene Server verwalten" +selectAccount: "Nutzerkonto auswählen" +switchAccount: "Konto wechseln" +enabled: "Aktiviert" +disabled: "Deaktiviert" +quickAction: "Schnellaktionen" +user: "Nutzer" +administration: "Verwaltung" +accounts: "Nutzerkonten" +switch: "Wechseln" +noMaintainerInformationWarning: "Betreiberinformationen sind nicht konfiguriert." +noBotProtectionWarning: "Schutz vor Bots ist nicht konfiguriert." +configure: "Konfigurieren" +postToGallery: "Erstelle einen neuen Beitrag zur Bilder-Galerie" +gallery: "Bilder-Galerie" +recentPosts: "Neue Beiträge" +popularPosts: "Beliebte Beiträge" +shareWithNote: "Mit Beitrag teilen" +ads: "Werbeanzeigen" +expiration: "Frist" +memo: "Merkzettel" +priority: "Priorität" +high: "Hoch" +middle: "Mittel" +low: "Niedrig" +emailNotConfiguredWarning: "Keine Email-Adresse hinterlegt." +ratio: "Verhältnis" +previewNoteText: "Vorschau anzeigen" +customCss: "Benutzerdefiniertes CSS" +customCssWarn: "Verwende diese Einstellung nur, wenn du weißt, was sie tut. Ungültige + Eingaben können dazu führen, dass der Client nicht mehr normal funktioniert." +global: "Global" +squareAvatars: "Profilbilder quadratisch anzeigen" +sent: "Gesendet" +received: "Erhalten" +searchResult: "Suchergebnisse" +hashtags: "Hashtags" +troubleshooting: "Problembehandlung" +useBlurEffect: "Weichzeichnungseffekt in der Benutzeroberfläche verwenden" +learnMore: "Mehr erfahren" +iceshrimpUpdated: "Iceshrimp wurde aktualisiert!" +whatIsNew: "Änderungen anzeigen" +translate: "Übersetzen" +translatedFrom: "Aus {x} übersetzt" +accountDeletionInProgress: "Die Löschung deines Nutzerkontos ist momentan in Bearbeitung" +usernameInfo: "Ein Name, durch den dein Nutzerkonto auf diesem Server identifiziert + werden kann. Du kannst das Alphabet (a~z, A~Z), Ziffern (0~9) oder Unterstriche + (_) verwenden. Nutzernamen können später nicht geändert werden." +aiChanMode: "Ai-Modus" +keepCw: "Inhaltswarnungen beibehalten" +pubSub: "Pub/Sub Nutzerkonten" +lastCommunication: "Letzte Kommunikation" +resolved: "Gelöst" +unresolved: "Ungelöst" +breakFollow: "Follower entfernen" +itsOn: "Eingeschaltet" +itsOff: "Ausgeschaltet" +emailRequiredForSignup: "Angabe einer Email-Adresse als benötigt markieren" +unread: "Ungelesen" +filter: "Filter" +controlPanel: "Systemsteuerung" +manageAccounts: "Nutzerkonten verwalten" +makeReactionsPublic: "Reaktionsverlauf veröffentlichen" +makeReactionsPublicDescription: "Jeder wird die Liste deiner gesendeten Reaktionen + einsehen können." +classic: "Mittig/zentriert" +muteThread: "Thread stummschalten" +unmuteThread: "Threadstummschaltung aufheben" +ffVisibility: "Sichtbarkeit von Gefolgten/Followern" +ffVisibilityDescription: "Konfiguriere wer sehen kann, wem du folgst sowie wer dir + folgt." +continueThread: "Beitrag fortsetzen" +deleteAccountConfirm: "Dein Nutzerkonto wird unwiderruflich gelöscht. Trotzdem fortfahren?" +incorrectPassword: "Falsches Passwort." +voteConfirm: "Wirklich für „{choice}“ abstimmen?" +hide: "Inhalt verbergen" +leaveGroup: "Gruppe verlassen" +leaveGroupConfirm: "Möchtest du „{name}“ wirklich verlassen?" +useDrawerReactionPickerForMobile: "Auf mobilen Geräten ausfahrbare Reaktionsauswahl + anzeigen" +clickToFinishEmailVerification: "Drücke bitte auf [{ok}], um die Email-Bestätigung + abzuschließen." +overridedDeviceKind: "Gerätetyp" +smartphone: "Smartphone" +tablet: "Tablet" +auto: "Automatisch" +themeColor: "Farbe der Ticker-Laufschrift" +size: "Größe" +numberOfColumn: "Spaltenanzahl" +searchByGoogle: "Suchen" +instanceDefaultLightTheme: "Standard-Farbkombination auf diesem Server: \"Hell\"" +instanceDefaultDarkTheme: "Standard-Farbkombination auf diesem Server: \"Dunkel\"" +instanceDefaultThemeDescription: "Gib den Farbschemencode im Objektformat ein." +mutePeriod: "Dauer der Stummschaltung" +indefinitely: "Dauerhaft" +tenMinutes: "10 Minuten" +oneHour: "Eine Stunde" +oneDay: "Einen Tag" +oneWeek: "Eine Woche" +reflectMayTakeTime: "Es kann etwas dauern, bis sich dies widerspiegelt." +failedToFetchAccountInformation: "Nutzerkontoinformationen konnten nicht abgefragt + werden" +rateLimitExceeded: "Anzahl der Versuche überschritten" +cropImage: "Bild zuschneiden" +cropImageAsk: "Möchtest du das Bild zuschneiden?" +file: "Datei" +recentNHours: "Die letzten {n} Stunden" +recentNDays: "Die letzten {n} Tage" +noEmailServerWarning: "Es ist kein Email-Server konfiguriert." +thereIsUnresolvedAbuseReportWarning: "Es liegen ungelöste Meldungen vor." +recommended: "Favoriten" +check: "Kontrolle" +driveCapOverrideLabel: "Die Cloud-Drive-Kapazität dieses Nutzers verändern" +driveCapOverrideCaption: "Gib einen Wert von 0 oder weniger ein, um die Kapazität + auf den Standard zurückzusetzen." +requireAdminForView: "Melde dich mit einem Administratorkonto an, um dies einzusehen." +isSystemAccount: "Dieses Konto wird vom System erstellt und automatisch verwaltet. + Bitte moderieren, bearbeiten, löschen oder manipulieren Sie dieses Konto nicht, + da es sonst zu einem Server-Absturz kommen könnte." +typeToConfirm: "Bitte gib zur Bestätigung {x} ein" +deleteAccount: "Nutzerkonto löschen" +document: "Dokumentation" +numberOfPageCache: "Anzahl der zwischengespeicherten Seiten" +numberOfPageCacheDescription: "Das Erhöhen dieses Caches führt zu einer angenehmerern + Nutzererfahrung, erhöht aber Serverlast und Arbeitsspeicherauslastung." +logoutConfirm: "Wirklich abmelden?" +lastActiveDate: "Zuletzt verwendet am" +statusbar: "Statusleiste" +pleaseSelect: "Wähle eine Option" +reverse: "Umkehren" +colored: "Farbig" +refreshInterval: "Aktualisierungsintervall" +label: "Beschriftung" +type: "Art" +speed: "Geschwindigkeit" +slow: "Langsam" +fast: "Schnell" +sensitiveMediaDetection: "Erkennung von Medien mit sensiblen Inhalten" +localOnly: "Nur Lokal" +remoteOnly: "Nur für andere/fremde Server" +failedToUpload: "Hochladen fehlgeschlagen" +cannotUploadBecauseInappropriate: "Diese Datei kann nicht hochgeladen werden, da Anteile + der Datei als möglicherweise NSFW festgestellt wurden." +cannotUploadBecauseNoFreeSpace: "Die Datei konnte nicht hochgeladen werden, da dein + Cloud-Drive-Speicherplatz aufgebraucht ist." +beta: "Beta" +enableAutoSensitive: "Automatische Markierung als sensibler Inhalt" +enableAutoSensitiveDescription: "Erlaubt, wo möglich, die automatische Erkennung und + Kennzeichnung von NSFW-Medien durch maschinelles Lernen. Auch wenn diese Option + deaktiviert ist, kann sie über den Server aktiviert sein." +activeEmailValidationDescription: "Aktivert strengere Überprüfung von E-Mail-Adressen, + d.h. Testen auf Wegwerfadressen und darauf, ob mit der Adresse tatsächlich kommuniziert + werden kann. Ist dies deaktiviert, so wird nur das Format der E-Mail überprüft." +navbar: "Navigationsleiste" +shuffle: "Mischen" +account: "Nutzerkonto" +move: "Verschieben" +_sensitiveMediaDetection: + description: "Ermöglicht eine Erleichterung der Servermoderation durch die automatische + Erkennungen von Medien mit sensiblen Inhalten unter Verwendung von maschinellem + Lernen. Hierdurch wird die Serverlast etwas erhöht." + sensitivity: "Erkennungssensitivität" + sensitivityDescription: "Durch das Senken der Sensitivität kann die Anzahl an Fehlerkennungen + (sog. false positives) reduziert werden. Durch ein Erhöhen dieser kann die Anzahl + an verpassten Erkennungen (sog. false negatives) reduziert werden." + setSensitiveFlagAutomatically: "Als sensiblen Inhalt markieren" + setSensitiveFlagAutomaticallyDescription: "Die Resultate der internen Erkennung + werden beibehalten, auch wenn diese Option deaktiviert ist." + analyzeVideos: "Videoanalyse aktivieren" + analyzeVideosDescription: "Analysiert zusätzlich zu Bildern auch Videos. Die Last + des Servers wird hierdurch etwas erhöht." +_emailUnavailable: + used: "Diese Email-Adresse wird bereits verwendet" + format: "Das Format dieser Email-Adresse ist ungültig" + disposable: "Wegwerf-Email-Adressen können nicht verwendet werden" + mx: "Dieser Email-Server ist ungültig" + smtp: "Dieser Email-Server antwortet nicht" +_ffVisibility: + public: "Öffentlich" + followers: "Nur für Follower sichtbar" + private: "Privat" +_signup: + almostThere: "Fast geschafft" + emailAddressInfo: "Bitte gib deine Email-Adresse ein. Sie wird nicht öffentlich + einsehbar sein." + emailSent: "An deine Email-Adresse ({email}) wurde soeben eine Bestätigungsmail + geschickt. Bitte klicke auf den enthaltenen Link, um die Erstellung deines Nutzerkontos + abzuschließen." +_accountDelete: + accountDelete: "Nutzerkonto löschen" + mayTakeTime: "Da die Löschung eines Nutzerkontos ein aufwendiger Prozess ist, kann + dessen Dauer davon abhängen, wie viel Inhalt von diesem erstellt wurde oder wie + viele Dateien von diesem hochgeladen wurden." + sendEmail: "Sobald die Löschung abgeschlossen ist, wird an die mit ihm verknüpfte + Email-Adresse eine Benachrichtigung versendet." + requestAccountDelete: "Löschung deines Nutzerkontos anfordern" + started: "Die Löschung wurde eingeleitet." + inProgress: "Löschung des Accounts ist in Bearbeitung" +_ad: + back: "Zurück" + reduceFrequencyOfThisAd: "Diese Werbeanzeige weniger anzeigen" +_forgotPassword: + enterEmail: "Gib die Email-Adresse ein, mit der du dich registriert hast. An diese + wird ein Link gesendet, mit dem du dein Passwort zurücksetzen kannst." + ifNoEmail: "Solltest du bei der Registrierung keine Email-Adresse angegeben haben, + wende dich bitte an den Server-Administrator." + contactAdmin: "Dieser Server unterstützt keine Verwendung von Email-Adressen. Kontaktiere + bitte den Server-Administrator, um dein Passwort zurücksetzen zu lassen." +_gallery: + my: "Meine Bilder-Galerie" + liked: "Mit \"Gefällt mir\" markierte Beiträge" + like: "Gefällt mir" + unlike: "\"Gefällt mir\" entfernen" +_email: + _follow: + title: "Du hast einen neuen Follower" + _receiveFollowRequest: + title: "Du hast eine Follow-Anfrage erhalten" +_plugin: + install: "Plugins installieren" + installWarn: "Bitte nur vertrauenswürdige Plugins installieren." + manage: "Plugins verwalten" +_preferencesBackups: + list: "Erstellte Backups" + saveNew: "Neu erstellen" + loadFile: "Von Datei laden" + apply: "Auf dieses Gerät anwenden" + save: "Speichern" + inputName: "Gib einen Namen für dieses Backup ein" + cannotSave: "Speichern fehlgeschlagen" + nameAlreadyExists: "Es existiert bereits ein Backup unter dem Namen \"{name}\". + Bitte gib einen anderen Namen ein." + applyConfirm: "Wirklich das Backup \"{name}\" auf dieses Gerät anwenden? Bestehende + Einstellungen darauf werden überschrieben." + saveConfirm: "Als {name} speichern?" + deleteConfirm: "Das Backup {name} löschen?" + renameConfirm: "Soll dieses Backup von \"{old}\" zu \"{new}\" umbenannt werden?" + noBackups: "Keine Backups existieren. Backups können über \"Neu erstellen\" erstelllt + werden." + createdAt: "Erstellt am: {date} {time}" + updatedAt: "Aktualisiert am: {date} {time}" + cannotLoad: "Laden fehlgeschlagen" + invalidFile: "Ungültiges Dateiformat" + delete: Backup löschen +_registry: + scope: "Scope" + key: "Schlüssel" + keys: "Schlüssel" + domain: "Domain" + createKey: "Schlüssel erstellen" +_aboutIceshrimp: + about: "Iceshrimp ist ein Fork von Firefish, der seit 2022 von zotan entwickelt + wird." + contributors: "Hauptmitwirkende" + allContributors: "Alle Mitwirkenden" + source: "Quellcode" + translation: "Iceshrimp übersetzen" + donate: "An Iceshrimp spenden" + morePatrons: "Wir schätzen ebenso die Unterstützung vieler anderer hier nicht gelisteter + Personen sehr. Danke! 🥰" + patrons: "UnterstützerInnen" + donateTitle: Gefällt dir Iceshrimp? + donateHost: An {host} spenden + pleaseDonateToIceshrimp: Bitte ziehe in Erwägung, an Iceshrimp zu spenden, um die + Entwicklung zu unterstützen. + documentation: Dokumentation + chatroom: Chatroom + roadmap: Roadmap + changelog: Changelog +_nsfw: + respect: "Medien mit sensiblen Inhalten verbergen" + ignore: "Medien mit sensiblen Inhalten nicht verbergen" + force: "Alle Medien verbergen" +_mfm: + cheatSheet: "MFM Spickzettel" + intro: "MFM ist eine Markup-Sprache, die in Iceshrimp, Misskey, Akkoma und anderen + Programmen verwendet wird und in Beiträgen und Chats genutzt werden kann. Hier + kannst du eine Liste aller verfügbaren MFM-Syntaxe einsehen." + dummy: "Iceshrimp erweitert die Welt des Fediverse" + mention: "Erwähnung" + mentionDescription: "Mit At-Zeichen und Nutzername kann ein individueller Nutzer + angegeben werden." + hashtag: "Hashtag" + hashtagDescription: "Mit einer Raute und Text kann ein Hashtag angegeben werden." + url: "URL" + urlDescription: "Zeigt URLs an." + link: "Link" + linkDescription: "Zeigt spezifische Textabschnitte als URL an." + bold: "Fett" + boldDescription: "Zeichen zur Betonung dicker erscheinen lassen." + small: "Klein" + smallDescription: "Inhalt klein und dünn erscheinen lassen." + center: "Zentrieren" + centerDescription: "Inhalt zentriert anzeigen." + inlineCode: "Code (Eingebettet)" + inlineCodeDescription: "Syntax-Hervorhebung für (Programm-)Code eingebettet anzeigen." + blockCode: "Code (Block)" + blockCodeDescription: "Syntax-Hervorhebung für mehrzeiligen (Programm-)Code als + Block anzeigen." + inlineMath: "Mathe (Eingebettet)" + inlineMathDescription: "Mathematische Formeln (KaTeX) eingebettet anzeigen" + blockMath: "Mathe (Block)" + blockMathDescription: "Mathematische Formeln (KaTeX) als Block einbetten" + quote: "Zitationen" + quoteDescription: "Inhalt als Zitat anzeigen." + emoji: "Benutzerdefinierte Emojis" + emojiDescription: "Durch das Umschließen von Emoji-Namen durch Doppelpunkte können + benutzerdefinierte Emojis angezeigt werden." + search: "Suche" + searchDescription: "Eine vorgefertige Suchanfragebox anzeigen." + flip: "Spiegelung" + flipDescription: "Inhalt horizontal oder vertikal gespiegelt anzeigen." + jelly: "Animation (Dehnen)" + jellyDescription: "Verleiht Inhalt eine sich dehnende Animation." + tada: "Animation (Tada)" + tadaDescription: "Verleiht Inhalt eine Animation mit \"Tada!\"-Gefühl." + jump: "Animation (Sprung)" + jumpDescription: "Verleiht Inhalt eine springende Animation." + bounce: "Animation (Federn)" + bounceDescription: "Verleiht Inhalt eine federnde Animation." + shake: "Animation (Zittern)" + shakeDescription: "Verleiht Inhalt eine zitternde Animation." + twitch: "Animation (Zucken)" + twitchDescription: "Verleiht Inhalt eine sehr stark zuckende Animation." + spin: "Animation (Rotieren)" + spinDescription: "Verleiht Inhalt eine rotierende Animation." + x2: "Groß" + x2Description: "Inhalte größer anzeigen." + x3: "Sehr groß" + x3Description: "Inhalte noch größer anzeigen." + x4: "Unglaublich groß" + x4Description: "Lässt Inhalte noch größer als größer als groß angezeigt werden." + blur: "Weichzeichnen" + blurDescription: "Inhalte durch Weihzeichnung verschwimmen lassen. Durch das Bewegen + des Mauszeigers über den Inhalt wird er klar angezeigt." + font: "Schriftart" + fontDescription: "Setzt die Schriftart des Inhaltes fest." + rainbow: "Regenbogen" + rainbowDescription: "Lässt den Inhalt in Regenbogenfarben erscheinen." + sparkle: "Glitzer" + sparkleDescription: "Verleiht Inhalt einen glitzernden Partikeleffekt." + rotate: "Drehen" + rotateDescription: "Dreht den Inhalt um einen angegebenen Winkel." + fade: "Ein-/Ausblenden" + fadeDescription: "Blended Inhalt ein and aus." + plain: "Schlicht" + plainDescription: "Deaktiviert jegliche MFM-Syntax, die sich innerhalb dieses MFM-Effekts + befindet." + foreground: Vordergrundfarbe + background: Hintergrundfarbe + positionDescription: Inhalt um einen bestimmten Betrag verschieben. + position: Position + cropDescription: Inhalt zuschneiden. + crop: Zuschneiden + scale: Maßstab + scaleDescription: Skaliere den Inhalt um einen bestimmten Betrag. + foregroundDescription: Ändern der Vordergrundfarbe von Text. + backgroundDescription: Ändern der Hintergrundfarbe von Text + play: MFM abspielen + stop: MFM anhalten + warn: MFM können schnell bewegte oder anderweitig auffallende Animationen enthalten + alwaysPlay: Alle animierten MFM immer automatisch abspielen + advancedDescription: Wenn diese Funktion deaktiviert ist, können nur einfache Formatierungen + vorgenommen werden, es sei denn, animiertes MFM ist aktiviert + advanced: Erweitertes MFM + border: Rahmen + unixtime: Unixzeit + rubyDescription: Stelle eine kleine Anmerkung über dem Text dar. Dies wird für gewöhnlich + dafür genutzt, um Angaben über die Aussprache von ostasiatischen Schriftzeichen + zu machen. + followmouseDescription: Lässt Inhalte dem Mauscursor folgen. + followmouseToggle: '' +_instanceTicker: + none: "Nie anzeigen" + remote: "Für Nutzer eines anderen Servers anzeigen" + always: "Immer anzeigen" +_serverDisconnectedBehavior: + reload: "Automatisch aktualisieren" + dialog: "Warnungsfenster zeigen" + quiet: "Unaufdringlich warnen" + nothing: Nichts ändern +_channel: + create: "Kanal erstellen" + edit: "Kanal bearbeiten" + setBanner: "Kanalbanner festlegen" + removeBanner: "Kanalbanner entfernen" + featured: "Trends" + owned: "In Besitz" + following: "Gefolgt" + usersCount: "{n} Teilnehmer" + notesCount: "{n} Beiträge" + nameAndDescription: Name und Beschreibung + nameOnly: Nur den Namen +_menuDisplay: + sideFull: "Seitlich" + sideIcon: "Seitlich (Icons)" + top: "Oben" + hide: "Ausblenden" +_wordMute: + muteWords: "Stummgeschaltete Wörter" + muteWordsDescription: "Zum Nutzen einer \"UND\"-Verknüpfung Einträge mit Leerzeichen + trennen, zum Nutzen einer \"ODER\"-Verknüpfung Einträge mit einem Zeilenumbruch + trennen." + muteWordsDescription2: "Umgib Schlüsselworter mit Schrägstrichen, um Reguläre Ausdrücke + zu verwenden." + softDescription: "Beiträge, die die angegebenen Konditionen erfüllen, in der Timeline + ausblenden." + hardDescription: "Verhindern, dass Beiträge, die die angegebenen Konditionen erfüllen, + der Timeline hinzugefügt werden. Zudem werden diese Beiträge auch nicht der Timeline + hinzugefügt, falls die Konditionen geändert werden." + soft: "Leicht" + hard: "Schwer" + mutedNotes: "Stummgeschaltete Beiträge" +_instanceMute: + instanceMuteDescription: "Schaltet alle Beiträge/Boosts stumm, die von den gelisteten + Servern stammen, inklusive Antworten von Nutzern an einen Nutzer eines stummgeschalteten + Servers." + instanceMuteDescription2: "Mit Zeilenumbrüchen trennen" + title: "Blendet Beiträge von aufgelisteten Servern aus." + heading: "Liste der Server die stummgeschaltet werden sollen" +_theme: + explore: "Farbkombinationen finden" + install: "Eine Farbkombination installieren" + manage: "Farbkombinationen verwalten" + code: "Farbschemencode" + description: "Beschreibung" + installed: "{name} wurde installiert" + installedThemes: "Installierte Farbkombinationen" + builtinThemes: "Vorinstallierte Farbkombinationen" + alreadyInstalled: "Diese Farbkombination ist bereits installiert" + invalid: "Diese Farbkombination ist nicht möglich" + make: "Erstelle eine Farbkombination" + base: "Vorlage" + addConstant: "Konstante hinzufügen" + constant: "Konstante" + defaultValue: "Standardwert" + color: "Farbe" + refProp: "Eigenschaft referenzieren" + refConst: "Konstante referenzieren" + key: "Schlüssel" + func: "Funktionen" + funcKind: "Funktionsart" + argument: "Parameter" + basedProp: "Referenzierte Eigenschaft" + alpha: "Transparenz" + darken: "Verdunkeln" + lighten: "Erhellen" + inputConstantName: "Name der Konstanten eingeben" + importInfo: "Hier kannst du Farbschemencode einfügen, um ihn in den Editor zu importieren" + deleteConstantConfirm: "Die Konstante {const} wirklich löschen?" + keys: + accent: "Akzentfarbe" + bg: "Hintergrund" + fg: "Text" + focus: "Fokus" + indicator: "Indikator" + panel: "Panel" + shadow: "Schatten" + header: "Kopfzeile" + navBg: "Hintergrund der Seitenleiste" + navFg: "Text der Seitenleiste" + navHoverFg: "Text der Seitenleiste (Mouseover)" + navActive: "Text der Seitenleiste (Aktiv)" + navIndicator: "Indikator der Seitenleiste" + link: "Link" + hashtag: "Hashtag" + mention: "Erwähnung" + mentionMe: "Erwähnung (Ich)" + renote: "Renote" + modalBg: "Modalhintergrund" + divider: "Trenner" + scrollbarHandle: "Griff des Scrollbalkens" + scrollbarHandleHover: "Griff des Scrollbalkens (Mouseover)" + dateLabelFg: "Text von Datumsbeschriftungen" + infoBg: "Hintergrund von Informationen" + infoFg: "Text von Informationen" + infoWarnBg: "Hintergrund von Warnungen" + infoWarnFg: "Text von Warnungen" + cwBg: "Hintergrund des Inhaltswarnungsknopfs" + cwFg: "Text des Inhaltswarnungsknopfs" + cwHoverBg: "Hintergrund des Inhaltswarnungsknopfs (Mouseover)" + toastBg: "Hintergrund von Benachrichtigungen" + toastFg: "Text von Benachrichtigungen" + buttonBg: "Hintergrund von Schaltflächen" + buttonHoverBg: "Hintergrund von Schaltflächen (Mouseover)" + inputBorder: "Rahmen von Eingabefeldern" + listItemHoverBg: "Hintergrund von Listeneinträgen (Mouseover)" + driveFolderBg: "Hintergrund von Cloud-Drive-Ordnern" + wallpaperOverlay: "Hintergrundbild-Overlay" + badge: "Wappen" + messageBg: "Hintergrund von Chats" + accentDarken: "Akzent (Verdunkelt)" + accentLighten: "Akzent (Erhellt)" + fgHighlighted: "Hervorgehobener Text" +_sfx: + note: "Beiträge" + noteMy: "Meine Beiträge" + notification: "Benachrichtigungen" + chat: "Chat" + chatBg: "Chat (Hintergrund)" + antenna: "News-Picker" + channel: "Kanalbenachrichtigung" +_ago: + future: "Zukunft" + justNow: "Gerade eben" + secondsAgo: "vor {n} s" + minutesAgo: "vor {n} min {n2} s" + hoursAgo: "vor {n} h {n2} min" + daysAgo: "vor {n} T {n2} h" + weeksAgo: "vor {n} W {n2} T" + monthsAgo: "vor {n} M {n2} W" + yearsAgo: "vor {n} J {n2} M" +_time: + second: "Sekunde(n)" + minute: "Minute(n)" + hour: "Stunde(n)" + day: "Tag(en)" +_tutorial: + title: "Wie man Iceshrimp benutzt" + step1_1: "Willkommen!" + step1_2: "Wir werden Sie einrichten. Sie werden im Handumdrehen einsatzbereit sein!" + step2_1: "Bitte füllen Sie zuerst Ihr Profil aus." + step2_2: "Wenn du ein paar Angaben zu deiner Person machst, können andere leichter + erkennen, ob sie deine Beiträge sehen oder dir folgen wollen." + step3_1: "Jetzt ist es an der Zeit, einigen Leuten zu folgen!" + step3_2: "Deine Home- und Social-Timeline basiert darauf, wem du folgst, also folge + für den Anfang ein paar Nutzerkonten.\nKlicke das Plus Symbol oben links in einem + Profil um ihm zu folgen." + step4_1: "Wir bringen dich nach draußen." + step4_2: "Für Ihren ersten Beitrag machen einige Leute gerne einen {introduction}-Beitrag + oder ein einfaches \"Hallo Welt!\"" + step5_1: "Timelines, Timelines überall!" + step5_2: "Dein Server hat {timelines} verschiedene Timelines aktiviert." + step5_3: "Die {icon} Home-Timeline ist die Timeline, in der du die Beiträge der + Nutzerkonten sehen kannst, denen du folgst." + step5_4: "In der {Icon} Local-Timeline kannst du die Beiträge von jedem/jeder sehen + der/die auf diesem Server registriert ist." + step5_5: "Die Social-Timeline {icon} ist eine Kombination aus der Home-Timeline + und der Local-Timeline." + step5_6: "In der Empfohlen-Timeline {icon} kannst du Posts sehen, die von den Admins + vorgeschlagen wurden." + step5_7: "In der {icon} Global-Timeline können Sie Beiträge von allen verknüpften + Servern aus dem Fediverse sehen." + step6_1: "Also, was ist das hier?" + step6_2: "Mit Deiner Anmeldung zu Iceshrimp bist Du gleichzeitig einem Portal zum + Fediverse beigetreten, einem Netzwerk mit Tausenden von, miteinander verbundenen, + Servern." + step6_3: "Jeder der Server funktioniert auf unterschiedliche Weise, und nicht alle + Server führen Iceshrimp aus. Dieser jedoch schon! Es ist zu Beginn vielleicht + ein wenig kompliziert, aber Sie werden in kürzester Zeit den Dreh raus haben." + step6_4: "Jetzt bist Du startbereit, entdecke die Möglichkeiten und hab Spaß dabei!" +_2fa: + alreadyRegistered: "Du hast bereits ein Gerät für Zwei-Faktor-Authentifizierung + registriert." + registerTOTP: "Neues Gerät registrieren" + registerSecurityKey: "Neuen Sicherheitsschlüssel registrieren" + step1: "Installiere zuerst eine Authentifizierungsapp (z.B. {a} oder {b}) auf deinem + Gerät." + step2: "Dann, scanne den angezeigten QR-Code mit deinem Gerät." + step2Url: "Nutzt du ein Desktopprogramm kannst du alternativ diese URL eingeben:" + step3: "Gib zum Abschluss den Token ein, der von deiner App angezeigt wird." + step4: "Alle folgenden Anmeldungsversuche werden ab sofort die Eingabe eines solchen + Tokens benötigen." + securityKeyInfo: "Du kannst neben Fingerabdruck- oder PIN-Authentifizierung auf + deinem Gerät auch Anmeldung mit Hilfe eines FIDO2-kompatiblen Hardware-Sicherheitsschlüssels + einrichten." + step3Title: Gib deinen Authentifizierungscode ein + renewTOTPOk: Neu konfigurieren + securityKeyNotSupported: Dein Browser unterstützt Hardware-Security-Keys nicht. + chromePasskeyNotSupported: Chrome Passkeys werden momentan nicht unterstützt. + renewTOTP: Konfiguriere deine Authenticator App neu + renewTOTPCancel: Abbrechen + tapSecurityKey: Bitte folge den Anweisungen deines Browsers, um einen Hardware-Security-Key + oder einen Passkey zu registrieren + removeKey: Entferne deinen Hardware-Security-Key + removeKeyConfirm: Möchtest du wirklich deinen Key mit der Bezeichnung {name} löschen? + renewTOTPConfirm: Das wird dazu führen, dass du Verifizierungscodes deiner vorherigen + Authenticator App nicht mehr nutzen kannst + whyTOTPOnlyRenew: Die Authentificator App kann nicht entfernt werden, solange ein + Hardware-Security-Key registriert ist. + step2Click: Ein Klick auf diesen QR-Code erlaubt es dir eine 2FA-Methode zu deinem + Security Key oder deiner Authenticator App hinzuzufügen. + registerTOTPBeforeKey: Bitte registriere eine Authentificator App, um einen Hardware-Security-Key + oder einen Passkey zu nutzen. + securityKeyName: Gib einen Namen für den Key ein + token: 2FA-Token +_permissions: + "read:account": "Deine Nutzerkontoinformationen lesen" + "write:account": "Deine Nutzerkontoinformationen bearbeiten" + "read:blocks": "Die Liste deiner blockierten Nutzer lesen" + "write:blocks": "Die Liste deiner blockierten Nutzer bearbeiten" + "read:drive": "Deine Cloud-Drive-Dateien und Ordner lesen" + "write:drive": "Deine Cloud-Drive-Dateien und Ordner bearbeiten oder löschen" + "read:favorites": "Deine Lesezeichen-Liste lesen" + "write:favorites": "Deine Lesezeichen-Liste bearbeiten" + "read:following": "Die Liste der Nutzer, denen du folgst, lesen" + "write:following": "Anderen Nutzern folgen oder entfolgen" + "read:messaging": "Chats lesen" + "write:messaging": "Chats bedienen" + "read:mutes": "Stummschaltungen lesen" + "write:mutes": "Stummschaltungen bearbeiten" + "write:notes": "Beiträge schreiben oder löschen" + "read:notifications": "Benachrichtigungen lesen" + "write:notifications": "Benachrichtigungen bedienen" + "read:reactions": "Reaktionen lesen" + "write:reactions": "Reaktionen bedienen" + "write:votes": "Umfragen bedienen" + "read:pages": "Deine Nutzer-Seiten lesen" + "write:pages": "Deine Nutzer-Seiten bearbeiten oder löschen" + "read:page-likes": "Liste der Nutzer-Seiten, die mir gefallen, lesen" + "write:page-likes": "Liste der Nutzer-Seiten, die mir gefallen, bearbeiten" + "read:user-groups": "Nutzergruppen lesen" + "write:user-groups": "Nutzergruppen bearbeiten oder löschen" + "read:channels": "Channels lesen" + "write:channels": "Channels bedienen" + "read:gallery": "Beiträge deiner Bilder-Galerie lesen" + "write:gallery": "Deine Bilder-Galerie bearbeiten" + "read:gallery-likes": "Liste deiner mit \"Gefällt mir\" markierten Bilder-Galerie-Beiträge + lesen" + "write:gallery-likes": "Liste deiner mit \"Gefällt mir\" markierten Bilder-Galerie-Beiträge + bearbeiten" +_auth: + shareAccess: "Möchtest du „{name}“ authorisieren, auf dieses Nutzerkonto zugreifen + zu können?" + shareAccessAsk: "Bist du dir sicher, dass du diese Anwendung authorisieren möchtest, + auf dein Nutzerkonto zugreifen zu können?" + permissionAsk: "Diese Anwendung fordert folgende Berechtigungen:" + pleaseGoBack: "Bitte kehre zur Anwendung zurück" + callback: "Es wird zur Anwendung zurückgekehrt" + denied: "Zugriff verweigert" + copyAsk: 'Bitte fügen Sie den folgenden Autorisierungscode in die Anwendung ein:' + allPermissions: Voller Kontozugriff + authRequired: Autorisierung erforderlich + signedInAs: Angemeldet als +_antennaSources: + all: "Alle Beiträge" + homeTimeline: "Beiträge von Nutzern, denen gefolgt wird" + users: "Beiträge von einem oder mehreren angegebenen Nutzern" + userList: "Beiträge von allen Nutzern einer Liste" + userGroup: "Beiträge von allen Nutzern einer Gruppe" + instances: Beiträge von allen Nutzern auf einem Server +_weekday: + sunday: "Sonntag" + monday: "Montag" + tuesday: "Dienstag" + wednesday: "Mittwoch" + thursday: "Donnerstag" + friday: "Freitag" + saturday: "Samstag" +_widgets: + memo: "Merkzettel" + notifications: "Benachrichtigungen" + timeline: "Timeline" + calendar: "Kalender" + trends: "Trends" + clock: "Uhr" + rss: "RSS-Reader" + rssTicker: "RSS Ticker" + activity: "Aktivität" + photos: "Fotos" + digitalClock: "Digitaluhr" + unixClock: "UNIX-Uhr" + federation: "Föderation" + postForm: "Beitragsfeld" + slideshow: "Diashow" + button: "Knopf" + onlineUsers: "Nutzer Online" + jobQueue: "Job-Warteschlange" + serverMetric: "Servermetriken" + aiscript: "AiScript-Konsole" + aichan: "Ai" + _userList: + chooseList: Wählen Sie eine Liste aus + userList: Benutzerliste + serverInfo: Server-Infos + meiliStatus: Server-Status + meiliSize: Indexgröße + meiliIndexCount: Indexierte Beiträge +_cw: + hide: "Verbergen" + show: "Inhalt anzeigen" + chars: "{count} Zeichen" + files: "{count} Datei(en)" +_poll: + noOnlyOneChoice: "Es müssen mindestens zwei Antwortmöglichkeiten vorhanden sein" + choiceN: "Auswahl {n}" + noMore: "Du kannst keine weiteren Auswahlmöglichkeiten hinzufügen" + canMultipleVote: "Auswahl mehrerer Antworten erlauben" + expiration: "Abstimmung beenden" + infinite: "Nie" + at: "Beenden am …" + after: "Beenden nach …" + deadlineDate: "Enddatum" + deadlineTime: "Zeit" + duration: "Dauer" + votesCount: "{n} Stimmen" + totalVotes: "Insgesamt {n} Stimmen" + vote: "Abstimmen" + showResult: "Ergebnis anzeigen" + voted: "Abgestimmt" + closed: "Beendet" + remainingDays: "{d} Tag(e) {h} Stunde(n) verbleibend" + remainingHours: "{h} Stunde(n) {m} Minute(n) verbleibend" + remainingMinutes: "{m} Minute(n) {s} Sekunde(n) verbleibend" + remainingSeconds: "{s} Sekunde(n) verbleibend" +_visibility: + public: "Öffentlich" + publicDescription: "Dein Beitrag wird global für alle Nutzer sichtbar sein" + home: "nicht aufgelistet" + homeDescription: "Beitrag nur auf der Home-Timeline anzeigen" + followers: "Follower" + followersDescription: "Nur für Follower sichtbar" + specified: "Direkt" + specifiedDescription: "Nur für bestimmte Nutzer sichtbar" + localOnly: "Nur Lokal" + localOnlyDescription: "Unsichtbar für Nutzer anderer Server" +_postForm: + replyPlaceholder: "Diesem Beitrag antworten …" + quotePlaceholder: "Diesen Beitrag zitieren …" + channelPlaceholder: "In einen Kanal senden …" + _placeholders: + a: "Was machst du momentan?" + b: "Was ist um dich herum los?" + c: "Was geht dir durch den Kopf?" + d: "Was möchtest du sagen?" + e: "Fang an zu schreiben …" + f: "Ich warte darauf, dass du schreibst …" +_profile: + name: "Name" + username: "Benutzername" + description: "Profilbeschreibung" + youCanIncludeHashtags: "Du kannst auch Hashtags in deiner Profilbeschreibung verwenden." + metadata: "Zusätzliche Informationen" + metadataEdit: "Zusätzliche Informationen bearbeiten" + metadataDescription: "Hierdurch kannst du auf deinem Profil zusätzliche Informationsblöcke + anzeigen lassen. Sie können ein {a}-Tag oder ein {l}-Tag mit {rel} hinzufügen, + um den Link in Ihrem Profil zu überprüfen!" + metadataLabel: "Beschriftung" + metadataContent: "Inhalt" + changeAvatar: "Profilbild ändern" + changeBanner: "Banner ändern" + locationDescription: Wenn Sie Ihren Ort zuerst eingeben, wird für andere Benutzer + die Ortszeit angezeigt. +_exportOrImport: + allNotes: "Alle Beiträge" + followingList: "Gefolgte Nutzer" + muteList: "Stummschaltungen" + blockingList: "Blockierungen" + userLists: "Listen" + excludeMutingUsers: "Stummgeschaltete Nutzer aussortieren" + excludeInactiveUsers: "Inaktive Nutzer aussortieren" +_charts: + federation: "Föderation" + apRequest: "Anfragen" + usersIncDec: "Unterschied in der Anzahl von Nutzern" + usersTotal: "Anzahl aller Nutzer" + activeUsers: "Aktive Nutzer" + notesIncDec: "Unterschied bei der Anzahl an Beiträgen" + localNotesIncDec: "Unterschied bei der Anzahl an lokalen Beiträgen" + remoteNotesIncDec: "Differenz zur Anzahl von Beiträgen von anderen Servern." + notesTotal: "Anzahl aller Beiträge" + filesIncDec: "Unterschied in der Anzahl an Dateien" + filesTotal: "Anzahl aller Dateien" + storageUsageIncDec: "Unterschied in der Höhe der Speichernutzung" + storageUsageTotal: "Gesamte Speichernutzung" +_instanceCharts: + requests: "Anfragen" + users: "Unterschied in der Anzahl an Nutzern" + usersTotal: "Gesamtanzahl an Nutzern" + notes: "Unterschied in der Anzahl an Beiträgen" + notesTotal: "Gesamtanzahl der Beiträge" + ff: "Unterschied in der Anzahl an gefolgten Nutzern und Followern " + ffTotal: "Gesamtanzahl an gefolgten Nutzern und Followern" + cacheSize: "Unterschied in der Größe des Caches" + cacheSizeTotal: "Gesamtgröße des Caches" + files: "Unterschied in der Anzahl an Dateien" + filesTotal: "Gesamtanzahl an Dateien" +_timelines: + home: "Home-TL" + local: "Local-TL" + social: "Social-TL" + global: "Global-TL" + recommended: Admin-Favoriten +_pages: + newPage: "Neue Seite erstellen" + editPage: "Seite bearbeiten" + readPage: "Quelltextansicht" + created: "Seite erfolgreich erstellt" + updated: "Seite erfolgreich aktualisiert" + deleted: "Seite erfolgreich gelöscht" + pageSetting: "Seiteneinstellungen" + nameAlreadyExists: "Die angegebene Seiten-URL existiert bereits" + invalidNameTitle: "Die angegebene Seiten-URL ist ungültig" + invalidNameText: "Überprüfe, ob der Seitentitel nicht leer ist" + editThisPage: "Diese Seite bearbeiten" + viewSource: "Quelltext anzeigen" + viewPage: "Seite anschauen" + like: "Gefällt mir" + unlike: "\"Gefällt mir\" entfernen" + my: "Meine Nutzer-Seiten" + liked: "Nutzer-Seiten, die mir gefallen" + featured: "Beliebt" + inspector: "Inspektor" + contents: "Inhalte" + content: "Seitenblock" + variables: "Variablen" + title: "Titel" + url: "Nutzer-Seiten-URL" + summary: "Zusammenfassung" + alignCenter: "Zentrieren" + hideTitleWhenPinned: "Nutzer-Seitentitel wenn angeheftet ausblenden" + font: "Schriftart" + fontSerif: "Serif" + fontSansSerif: "sans-serif" + eyeCatchingImageSet: "Vorschaubild festlegen" + eyeCatchingImageRemove: "Vorschaubild entfernen" + chooseBlock: "Block hinzufügen" + selectType: "Typ auswählen" + enterVariableName: "Gib einen Variablennamen ein" + variableNameIsAlreadyUsed: "Dieser Name wird bereits von einer anderen Variable + verwendet" + contentBlocks: "Inhalt" + inputBlocks: "Eingabe" + specialBlocks: "Spezial" + blocks: + text: "Text" + textarea: "Textfeld" + section: "Abschnitt" + image: "Bild" + button: "Knopf" + if: "Falls" + _if: + variable: "Variable" + post: "Beitragsfeld" + _post: + text: "Inhalt" + attachCanvasImage: "Leinwandbild anfügen" + canvasId: "Leinwand-ID" + textInput: "Texteingabe" + _textInput: + name: "Variablenname" + text: "Titel" + default: "Standardwert" + textareaInput: "Mehrzeiliges Texteingabefeld" + _textareaInput: + name: "Variablenname" + text: "Titel" + default: "Standardwert" + numberInput: "Zahleneingabe" + _numberInput: + name: "Variablenname" + text: "Titel" + default: "Standardwert" + canvas: "Leinwand" + _canvas: + id: "Leinwand-ID" + width: "Breite" + height: "Höhe" + note: "Eingebetteter Beitrag" + _note: + id: "Beitrags-ID" + idDescription: "Du kannst alternativ auch die Beitrags-URL angeben." + detailed: "Detailierte Ansicht" + switch: "Fallunterscheidung" + _switch: + name: "Variablenname" + text: "Titel" + default: "Standardwert" + counter: "Zähler" + _counter: + name: "Variablenname" + text: "Titel" + inc: "Schrittgröße" + _button: + text: "Titel" + colored: "Farbig" + action: "Aktion, die bei Knopfdruck ausgeführt wird" + _action: + dialog: "Dialogfenster anzeigen" + _dialog: + content: "Inhalt" + resetRandom: "Zufallswert zurücksetzen" + pushEvent: "Ein Event senden" + _pushEvent: + event: "Eventname" + message: "Meldung, die bei Aktivierung angezeigt werden soll" + variable: "Variable, die gesendet werden soll" + no-variable: "Keine" + callAiScript: "AiScript ausführen" + _callAiScript: + functionName: "Funktionsname" + radioButton: "Optionsfeld" + _radioButton: + name: "Variablenname" + title: "Titel" + values: "Durch Zeilenümbrüche getrennte Auswahlmöglichkeiten" + default: "Standardwert" + script: + categories: + flow: "Steuerung" + logical: "Logische Operationen" + operation: "Berechnungen" + comparison: "Vergleiche" + random: "Zufällig" + value: "Werte" + fn: "Funktionen" + text: "Textoperationen" + convert: "Konvertierungen" + list: "Listen" + blocks: + text: "Text" + multiLineText: "Text (Mehrzeilig)" + textList: "Textliste" + _textList: + info: "Trenne jeden Eintrag mit einem Zeilenumbruch" + strLen: "Textlänge" + _strLen: + arg1: "Text" + strPick: "Text extrahieren" + _strPick: + arg1: "Text" + arg2: "Textposition" + strReplace: "Textersetzung" + _strReplace: + arg1: "Text" + arg2: "Zu ersetzender Text" + arg3: "Ersetzen mit" + strReverse: "Text umkehren" + _strReverse: + arg1: "Text" + join: "Text zusammenfügen" + _join: + arg1: "Liste" + arg2: "Trennzeichen" + add: "Addieren" + _add: + arg1: "A" + arg2: "B" + subtract: "Subtrahieren" + _subtract: + arg1: "A" + arg2: "B" + multiply: "Multiplizieren" + _multiply: + arg1: "A" + arg2: "B" + divide: "Teilen" + _divide: + arg1: "A" + arg2: "B" + mod: "Rest" + _mod: + arg1: "A" + arg2: "B" + round: "Rundung von Dezimalstellen" + _round: + arg1: "Nummer" + eq: "A und B sind gleich" + _eq: + arg1: "A" + arg2: "B" + notEq: "A und B sind nicht gleich" + _notEq: + arg1: "A" + arg2: "B" + and: "A UND B" + _and: + arg1: "A" + arg2: "B" + or: "A ODER B" + _or: + arg1: "A" + arg2: "B" + lt: "< A ist kleiner als B" + _lt: + arg1: "A" + arg2: "B" + gt: "> A ist größer als B" + _gt: + arg1: "A" + arg2: "B" + ltEq: "<= A ist kleiner als oder gleich B" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: ">= A ist größer als oder gleich B" + _gtEq: + arg1: "A" + arg2: "B" + if: "Kondition" + _if: + arg1: "Falls" + arg2: "Wenn wahr" + arg3: "Sonst" + not: "NICHT" + _not: + arg1: "NICHT" + random: "Zufällig" + _random: + arg1: "Warscheinlichkeit" + rannum: "Zufallsnummer" + _rannum: + arg1: "Minimum" + arg2: "Maximum" + randomPick: "Zufallswahl aus Liste" + _randomPick: + arg1: "Liste" + dailyRandom: "Zufällig (Pro Nutzer jeden Tag verschieden)" + _dailyRandom: + arg1: "Warscheinlichkeit" + dailyRannum: "Zufallsnummer (Pro Nutzer jeden Tag verschieden)" + _dailyRannum: + arg1: "Minimum" + arg2: "Maximum" + dailyRandomPick: "Zufallsauswahl aus einer Liste (Pro Nutzer jeden Tag verschieden)" + _dailyRandomPick: + arg1: "Liste" + seedRandom: "Zufällig (mit Startwert / Seed)" + _seedRandom: + arg1: "Startwert / Seed" + arg2: "Warscheinlichkeit" + seedRannum: "Zufallsnummer (mit Startwert / Seed)" + _seedRannum: + arg1: "Startwert / Seed" + arg2: "Minimum" + arg3: "Maximum" + seedRandomPick: "Zufallsauswahl aus Liste (mit Startwert / Seed)" + _seedRandomPick: + arg1: "Startwert / Seed" + arg2: "Liste" + DRPWPM: "Zufallsauswahl aus gewichteter Liste (Pro Nutzer jeden Tag verschieden)" + _DRPWPM: + arg1: "Textliste" + pick: "Aus einer Liste wählen" + _pick: + arg1: "Liste" + arg2: "Position" + listLen: "Listenlänge abrufen" + _listLen: + arg1: "Liste" + number: "Nummer" + stringToNumber: "Text zu Nummer" + _stringToNumber: + arg1: "Text" + numberToString: "Nummer zu Text" + _numberToString: + arg1: "Nummer" + splitStrByLine: "Text nach Zeilenumbrüchen aufteilen" + _splitStrByLine: + arg1: "Text" + ref: "Variable" + aiScriptVar: "AiScript Variable" + fn: "Funktion" + _fn: + slots: "Slots" + slots-info: "Trenne jeden Slot mit einem Zeilenumbruch" + arg1: "Ausgabe" + for: "for-Schleife" + _for: + arg1: "Anzahl der Schleifendurchläufe" + arg2: "Aktion" + typeError: "Slot {slot} akzeptiert Werte vom Typ „{expect}“, aber es wurde ein + „{actual}“ Wert angegeben!" + thereIsEmptySlot: "Slot {slot} ist leer!" + types: + string: "Text" + number: "Nummer" + boolean: "Wahrheitswert" + array: "Liste" + stringArray: "Textliste" + emptySlot: "Leerer Slot" + enviromentVariables: "Umgebungsvariable" + pageVariables: "Seitenelemente" + argVariables: "Eingabeslots" +_relayStatus: + requesting: "Ausstehend" + accepted: "Akzeptiert" + rejected: "Abgelehnt" +_notification: + fileUploaded: "Datei erfolgreich hochgeladen" + youGotMention: "{name} hat dich erwähnt" + youGotReply: "{name} hat dir geantwortet" + youGotQuote: "{name} hat dich zitiert" + youRenoted: "Renote deines Beitrages von {name}" + youGotPoll: "{name} hat in deiner Umfrage abgestimmt" + youGotMessagingMessageFromUser: "{name} hat dir eine Chatnachricht gesendet" + youGotMessagingMessageFromGroup: "In die Gruppe {name} wurde eine Chatnachricht + gesendet" + youWereFollowed: "folgt dir nun" + youReceivedFollowRequest: "Du hast eine Follow-Anfrage erhalten" + yourFollowRequestAccepted: "Deine Follow-Anfrage wurde akzeptiert" + youWereInvitedToGroup: "{userName} hat dich in eine Gruppe eingeladen" + pollEnded: "Umfrageergebnisse sind verfügbar" + emptyPushNotificationMessage: "Push-Benachrichtigungen wurden aktualisiert" + _types: + all: "Alle" + follow: "Neue Follower" + mention: "Erwähnungen" + reply: "Antworten" + renote: "Renotes" + quote: "Zitationen" + reaction: "Reaktionen" + pollVote: "Antworten auf Umfragen" + pollEnded: "Ende von Umfragen" + receiveFollowRequest: "Erhaltene Follow-Anfragen" + followRequestAccepted: "Akzeptierte Follow-Anfragen" + groupInvited: "Erhaltene Gruppeneinladungen" + app: "Benachrichtigungen von Apps" + _actions: + followBack: "folgt dir nun auch" + reply: "Antworten" + renote: "Renote" + voted: haben bei deiner Umfrage abgestimmt + reacted: hat auf deinen Beitrag reagiert + renoted: hat Ihren Beitrag geteilt +_deck: + alwaysShowMainColumn: "Hauptspalte immer zeigen" + columnAlign: "Spaltenausrichtung" + addColumn: "Spalte hinzufügen" + configureColumn: "Spalteneinstellungen" + swapLeft: "Mit linker Spalte tauschen" + swapRight: "Mit rechter Spalte tauschen" + swapUp: "Mit oberer Spalte tauschen" + swapDown: "Mit unterer Spalte tauschen" + stackLeft: "Auf linke Spalte stapeln" + popRight: "Nach rechts vom Stapel nehmen" + profile: "Arbeitsbereich" + newProfile: "Neuer Arbeitsbereich" + deleteProfile: "Arbeitsbereich löschen" + introduction: "Erstelle eine auf dich zugeschneiderte Benutzeroberfläche durch das + Aneinanderreihen von Spalten!" + introduction2: "Klicke auf das + rechts um wann immer du möchtest neue Spalten hinzuzufügen." + widgetsIntroduction: "Drücke bitte \"Widgets bearbeiten\" im Spaltenmenü und füge + ein Widget hinzu." + _columns: + main: "Hauptspalte" + widgets: "Widgets" + notifications: "Benachrichtigungen" + tl: "Timeline" + antenna: "Antenne" + list: "Listen" + mentions: "Erwähnungen" + direct: "Direktnachrichten" + channel: Kanal + renameProfile: Arbeitsbereich umbenennen + nameAlreadyExists: Der Name für den Arbeitsbereich ist bereits vorhanden. +enableRecommendedTimeline: '"Favoriten"-Timeline einschalten' +secureMode: Sicherer Modus (Autorisierter Abruf) +instanceSecurity: Server-Sicherheit +manageGroups: Gruppen verwalten +noThankYou: Nein, danke +privateMode: Privater Modus +enableEmojiReactions: Emoji-Reaktionen aktivieren +flagSpeakAsCat: Wie eine Katze sprechen +showEmojisInReactionNotifications: Emojis in Reaktionsbenachrichtigungen anzeigen +userSaysSomethingReason: '{name} sagte {reason}' +hiddenTagsDescription: 'Geben sie hier die Schlagworte (ohne #hashtag) an, die vom + "Trending and Explore" ausgeschlossen werden sollen. Versteckte Schlagworte sind + immer noch über andere Wege auffindbar.' +addInstance: Server hinzufügen +flagSpeakAsCatDescription: Deine Beiträge werden im Katzenmodus nyanisiert +hiddenTags: Versteckte Hashtags +antennaInstancesDescription: Geben sie einen Server-Namen pro Zeile ein +secureModeInfo: Bei Anfragen an andere Server nicht ohne Nachweis zurücksenden. +renoteMute: Boosts stummschalten +renoteUnmute: Stummschaltung von Boosts aufheben +noInstances: Keine Server gefunden +privateModeInfo: Wenn diese Option aktiviert ist, können nur als vertrauenswürdig + eingestufte Server mit diesem Server kommunizieren. Alle Beiträge werden für die + Öffentlichkeit verborgen. +allowedInstances: Vertrauenswürdige Server +selectInstance: Wähle einen Server aus +silencedInstancesDescription: Liste die Hostnamen der Server auf, die du stummschalten + möchtest. Nutzerkonten in den aufgelisteten Servern werden als "Stumm" behandelt, + können nur Follow-Anfragen stellen und können keine lokalen Nutzerkonten erwähnen, + wenn sie nicht gefolgt werden. Dies wirkt sich nicht auf die blockierten Server + aus. +editNote: Beitrag bearbeiten +edited: 'Bearbeitet um {date} {time}' +silenceThisInstance: Diesen Server stummschalten +silencedInstances: Stummgeschaltete Server +silenced: Stummgeschaltet +deleted: Gelöscht +breakFollowConfirm: Sind sie sicher, dass sie eine(n) Follower entfernen möchten? +unsubscribePushNotification: Push-Benachrichtigungen deaktivieren +pushNotificationAlreadySubscribed: Push-Benachrichtigungen sind bereits aktiviert +pushNotificationNotSupported: Ihr Browser oder der Server unterstützt keine Push-Benachrichtigungen +pushNotification: Push-Benachrichtigungen +subscribePushNotification: Push-Benachrichtigungen aktivieren +showLocalPosts: 'Zeige lokale Beiträge in:' +homeTimeline: Home-Timeline +cannotUploadBecauseExceedsFileSizeLimit: Die Datei konnte nicht hochgeladen werden, + da sie die maximal zulässige Größe überschreitet. +moveFromLabel: 'Nutzerkonto von dem Sie umziehen:' +moveAccount: Nutzerkonto umziehen! +defaultReaction: Standard-Emoji-Reaktion für ausgehende und eingehende Beiträge +moveTo: Umzug des Nutzerkontos zu einem neuen Nutzerkonto +moveToLabel: 'Nutzerkonto zu dem sie umziehen:' +moveAccountDescription: 'Dieser Vorgang kann nicht rückgängig gemacht werden! Stellen + sie vor dem Umzug dieses Nutzerkontos sicher, dass Sie einen Namen für Ihr neues + Nutzerkonto eingerichtet haben. Bitte geben sie die Bezeichnung des neuen Nutzerkontos + wie folgt ein: @name@server.xyz' +findOtherInstance: Einen anderen Server finden +sendPushNotificationReadMessage: Löschung der Push-Benachrichtigungen sobald die entsprechenden + Benachrichtigungen oder Beiträge gelesen wurden. +signupsDisabled: Derzeit sind keine Anmeldungen auf diesem Server möglich! Anmeldungen + auf anderen Servern sind jedoch möglich! Wenn Sie einen Einladungscode für diesen + Server haben, geben Sie ihn bitte unten ein. +swipeOnDesktop: Am Desktop PC das Wischen wie bei mobilen Geräten zulassen +enterSendsMessage: Drücken sie zum Senden des Beitrages die Eingabetaste (Strg-Taste + ausgeschaltet) +showUpdates: Zeigt ein Popup-Fenster an, wenn Iceshrimp aktualisiert wird. +socialTimeline: Social-Timeline +moveFrom: Bisheriges Nutzerkonto zu diesem Nutzerkonto umziehen +_messaging: + groups: Gruppen + dms: Privat +recommendedInstances: Empfohlene Server +logoImageUrl: URL des Logo-Bildes +userSaysSomethingReasonReply: '{name} hat auf einen Beitrag geantwortet der {reason} + beinhaltet' +userSaysSomethingReasonRenote: '{name} hat einen Beitrag geteilt der {reason} beinhaltet' +userSaysSomethingReasonQuote: '{name} hat einen Beitrag zitiert der {reason} beinhaltet' +seperateRenoteQuote: Getrennte Boost- und Zitat-Schaltflächen +showAds: Anzeigen anzeigen +splash: Begrüßungsbildschirm +customSplashIconsDescription: URLs für benutzerdefinierte Splash-Screen-Symbole, die + durch Zeilenumbrüche getrennt sind und nach dem Zufallsprinzip jedes Mal angezeigt + werden, wenn ein Benutzer die Seite lädt/neu lädt. Bitte stelle sicher, dass die + Bilder unter einer statischen URL stehen, vorzugsweise alle in der Größe 192x192. +sendPushNotificationReadMessageCaption: Eine Benachrichtigung mit dem Text "{emptyPushNotificationMessage}" + wird für kurze Zeit angezeigt. Dies kann ggf. den Akkuverbrauch Ihres Geräts erhöhen. +customSplashIcons: Benutzerdefinierte Begrüßungsbildschirmsymbole (URLs) +adminCustomCssWarn: Diese Einstellung sollte nur verwendet werden, wenn Sie wissen, + was sie tut. Die Eingabe falscher Werte kann dazu führen, dass ALLE Clients nicht + mehr normal funktionieren. Bitte stellen Sie sicher, dass Ihr CSS ordnungsgemäß + funktioniert, indem Sie es in Ihren Benutzereinstellungen testen. +customMOTD: Benutzerdefinierte Meldung des Tages (Begrüßungsbildschirmmeldungen) +allowedInstancesDescription: Hosts von Servern, die zur Verbindung auf die Liste vertrauenswürdiger + Server gesetzt werden sollen, werden jeweils durch eine neue Zeile getrennt eingegeben + (gilt nur im privaten Modus). +migration: Migration +updateAvailable: Es könnte eine Aktualisierung verfügbar sein! +showAdminUpdates: Anzeigen, dass eine neue Iceshrimp-Version verfügbar ist (nur Administrator) +customMOTDDescription: Benutzerdefinierte Meldungen für die Meldung des Tages (Begrüßungsbildschirm), + die durch Zeilenumbrüche getrennt sind und nach dem Zufallsprinzip jedes Mal angezeigt + werden, wenn ein Benutzer die Seite (neu) lädt. +recommendedInstancesDescription: Empfohlene Server, die durch Zeilenumbrüche getrennt + sind, werden in der "Favoriten"-Timeline angezeigt. Fügen Sie NICHT "https://" hinzu, + sondern NUR die Domain. +sendModMail: Moderationshinweis senden +moveFromDescription: 'Dadurch wird ein Alias Ihres alten Nutzerkontos festgelegt, + sodass Sie von ihrem bisherigen Konto zu diesem Nutzerkonto wechseln können. Tun + Sie dies, BEVOR Sie von Ihrem bisherigen Nutzerkonto hierhin wechseln. Bitte geben + Sie den Namen des Nutzerkontos wie folgt ein: @person@server.xyz' +preventAiLearning: KI gestütztes bot-scraping unterdrücken +preventAiLearningDescription: Fordern Sie KI-Sprachmodelle von Drittanbietern auf, + die von Ihnen hochgeladenen Inhalte, wie z. B. Beiträge und Bilder, nicht zu untersuchen. +license: Genehmigung +indexPosts: Gelistete Beiträge +migrationConfirm: "Sind Sie absolut sicher, dass Sie Ihr Nutzerkonto zu diesem {account} + umziehen möchten? Sobald Sie dies bestätigt haben, kann dies nicht mehr rückgängig + gemacht werden und Ihr Nutzerkonto kann nicht mehr von ihnen genutzt werden.\nStellen + Sie außerdem sicher, dass Sie dieses Nutzerkonto als das Konto festgelegt haben, + von dem Sie umziehen." +noteId: Beitrags-ID +customKaTeXMacro: Individuelle KaTeX Makros +enableCustomKaTeXMacro: Individuelle KaTeX-Makros aktivieren +replayTutorial: Wiederhole die Benutzeranleitung +apps: Apps +caption: Automatische Untertitelung +pwa: PWA installieren +cw: Inhaltswarnung +older: älter +newer: neuer +accessibility: Erreichbarkeit +jumpToPrevious: Zum Vorherigen springen +silencedWarning: Diese Meldung wird angezeigt, weil diese Nutzer von Servern stammen, + die Ihr Administrator abgeschaltet hat, so dass es sich möglicherweise um Spam handelt. +noGraze: Bitte deaktivieren Sie die Browsererweiterung "Graze for Mastodon", da sie + die Funktion von Iceshrimp stört. +indexFrom: Indexieren ab Beitragskennung aufwärts +indexNotice: Wird jetzt indexiert. Dies wird wahrscheinlich eine Weile dauern, bitte + starten Sie Ihren Server für mindestens eine Stunde nicht neu. +customKaTeXMacroDescription: "Richten Sie Makros ein, um mathematische Ausdrücke einfach + zu schreiben! Die Notation entspricht den LaTeX-Befehlsdefinitionen und wird als\n + \\newcommand{\\name}{content} or \\newcommand{\\name}[number of arguments]{content}\n + geschrieben.\nZum Beispiel wird\n\\newcommand{\\add}[2]{#1 + #2} \\add{3}{foo} um + 3 + foo erweitert.\nDie geschweiften Klammern, die den Makronamen umgeben, können + in runde oder eckige Klammern geändert werden. Dies hat Auswirkungen auf die Klammern, + die für die Argumente verwendet werden. Pro Zeile kann ein (und nur ein) Makro definiert + werden, und Sie können die Zeile nicht mitten in der Definition umbrechen. Ungültige + Zeilen werden einfach ignoriert. Es werden nur einfache Funktionen zur Substitution + von Zeichenketten unterstützt; erweiterte Syntax, wie z. B. bedingte Verzweigungen, + können hier nicht verwendet werden." +expandOnNoteClickDesc: Wenn deaktiviert, können Sie Beiträge trotzdem über das Rechtsklickmenü + oder durch Anklicken des Zeitstempels öffnen. +selectChannel: Wählen Sie einen Kanal aus +expandOnNoteClick: Beitrag bei Klick öffnen +image: Bild +video: Video +audio: Audio +indexFromDescription: Leer lassen, um jeden Beitrag zu indexieren +_filters: + fromUser: Von Benutzer + notesAfter: Beiträge nach + withFile: Mit Datei + fromDomain: Von Domain + notesBefore: Beiträge vor + followingOnly: Von Benutzern denen ich folge + followersOnly: Von Benutzern die mir folgen + _dialog: + learnMore: Filtersyntax ansehen + userDomain: Nach Autor, erwähnten Nutzern, antwortendem Nutzer oder Instanzdomain + filtern + info1: Text in eckigen klammern zeigen optionale Filter-Parameter an. Parameter-Optionen + sind mit einem |-Symbol getrennt. + title: Suchfilter-Syntax + wordFilters: Nach Post-Text filtern + inFilters: Nach Lesezeichen oder Favoritenstatus filtern + miscFilters: Nach Folge-Beziehung oder Post-Art filtern + postDate: Nach Post-Datum filtern + exclusivity: 'Beachte, dass der before: filter exklusiv, der after: filter aber + inklusiv ist.' + word: wort + phrase: wortwörtliche Phrase die (irgendwelche) Zeichen enthält + attachmentType: Nach Anhang-Typ filtern + matchOptions: Groß- und Kleinschreibung beachten und/oder ganze Wörter erkennen + info: Nomenklatur + info2: Ein Bindestrich in eckigen Klammern zeigt an, dass der Filter mit einem + Minus-Symbol invertiert werden kann. + infoEnd: Filter-Aliase + infoEnd1: Für einfachere Bedienung haben manche Filter Aliase, welche hier aufgelistet + sind. + repliesOnly: Nur Antworten + replyTo: Antwort an + mentioning: Erwähnt + inFavorites: Favorisiert + inBookmarks: Lesezeichen + excludeReplies: Antworten ausschließen + excludeRenotes: Boosts ausschließen + caseSensitive: Groß- und Kleinschreibung beachten + matchWords: Ganze Wörter durchsuchen +isBot: Dieses Konto ist ein Bot +isModerator: Moderator +isAdmin: Administrator +_dialog: + charactersExceeded: 'Maximale Anzahl an Zeichen aufgebraucht! Limit: {current} / + {max}' + charactersBelow: Nicht genug Zeichen! Du hast aktuell {current} von {min} Zeichen +searchPlaceholder: Das Fediverse durchsuchen +antennasDesc: "Antennen zeigen neue Posts an, die deinen definierten Kriterien entsprechen!\n + Sie können von der Timeline-Seite aufgerufen werden." +isPatron: Iceshrimp Patron +removeReaction: Entferne deine Reaktion +listsDesc: Listen lassen dich Timelines mit bestimmten Nutzer:innen erstellen. Sie + können von der Timeline-Seite erreicht werden. +clipsDesc: Clips sind wie teilbare, kategorisierte Lesezeichen. Du kannst Clips vom + Menü individueller Posts aus erstellen. +channelFederationWarn: Kanäle föderieren noch nicht zu anderen Servern +reactionPickerSkinTone: Bevorzugte Emoji-Hautfarbe +swipeOnMobile: Wischen zwischen den Seiten erlauben +enableServerMachineStats: Server-Hardware-Statistiken aktivieren +showPopup: Nutzer mit Pop-up benachrichtigen +youHaveUnreadAnnouncements: Du hast ungelesene Bekanntmachungen +donationLink: Link zur Spendenseite +neverShow: Nicht mehr anzeigen +remindMeLater: Vielleicht später +removeQuote: Zitat entfernen +removeRecipient: Empfänger entfernen +removeMember: Mitglied entfernen +openInMainColumn: In Hauptspalte öffnen +_feeds: + atom: Atom + jsonFeed: JSON feed + rss: RSS + copyFeed: Feed kopieren +expandAllCws: Inhaltswarnungen aller Antworten anzeigen +collapseAllCws: Inhaltswarnungen aller Antworten ausblenden +xl: Sehr Groß +alt: Bildbeschreibung +isLocked: Dieses Konto hat Folge-Anfragen aktiviert +enableIdenticonGeneration: Identicon-Generierung aktivieren +verifiedLink: Bestätigter Link +cannotChangeScopeWhenEditing: Du kannst die Sichtbarkeit eines Posts nach dem Erstellen + nicht ändern +searchNotLoggedIn_2: Du darfst allerdings nach Hashtags und Benutzern suchen. +cwStyle: Aussehen von Inhaltswarnungen +_cwStyle: + modern: Modern + classic: Klassisch (wie Misskey/Foundkey) + alternative: Alternativ (wie Firefish) +alwaysExpandCws: Posts mit Inhaltswarnungen immer ausklappen +hideFromHome: Von der Home-Timeline verbergen +antennaTimelineHint: Antennen zeigen übereinstimmende Posts in der Reihenfolge an, + in der sie erhalten werden, was nicht zwingend chronologisch ist. +searchNotLoggedIn_1: Du musst zum benutzen der Volltextsuche angemeldet sein. +showWithSparkles: Mit funkel-Animation anzeigen +searchEmptyQuery: Gib einen Suchbegriff ein, um loszulegen. +_wellness: + name: Wohlbefinden diff --git a/locales/el-GR.yml b/locales/el-GR.yml new file mode 100644 index 0000000..f3d94b7 --- /dev/null +++ b/locales/el-GR.yml @@ -0,0 +1,819 @@ +_lang_: "Ελληνικά" +monthAndDay: "{day}/{month}" +search: "Αναζήτηση" +notifications: "Ειδοποιήσεις" +username: "Όνομα μέλους" +password: "Κωδικός πρόσβασης" +forgotPassword: "Ξέχασα τον κωδικό πρόσβασης" +fetchingAsApObject: "Άντληση από το Fediverse" +ok: "Εντάξει" +gotIt: "Τό'πιασα!" +cancel: "Ακύρωση" +enterUsername: "Εισαγωγή ονόματος μέλους" +renotedBy: "Προωθήθηκε από {user}" +noNotes: "Δεν υπάρχουν δημοσιεύσεις" +noNotifications: "Δεν υπάρχουν ειδοποιήσεις" +settings: "Ρυθμίσεις" +basicSettings: "Βασικές Ρυθμίσεις" +otherSettings: "Άλλες Ρυθμίσεις" +openInWindow: "Άνοιγμα σε παράθυρο" +profile: "Προφίλ" +timeline: "Χρονολόγιο" +noAccountDescription: "Αυτό το μέλος δεν έχει γράψει βιογραφικό ακόμη." +login: "Σύνδεση" +loggingIn: "Συνδέεστε" +logout: "Αποσύνδεση" +signup: "Εγγραφή" +uploading: "Ανέβασμα..." +save: "Αποθήκευση" +users: "Μέλη" +addUser: "Προσθήκη μέλους" +favorite: "Προσθήκη στους σελιδοδείκτες" +favorites: "Σελιδοδείκτες" +unfavorite: "Αφαίρεση από τους σελιδοδείκτες" +favorited: "Προστέθηκε στους σελιδοδείκτες." +alreadyFavorited: "Έχει ήδη προστεθεί στους σελιδοδείκτες." +cantFavorite: "Αδυναμία προσθήκης στους σελιδοδείκτες." +pin: "Καρφίτσωμα στο προφίλ" +unpin: "Ξεκαρφίτσωμα από το προφίλ" +copyContent: "Αντιγραφή περιεχομένων" +copyLink: "Αντιγραφή συνδέσμου" +delete: "Διαγραφή" +deleteAndEdit: "Διαγραφή και επεξεργασία" +deleteAndEditConfirm: "Σίγουρα θέλετε να διαγράψετε αυτή τη δημοσίευση και να την\ + \ επεξεργαστείτε; Θα χάσετε όλες τις αντιδράσεις, προωθήσεις και απαντήσεις σε αυτήν." +addToList: "Προσθήκη στη λίστα" +sendMessage: "Αποστολή μηνύματος" +copyUsername: "Αντιγραφή ονόματος μέλους" +searchUser: "Αναζήτηση μέλους" +reply: "Απάντηση" +loadMore: "Φόρτωσε περισσότερα" +showMore: "Δείξε περισσότερα" +showLess: "Κλείσιμο" +youGotNewFollower: "σε ακολούθησε" +receiveFollowRequest: "Λάβατε αίτημα ακολούθησης" +followRequestAccepted: "Το αίτημα ακολούθησης έγινε δεκτό" +mention: "Επισήμανση" +mentions: "Επισημάνσεις" +directNotes: "Απευθείας μηνύματα" +importAndExport: "Εισαγωγή/Εξαγωγή Δεδομένων" +import: "Εισαγωγή" +export: "Εξαγωγή" +files: "Αρχεία" +download: "Κατέβασμα" +driveFileDeleteConfirm: "Θέλετε σίγουρα να διαγράψετε το αρχείο \"{name}\"; Οι δημοσιεύσεις\ + \ με αυτό το συνημμένο αρχείο επίσης θα διαγραφούν." +unfollowConfirm: "Θέλετε σίγουρα να σταματήσετε να ακολουθείτε το μέλος {name};" +exportRequested: "Ζητήσατε μία εξαγωγή. Αυτό μπορεί να πάρει κάποιον χρόνο. Θα προστεθεί\ + \ στον Αποθηκευτικό Χώρο σας μόλις ολοκληρωθεί." +importRequested: "Ζητήσατε μια εισαγωγή. Αυτό μπορεί να πάρει κάποιον χρόνο." +lists: "Λίστες" +noLists: "Δεν έχετε λίστες" +note: "Δημοσίευση" +notes: "Δημοσιεύσεις" +following: "Ακολουθεί" +followers: "Ακολουθούν" +followsYou: "Σε ακολουθεί" +createList: "Δημιουργία λίστας" +manageLists: "Διαχείριση λιστών" +error: "Σφάλμα" +somethingHappened: "Προέκυψε ένα σφάλμα" +retry: "Προσπάθεια ξανά" +pageLoadError: "Ένα σφάλμα προέκυψε φορτώνοντας τη σελίδα." +pageLoadErrorDescription: "Αυτό κανονικά προκαλείται από σφάλματα δικτύου ή από την\ + \ προσωρινή μνήμη του προγράμματος περιήγησης. Δοκιμάστε να σβήσετε την προσωρινή\ + \ μνήμη (cache) και να δοκιμάσετε ξανά μετά από λίγο." +serverIsDead: "Αυτός ο διακομιστής (server) δεν αποκρίνεται. Παρακαλώ περιμένετε λίγο\ + \ και δοκιμάστε ξανά." +youShouldUpgradeClient: "Για να δείτε αυτή τη σελίδα, παρακαλώ επαναφορτώστε για να\ + \ γίνει ενημέρωση." +enterListName: "Πληκτρολογήστε ένα όνομα για τη λίστα" +privacy: "Ιδιωτικότητα" +makeFollowManuallyApprove: "Τα αιτήματα ακολούθησης χρειάζονται έγκριση" +defaultNoteVisibility: "Προεπιλεγμένη ορατότητα" +follow: "Ακολουθήστε" +followRequest: "Ακολουθήστε" +followRequests: "Αιτήματα ακολούθησης" +unfollow: "Να μην ακολουθώ" +followRequestPending: "Το αίτημα ακολούθησης εκκρεμεί" +enterEmoji: "Εισάγετε ένα emoji" +renote: "Προώθηση" +unrenote: "Αναίρεση προώθησης" +renoted: "Προωθήθηκε." +cantRenote: "Αυτή η δημοσίευση δεν μπορεί να προωθηθεί." +cantReRenote: "Μία προώθηση δεν μπορεί να προωθηθεί." +quote: "Παράθεση" +pinnedNote: "Καρφιτσωμένη δημοσίευση" +pinned: "Καρφίτσωμα στο προφίλ" +you: "Εσύ" +clickToShow: "Κάντε κλικ για εμφάνιση" +add: "Προσθήκη" +reaction: "Αντιδράσεις" +reactionSetting: "Αντιδράσεις που θα εμφανίζονται στον επιλογέα" +reactionSettingDescription2: "Σύρετε για να αλλάξετε τη σειρά, κάντε κλικ για να διαγράψετε,\ + \ πατήστε \"+\" για να προσθέσετε." +rememberNoteVisibility: "Θυμήσου τις ρυθμίσεις ορατότητας για τις δημοσιεύσεις" +attachCancel: "Αφαίρεση επισυναπτόμενου" +enterFileName: "Πληκτρολογήστε όνομα αρχείου" +mute: "Σίγαση" +unmute: "Διακοπή σίγασης" +block: "Μπλοκάρισμα" +unblock: "Διακοπή μπλοκαρίσματος" +suspend: "Αποβολή" +unsuspend: "Διακοπή αποβολής" +blockConfirm: "Θέλετε σίγουρα να μπλοκάρετε αυτόν τον λογαριασμό;" +unblockConfirm: "Θέλετε σίγουρα να ξεμπλοκάρετε αυτόν τον λογαριασμό;" +suspendConfirm: "Θέλετε σίγουρα να αποβάλετε αυτόν τον λογαριασμό;" +unsuspendConfirm: "Θέλετε σίγουρα να άρετε την αποβολή αυτού του λογαριασμού;" +selectList: "Επιλέξτε μια λίστα" +selectAntenna: "Επιλέξτε μια αντένα" +selectWidget: "Επιλέξτε ένα πρόσθετο" +editWidgets: "Επεξεργασία πρόσθετων" +editWidgetsExit: "Ολοκληρώθηκε" +customEmojis: "Προσαρμοσμένα Emoji" +emojiName: "Όνομα emoji" +addEmoji: "Προσθήκη" +settingGuide: "Προτεινόμενες ρυθμίσεις" +flagAsBot: "Δήλωση αυτού του λογαριασμού ως bot" +flagAsCat: "Είσαι γατί; \U0001F63A" +flagShowTimelineReplies: "Εμφάνιση απαντήσεων στο χρονολόγιο" +addAccount: "Προσθήκη λογαριασμού" +general: "Γενικές" +wallpaper: "Ταπετσαρία" +setWallpaper: "Ορισμός ταπετσαρίας" +removeWallpaper: "Αφαίρεση ταπετσαρίας" +searchWith: "Αναζήτηση: {q}" +youHaveNoLists: "Δεν έχετε λίστες" +followConfirm: "Θέλετε σίγουρα να ακολουθήσετε τον λογαριασμό {name};" +host: "Φιλοξενεί (Host)" +selectUser: "Επιλέξτε ένα μέλος" +recipient: "Αποδέκτης-τρια(-ες)" +annotation: "Σχόλια" +federation: "Ομοσπονδία" +storageUsage: "Χρήση χώρου" +version: "Έκδοση" +metadata: "Μεταδεδομένα" +network: "Δίκτυο" +disk: "Δίσκος" +instanceInfo: "Πληροφορίες Instance" +statistics: "Στατιστικά" +clearQueue: "Εκκαθάριση ουράς" +clearQueueConfirmTitle: "Θέλετε να διαγράψετε την ουρά;" +clearCachedFiles: "Εκκαθάριση προσωρινής μνήμης (cache)" +done: "Ολοκληρώθηκε" +attachFile: "Επισύναψη αρχείων" +more: "Περισσότερα!" +noSuchUser: "Το μέλος δεν βρέθηκε" +announcements: "Ανακοινώσεις" +imageUrl: "URL εικόνας" +remove: "Διαγραφή" +removed: "Η διαγραφή ολοκληρώθηκε επιτυχώς" +saved: "Αποθηκεύτηκε" +messaging: "Συνομιλία" +upload: "Ανεβάστε" +fromDrive: "Από τον Αποθηκευτικό Χώρο" +fromUrl: "Από URL" +uploadFromUrl: "Ανέβασμα από URL" +explore: "Εξερεύνηση" +messageRead: "Διαβάστηκε" +startMessaging: "Ξεκινήστε μια νέα συνομιλία" +nUsersRead: "διαβάστηκε από {n}" +tos: "Όροι Χρήσης" +start: "Ας αρχίσουμε" +home: "Κεντρικό" +activity: "Δραστηριότητα" +images: "Εικόνες" +birthday: "Γενέθλια" +registeredDate: "Έγινε μέλος στις" +location: "Τοποθεσία" +theme: "Θέματα" +light: "Φωτεινό" +dark: "Σκοτεινό" +drive: "Αποθηκευτικός Χώρος" +fileName: "Όνομα αρχείου" +selectFile: "Επιλέξτε ένα αρχείο" +selectFiles: "Επιλέξτε αρχεία" +selectFolder: "Επιλέξτε φάκελο" +selectFolders: "Επιλέξτε φακέλους" +renameFile: "Μετονομασία αρχείου" +addFile: "Προσθέστε ένα αρχείο" +emptyDrive: "Ο Αποθηκευτικός Χώρος σας είναι άδειος" +copyUrl: "Αντιγραφή διεύθυνσης URL" +rename: "Μετονομασία" +avatar: "Άβαταρ" +banner: "Εξώφυλλο" +reload: "Ανανέωση" +doNothing: "Αγνόηση" +watch: "Παρακολούθηση" +unwatch: "Διακοπή παρακολούθησης" +accept: "Αποδοχή" +reject: "Απόρριψη" +normal: "Κανονικό" +instanceName: "Όνομα instance" +thisYear: "Έτος" +thisMonth: "Μήνας" +today: "Σήμερα" +dayX: "{day}" +pages: "Σελίδες" +connectService: "Σύνδεση" +disconnectService: "Αποσύνδεση" +registration: "Εγγραφή" +pinnedPages: "Καρφιτσωμένες Σελίδες" +pinnedNotes: "Καρφιτσωμένες δημοσιεύσεις" +antennas: "Αντένες" +manageAntennas: "Διαχείριση Αντενών" +name: "Όνομα" +antennaSource: "Πηγή Αντένας" +antennaKeywords: "Λέξεις-κλειδιά για παρακολούθηση" +antennaExcludeKeywords: "Λέξεις-κλειδιά για εξαίρεση" +notifyAntenna: "Ειδοποίηση για νέες δημοσιεύσεις" +withFileAntenna: "Μόνο δημοσιεύσεις με αρχεία" +caseSensitive: "Διάκριση Πεζών-Κεφαλαίων" +popularTags: "Δημοφιλείς ετικέτες" +userList: "Λίστες" +about: "Σχετικά με" +moderator: "Συντονιστής/στρια" +moderation: "Συντονισμός" +cacheClear: "Εκκαθάριση προσωρινής μνήμης (cache)" +markAsReadAllNotifications: "Σημειώστε όλες τις ειδοποιήσεις ως διαβασμένες" +group: "Ομάδα" +groups: "Ομάδες" +createGroup: "Δημιουργία ομάδας" +ownedGroups: "Οι ομάδες σας" +groupName: "Όνομα ομάδας" +members: "Μέλη" +transfer: "Μεταφορά" +messagingWithUser: "Προσωπική συνομιλία" +messagingWithGroup: "Ομαδική συνομιλία" +title: "Τίτλος" +text: "Κείμενο" +enable: "Ενεργοποίηση" +next: "Επόμενο" +noteOf: "Δημοσίευση από {user}" +inviteToGroup: "Πρόσκληση στην ομάδα" +quoteAttached: "Παράθεση" +signinRequired: "Παρακαλούμε δημιουργήστε λογαριασμό ή συνδεθείτε πριν συνεχίσετε" +category: "Κατηγορία" +tags: "Ετικέτες" +createAccount: "Δημιουργία λογαριασμού" +local: "Τοπικό" +remote: "Απομακρυσμένο" +total: "Σύνολο" +appearance: "Εμφάνιση" +accountSettings: "Ρυθμίσεις Λογαριασμού" +sounds: "Ήχοι" +sound: "Ήχοι" +listen: "Ακρόαση" +showInPage: "Εμφάνιση στη σελίδα" +volume: "Ένταση" +masterVolume: "Κεντρική ένταση" +details: "Λεπτομέρειες" +install: "Εγκαταστήστε" +uninstall: "Απεγκατάσταση" +manage: "Διαχείριση" +smtpHost: "Φιλοξενεί (Host)" +smtpUser: "Όνομα μέλους" +smtpPass: "Κωδικός" +notificationSetting: "Ρυθμίσεις ειδοποιήσεων" +notificationSettingDesc: "Επιλέξτε τους τύπους ειδοποιήσεων για προβολή." +switchUi: "Διάταξη" +clip: "Κλιπ" +driveFilesCount: "Αριθμός αρχείων Αποθηκευτικού Χώρου" +driveUsage: "Χρήση Αποθηκευτικού Χώρου" +noteFavoritesCount: "Αριθμός αγαπημένων σημειωμάτων" +clips: "Κλιπ" +clearCache: "Εκκαθάριση προσωρινής μνήμης" +emailNotification: "Ειδοποιήσεις μέσω mail" +inChannelSearch: "Αναζήτηση στο κανάλι" +info: "Πληροφορίες" +notRecommended: "Δεν προτείνεται" +switchAccount: "Αλλαγή λογαριασμού" +user: "Μέλη" +administration: "Διαχείριση" +switch: "Εναλλαγή" +gallery: "Γκαλερί" +global: "Παγκόσμιο" +searchResult: "Αποτελέσματα αναζήτησης" +learnMore: "Μάθετε περισσότερα" +controlPanel: "Πίνακας ελέγχου" +manageAccounts: "Διαχείριση Λογαριασμών" +searchByGoogle: "Αναζήτηση" +file: "Αρχεία" +recommended: "Προτεινόμενα" +cannotUploadBecauseNoFreeSpace: "Το ανέβασμα απέτυχε λόγω ανεπαρκούς Αποθηκευτικού\ + \ Χώρου." +_email: + _follow: + title: "Έχετε ένα νέο ακόλουθο" +_mfm: + mention: "Επισήμανση" + quote: "Παράθεση" + emoji: "Επιπλέον emoji" + search: "Αναζήτηση" +_channel: + featured: "Δημοφιλή" +_theme: + keys: + panel: "Πίνακας" + mention: "Επισήμανση" + renote: "Κοινοποίηση σημειώματος" +_sfx: + note: "Σημειώματα" + notification: "Ειδοποιήσεις" + chat: "Συνομιλία" + chatBg: "Συνομιλία (Παρασκήνιο)" + antenna: "Αντένες" + channel: "Ειδοποιήσεις καναλιών" +_ago: + future: "Μελλοντικό" + justNow: "Μόλις τώρα" + secondsAgo: "{n} δευτερόλεπτο(α) πριν" + minutesAgo: "{n} λεπτό(ά {n2} δευτερόλεπτο(α) πριν" + hoursAgo: "{n} ώρα(ες {n2} λεπτό(ά) πριν" + daysAgo: "{n} μέ {n2} ώρα(ες) πριν" + weeksAgo: "{n} εβδομάδ {n2} μέρα(ες) πριν" + monthsAgo: "{n} μήν {n2} εβδομάδα(ες) πριν" + yearsAgo: "{n} έτος(η {n2} μήνα(ες) πριν" +_permissions: + "write:drive": "Επεξεργαστείτε ή διαγράψτε τα αρχεία και τους φακέλους του Αποθηκευτικού\ + \ Χώρου σας" + "read:favorites": "Δείτε τη λίστα με τους σελιδοδείκτες σας" + "write:favorites": "Επεξεργαστείτε τη λίστα με τους σελιδοδείκτες σας" + "read:messaging": "Δείτε τις συνομιλίες σας" + "write:messaging": "Γράψτε ή διαγράψτε μηνύματα συνομιλίας" + "read:notifications": "Δείτε τις ειδοποιήσεις σας" + "write:notifications": "Διαχειριστείτε τις ειδοποιήσεις σας" + "read:pages": "Δείτε τις Σελίδες σας" + "write:pages": "Επεξεργαστείτε ή διαγράψτε τις σελίδες σας" + "write:gallery-likes": Επεξεργασία της λίστας των αγαπημένων σας δημοσιεύσεων γκαλερί + "read:gallery": Δείτε την γκαλερί σας + "write:gallery": Επεξεργασία της γκαλερί σας + "read:gallery-likes": Δείτε τη λίστα των αγαπημένων σας δημοσιεύσεων γκαλερί +_antennaSources: + all: "Όλα τα σημειώματα" + homeTimeline: "Σημειώματα από μέλη που ακολουθείτε" + users: "Σημειώματα από συγκεκριμένα μέλη" + userList: "Σημειώματα από καθορισμένη λίστα μελών" + userGroup: "Σημειώματα από μέλη καθορισμένης ομάδας" +_widgets: + profile: "Προφίλ" + instanceInfo: "Πληροφορίες του instance" + notifications: "Ειδοποιήσεις" + timeline: "Χρονολόγιο" + calendar: "Ημερολόγιο" + trends: "Δημοφιλή" + clock: "Ρολόι" + activity: "Δραστηριότητα" + photos: "Φωτογραφίες" + digitalClock: "Ψηφιακό ρολόι" + federation: "Ομοσπονδία" + postForm: "Φόρμα δημοσίευσης" + button: "Κουμπί" + onlineUsers: "Συνδεδεμένα μέλη" + _userList: + chooseList: "Επιλέξτε μία λίστα" +_cw: + show: "Δείτε περισσότερα" +_visibility: + home: "Κεντρικό" + homeDescription: "Δημοσίευση στο κεντρικό χρονολόγιο μόνο" + followers: "Ακολουθούν" +_profile: + name: "Όνομα" + username: "Όνομα μέλους" + changeAvatar: Αλλαγή άβαταρ +_exportOrImport: + allNotes: "Όλα τα σημειώματα" + followingList: "Ακολουθεί" + muteList: "Μέλη σε σίγαση" + blockingList: "Μπλοκαρισμένα μέλη" + userLists: "Λίστες" +_charts: + federation: "Ομοσπονδία" +_timelines: + home: "Κεντρικό" + local: "Τοπικό" + social: "Κοινωνικό" + global: "Παγκόσμιο" +_pages: + viewPage: "Δείτε τις Σελίδες σας" + blocks: + image: "Εικόνες" +_notification: + youGotMessagingMessageFromUser: "{name} σάς έστειλε ένα μήνυμα συνομιλίας" + youWereFollowed: "σε ακολούθησε" + _types: + follow: "Νέοι ακόλουθοι" + mention: "Επισήμανση" + renote: "Κοινοποίηση σημειώματος" + quote: "Παράθεση" + reaction: "Αντιδράσεις" + _actions: + reply: "Απάντηση" + renote: "Κοινοποίηση σημειώματος" +_deck: + widgetsIntroduction: "Παρακαλούμε επιλέξτε \"Επεξεργασία πρόσθετων\" στο μενού και\ + \ προσθέστε μαραφέτι." + _columns: + widgets: "Πρόσθετα" + notifications: "Ειδοποιήσεις" + tl: "Χρονολόγιο" + antenna: "Αντένες" + list: "Λίστα" + mentions: "Επισημάνσεις" +sensitive: Ευαίσθητο περιεχόμενο (NSFW) +createFolder: Δημιουργία φακέλου +uploadFromUrlDescription: Το URL του αρχείου που θέλετε να ανεβάσετε +emptyFolder: Αυτός ο φάκελος είναι άδειος +unableToDelete: Αδυναμία διαγραφής +recentlyUpdatedUsers: Πρόσφατα ενεργά μέλη +recentlyRegisteredUsers: Νέα μέλη +exploreUsersCount: Υπάρχουν {count} μέλη +help: Βοήθεια +inputNewFileName: Πληκτρολογήστε ένα νέο όνομα αρχείου +nothing: Δεν υπάρχει τίποτα να δείτε εδώ +newNoteRecived: Υπάρχουν νέες δημοσιεύσεις +passwordMatched: Ταιριάζει +unmarkAsSensitive: Αναίρεση επισήμανσης ως Ευαίσθητο Περιεχόμενο (NSFW) +blockedUsers: Μπλοκαρισμένα μέλη +noteDeleteConfirm: Θέλετε σίγουρα να διαγράψετε αυτή τη δημοσίευση; +preview: Προεπισκόπηση +noCustomEmojis: Δεν υπάρχουν emoji +tosUrl: URL Όρων Χρήσης +monthX: '{month}' +markAsReadAllTalkMessages: Σημειώστε όλα τα μηνύματα ως διαβασμένα +inputMessageHere: Γράψτε εδώ το μήνυμά σας +close: Κλείσιμο +newMessageExists: Υπάρχουν νέα μηνύματα +usernameInvalidFormat: Μπορείτε να χρησιμοποιήσετε κεφαλαία και μικρά γράμματα, αριθμούς, + και κάτω παύλες. +tooShort: Πολύ σύντομο +passwordNotMatched: Δεν ταιριάζει +existingAccount: Υπάρχων λογαριασμός +deleteAll: Διαγραφή όλων +chooseEmoji: Επιλέξτε ένα emoji +sort: Ταξινόμηση +descendingOrder: Φθίνουσα +deleteAllFiles: Διαγραφή όλων των αρχείων +userSuspended: Αυτό το μέλος έχει αποβληθεί. +menu: Μενού +divider: Χώρισμα +deletedNote: Διαγραμμένη δημοσίευση +useCw: Απόκρυψη περιεχομένου +description: Περιγραφή +width: Πλάτος +disableAll: Απενεργοποίηση όλων +notificationType: Τύπος ειδοποίησης +wordMute: Σίγαση λέξεων +userSaysSomething: '{name} είπε κάτι' +metrics: Μετρήσεις +overview: Γενική εικόνα +database: Βάση δεδομένων +channel: Κανάλια +other: Άλλα +abuseReports: Αναφορές +reportAbuse: Αναφορά +unclip: Ακύρωση κλιπ +public: Δημόσιο +renotedCount: Αριθμός προωθήσεων που ελήφθησαν +alwaysMarkSensitive: Επισήμανση ως ευαίσθητο περιεχόμενο (NSFW) ως προεπιλογή +markAllAsRead: Σημειώστε τα όλα ως διαβασμένα +_gallery: + like: Μου αρέσει + liked: Αγαπημένες δημοσιεύσεις + my: Η Γκαλερί μου + unlike: Δεν μου αρέσει +showOnRemote: Δείτε στο απομακρυσμένο instance +perDay: Ανά Ημέρα +software: Λογισμικό +cpuAndMemory: CPU και Μνήμη +noUsers: Δεν υπάρχουν μέλη +processing: Επεξεργασία +changePassword: Αλλαγή κωδικού +security: Ασφάλεια +featured: Προτεινόμενα +keepOriginalUploading: Διατήρηση πρωτότυπης εικόνας +manageGroups: Διαχείριση ομάδων +deleteFolder: Διαγραφή φακέλου +nsfw: Ευαίσθητο περιεχόμενο (NSFW) +nUsersMentioned: Έχει αναφερθεί από {n} μέλη +notFound: Δεν βρέθηκε +markAsReadAllUnreadNotes: Σημειώστε όλες τις δημοσιεύσεις ως διαβασμένες +invites: Προσκλήσεις +quoteQuestion: Να προστεθεί ως Παράθεση; +noMessagesYet: Δεν υπάρχουν μηνύματα ακόμη +onlyOneFileCanBeAttached: Μπορείτε να επισυνάψετε μόνο ένα αρχείο σε ένα μήνυμα +tooLong: Υπερβολικά μακροσκελές +or: Ή +language: Γλώσσα +groupInvited: Προσκληθήκατε σε μία ομάδα +ascendingOrder: Αύξουσα +visibility: Ορατότητα +invisibleNote: Αόρατη δημοσίευση +enableInfiniteScroll: Αυτόματη φόρτωση περισσοτέρων +poll: Ψηφοφορία +enablePlayer: Άνοιγμα προβολής βίντεο +large: Μεγάλο +medium: Μεσαίο +small: Μικρό +postToGallery: Δημιουργία νέας δημοσίευσης γκαλερί +reloadConfirm: Θα θέλατε να ανανεώσετε το χρονολόγιο; +enableAll: Ενεργοποίηση όλων +permission: Εξουσιοδοτήσεις +sample: Δείγμα +copy: Αντιγραφή +display: Προβολή +send: Αποστολή +behavior: Συμπεριφορά +useGlobalSetting: Χρήση παγκόσμιων ρυθμίσεων +abuseMarkAsResolved: Επισήμανση της αναφοράς ως επιλυμένης +openInNewTab: Άνοιγμα σε νέα καρτέλα +_sensitiveMediaDetection: + setSensitiveFlagAutomatically: Επισήμανση ως ευαίσθητο περιεχόμενο (NSFW) +defaultNavigationBehaviour: Προεπιλεγμένη συμπεριφορά περιήγησης +system: Σύστημα +createNew: Δημιουργία νέου +createNewClip: Δημιουργία νέου κλιπ +repliesCount: Αριθμός απεσταλμένων απαντήσεων +optional: Προαιρετικό +renotesCount: Αριθμός προωθήσεων σε δημοσιεύσεις άλλων +addItem: Προσθήκη αντικειμένου +disablePlayer: Κλείσιμο προβολής βίντεο +describeFile: Προσθήκη περιγραφής +enterFileDescription: Πληκτρολογήστε περιγραφή +author: Συντάκτης/τρια +setMultipleBySeparatingWithSpace: Διαχωρίστε πολλαπλές καταχωρήσεις με κενά. +random: Τυχαίο +accountInfo: Πληροφορίες Λογαριασμού +notesCount: Αριθμός δημοσιεύσεων +repliedCount: Αριθμός απαντήσεων που ελήφθησαν +flagAsCatDescription: Θα έχεις γατοαυτιά και θα μιλάς σαν γατί! +muteAndBlock: Σιγάσεις και Μπλοκαρίσματα +mutedUsers: Σιγασμένα μέλη +editProfile: Επεξεργασία προφίλ +pinLimitExceeded: Δεν μπορείτε να καρφιτσώσετε άλλες δημοσιεύσεις +currentPassword: Τρέχων κωδικός +newPassword: Νέος κωδικός +newPasswordRetype: Ξαναπληκτρολογήστε τον νέο κωδικό +notesAndReplies: Δημοσιεύσεις και απαντήσεις +popularUsers: Δημοφιλή μέλη +share: Κοινοποίηση +retype: Πληκτρολογήστε ξανά +invitations: Προσκλήσεις +available: Διαθέσιμο +unavailable: Μη διαθέσιμο +youHaveNoGroups: Δεν έχετε ομάδες +doing: Επεξεργασία... +yourAccountSuspendedTitle: Αυτός ο λογαριασμός έχει αποβληθεί +leaveConfirm: Υπάρχουν αλλαγές που δεν έχουν σωθεί. Θέλετε να τις απορρίψετε; +height: Ύψος +edit: Επεξεργασία +headlineIceshrimp: Μία ανοιχτού λογισμικού, αποκεντρωμένη πλατφόρμα κοινωνικής δικτύωσης + που θα είναι για πάντα ελεύθερη! 🚀 +introIceshrimp: Καλώς ήρθατε! Το Iceshrimp είναι μία ανοιχτού λογισμικού, αποκεντρωμένη + πλατφόρμα κοινωνικής δικτύωσης που θα είναι για πάντα ελεύθερη! 🚀 +markAsSensitive: Επισήμανση ως Ευαίσθητο Περιεχόμενο (NSFW) +autoAcceptFollowed: Αυτόματη έγκριση αιτημάτων ακολούθησης από λογαριασμούς που ακολουθείτε +loginFailed: Αποτυχία σύνδεσης +accountMoved: 'Έχει μεταφερθεί σε νέο λογαριασμό:' +perHour: Ανά Ώρα +remoteUserCaution: Οι πληροφορίες από απομακρυσμένους λογαριασμούς μπορεί να είναι + ατελείς. +folderName: Όνομα φακέλου +renameFolder: Μετονομασία φακέλου +recentUsed: Χρησιμοποιήθηκαν πρόσφατα +deleteAllFilesConfirm: Σίγουρα θέλετε να διαγράψετε όλα τα αρχεία; +removeAllFollowing: Διακοπή ακολούθησης όλων των ακολουθούμενων μελών +userSilenced: Αυτό το μέλος είναι υπό σιώπηση. +makeActive: Ενεργοποίηση +create: Δημιουργία +reportAbuseOf: Αναφορά {name} +cacheRemoteFilesDescription: Όταν αυτή η ρύθμιση είναι απενεργοποιημένη, τα απομακρυσμένα + αρχεία φορτώνονται απευθείας από το απομακρυσμένο instance. Η απενεργοποίηση θα + μειώσει τη χρήση του δίσκου σας, αλλά θα αυξήσει την κίνηση δεδομένων, καθώς δεν + θα δημιουργούνται σμικρύνσεις αρχείων (thumbnails). +registeredAt: Εγγράφηκε στις +latestStatus: Τελευταία κατάσταση +charts: Πίνακες +stopActivityDelivery: Σταμάτα να στέλνεις δραστηριότητες +operations: Λειτουργίες +monitor: Παρακολούθηση +jobQueue: Ουρά εργασιών +blockedInstances: Μπλοκαρισμένα Instances +blockedInstancesDescription: Παραθέστε τις διευθύνσεις (hostnames) των instances που + θέλετε να μπλοκάρετε. Τα παρακάτω instances δεν θα μπορούν πλέον να επικοινωνούν + με αυτό το instance. +intro: Η εγκατάσταση του Iceshrimp τελείωσε! Παρακαλώ δημιουργήστε ένα μέλος διαχειριστή/στρια. +noThankYou: Όχι, ευχαριστώ +addInstance: Προσθήκη instance +renoteMute: Σίγαση προωθήσεων +emojiUrl: Διεύθυνση emoji (URL) +cacheRemoteFiles: Προσωρινή αποθήκευση απομακρυσμένων αρχείων +flagSpeakAsCat: Να μιλάς σαν γατί +flagSpeakAsCatDescription: Οι δημοσιεύσεις σου θα nyaοποιούνται όταν είσαι γατί +selectInstance: Επιλέξτε ένα instance +latestRequestSentAt: Τελευταίο αίτημα στάλθηκε +hiddenTags: Κρυμμένες Ετικέτες (Hashtags) +noInstances: Δεν υπάρχουν instances +renoteUnmute: Διακοπή σίγασης προωθήσεων +flagAsBotDescription: Ενεργοποιήστε αυτή την επιλογή αν αυτός ο λογαριασμός ελέγχεται + από ένα πρόγραμμα. Αν ενεργοποιηθεί, θα λειτουργεί σαν σημάδι για τους προγραμματιστές, + ώστε να αποφύγουν ατέρμονη αλληλεπίδραση με άλλα bots και για να ρυθμίσει τα εσωτερικά + συστήματα του Iceshrimp ώστε να αντιμετωπίζουν αυτόν τον λογαριασμό ως bot. +flagShowTimelineRepliesDescription: Εμφάνιση απαντήσεων μελών σε δημοσιεύσεις άλλων + μελών στο χρονολόγιο. +latestRequestReceivedAt: Τελευταίο αίτημα ελήφθη +blockThisInstance: Μπλοκάρισμα αυτού του instance +clearQueueConfirmText: Τυχόν δημοσιεύσεις στην ουρά που δεν έχουν αποσταλεί δεν θα + ομοσπονδοποιηθούν. Συνήθως αυτή η λειτουργία δεν χρειάζεται. +clearCachedFilesConfirm: Σίγουρα θέλετε να διαγράψετε όλα τα προσωρινά αποθηκευμένα + απομακρυσμένα αρχεία; +default: Προεπιλεγμένο +defaultValueIs: 'Προεπιλεγμένο: {value}' +noJobs: Δεν υπάρχουν εργασίες (jobs) +federating: Ομοσπονδοποιείται +blocked: Μπλοκαρισμένο +suspended: Σε αποβολή +instanceFollowing: Ακολουθεί στο instance +instanceFollowers: Ακόλουθοι του instance +instanceUsers: Μέλη αυτού του instance +retypedNotMatch: Οι καταχωρήσεις δεν ταιριάζουν. +usernameOrUserId: Όνομα μέλους ή ταυτότητα μέλους (id) +removeAreYouSure: Θέλετε σίγουρα να αφαιρέσετε το "{x}"; +deleteAreYouSure: Θέλετε σίγουρα να διαγράψετε το "{x}"; +resetAreYouSure: Σίγουρα επανεκκίνηση; +uploadFromUrlMayTakeTime: Ίσως πάρει λίγο χρόνο μέχρι το ανέβασμα να ολοκληρωθεί. +noMoreHistory: Δεν υπάρχει περαιτέρω ιστορικό +agreeTo: Συμφωνώ στο {0} +yearsOld: '{age} ετών' +themeForDarkMode: Θέμα για τη Σκοτεινή Λειτουργία +syncDeviceDarkMode: Συγχρονισμός της Σκοτεινής Λειτουργίας με τις ρυθμίσεις της συσκευής + σας +inputNewDescription: Προσθέστε νέα περιγραφή +whenServerDisconnected: Όταν χάνεται η σύνδεση στον σέρβερ +disconnectedFromServer: Η σύνδεση στον σέρβερ έχει χαθεί +instanceDescription: Περιγραφή instance +maintainerEmail: Διεύθυνση email προγραμματιστή/στριας +yearX: '{year}' +enableGlobalTimeline: Ενεργοποίηση παγκόσμιου χρονολογίου +enableLocalTimeline: Ενεργοποίηση τοπικού χρονολογίου +enableRegistration: Ενεργοποίηση εγγραφής νέων μελών +invite: Πρόσκληση +disablingTimelinesInfo: Οι Διαχειρίστριες-ες και οι Συντονιστές-στριες θα έχουν πάντα + πρόσβαση σε όλα τα χρονολόγια, ακόμα κι αν δεν είναι ενεργοποιημένα. +inMb: Σε megabytes +iconUrl: Διεύθυνση URL εικονιδίου +bannerUrl: Διεύθυνση URL εικόνας Εξώφυλλου +pinnedUsers: Καρφιτσωμένα μέλη +hcaptchaSiteKey: Κλειδί του site +recaptcha: Προστασία reCAPTCHA +enableServiceworker: Ενεργοποίηση Ειδοποιήσεων Push για τον browser σας +recentlyDiscoveredUsers: Μέλη που ανακαλύφθηκαν πρόσφατα +twoStepAuthentication: Επαλήθευση δύο παραγόντων +securityKey: Κλειδί ασφάλειας +registerSecurityKey: Καταχωρήστε ένα κλειδί ασφάλειας +resetPassword: Επαναφορά κωδικού +newPasswordIs: Ο νέος κωδικός είναι "{password}" +uploadFolder: Προεπιλεγμένος φάκελος για ανέβασμα αρχείων +joinedGroups: Οι ομάδες που είστε μέλος +checking: Έλεγχος... +invitationCode: Κωδικός πρόσκλησης +normalPassword: Μέτριος κωδικός +weakPassword: Αδύναμος κωδικός +strongPassword: Δυνατός κωδικός +signinWith: Συνδεθείτε με {x} +tapSecurityKey: Βάλτε το κλειδί ασφάλειας +signinFailed: Αδυναμία σύνδεσης. Το όνομα μέλους ή ο κωδικός είναι λάθος. +aboutX: Σχετικά με {x} +useOsNativeEmojis: Χρήση των Emoji του λειτουργικού συστήματος +uiLanguage: Γλώσσα διεπαφής +disableDrawer: Να μη χρησιμοποιούνται μενού σε στιλ συρταριού +noHistory: Δεν υπάρχει διαθέσιμο ιστορικό +joinOrCreateGroup: Λάβετε πρόσκληση για μία ομάδα ή δημιουργήστε τη δική σας. +docSource: Πηγή αυτού του εγγράφου +regenerate: Επαναδημιουργία +fontSize: Μέγεθος γραμματοσειράς +noFollowRequests: Δεν έχετε αιτήματα ακολούθησης σε αναμονή +dashboard: Ταμπλό +clientSettings: Ρυθμίσεις διεπαφής +numberOfDays: Αριθμός ημερών +hideThisNote: Απόκρυψη αυτής της δημοσίευσης +showFeaturedNotesInTimeline: Εμφάνιση προτεινόμενων δημοσιεύσεων στα χρονολόγια +objectStorage: Αποθήκευση Object Storage +useObjectStorage: Χρήση object storage +objectStorageBucket: '' +showFixedPostForm: Εμφάνιση της φόρμας δημοσίευσης στο πάνω μέρος των χρονολογίων +none: Κανένα +unableToProcess: Η επιχείρηση ήταν αδύνατο να ολοκληρωθεί +installedApps: Εφαρμογές με εξουσιοδότηση +state: Κατάσταση +installedDate: Εξουσιοδοτήθηκε στις +lastUsedDate: Χρησιμοποιήθηκε τελευταία φορά στις +scratchpadDescription: Το σημειωματάριο παρέχει ένα περιβάλλον για πειραματισμό με + AiScript. Σε αυτό μπορείτε να γράψετε, να εκτελέσετε, και να δοκιμάσετε τα αποτελέσματα + της αλληλεπίδρασης του AiScript με το Iceshrimp. +scratchpad: Σημειωματάριο +output: Αποτέλεσμα +updateRemoteUser: Ανανέωση πληροφοριών απομακρυσμένου μέλους +disablePagesScript: Απενεργοποίηση του AiScript στις Σελίδες +removeAllFollowingDescription: Η εκτέλεση θα διακόψη την ακολούθηση όλων των μελών + από {host}. Παρακαλούμε εκτελέστε το αν το instance π.χ. δεν υπάρχει πια. +caption: Αυτόματη Περιγραφή +all: Όλα +subscribing: Εγγραφή σε συνδρομή +publishing: Δημοσιεύεται +notResponding: Δεν αποκρίνεται +keepOriginalUploadingDescription: Αποθηκεύει το πρωτότυπο αρχείο όπως είναι. Αν απενεργοποιηθεί, + μία έκδοση για προβολή στο ίντερνετ θα δημιουργηθεί κατά το ανέβασμα. +lookup: Αναζήτηση +lightThemes: Φωτεινά θέματα +darkThemes: Σκοτεινά θέματα +inputNewFolderName: Πληκτρολογήστε ένα νέο όνομα φακέλου +hasChildFilesOrFolders: Εφόσον αυτός ο φάκελος δεν είναι άδειος, δεν μπορεί να διαγραφεί. +integration: Ενσωματώσεις +enableRecommendedTimeline: Ενεργοποίηση χρονολογίου προτεινόμενων +driveCapacityPerLocalAccount: Μέγεθος Αποθηκευτικού Χώρου ανά τοπικό μέλος +driveCapacityPerRemoteAccount: Μέγεθος Αποθηκευτικού Χώρου ανά απομακρυσμένο μέλος +basicInfo: Βασικές πληροφορίες +pinnedClipId: Ταυτότητα (id) του κλιπ για καρφίτσωμα +hcaptcha: Προστασία hCaptcha +enableHcaptcha: Ενεργοποίηση hCaptcha +hcaptchaSecretKey: Μυστικό κλειδί +enableRecaptcha: Ενεργοποίηση reCAPTCHA +recaptchaSiteKey: Κλειδί του site +recaptchaSecretKey: Μυστικό κλειδί +antennaKeywordsDescription: Διαχωρίστε με κενά για συνθήκη ΚΑΙ ή με αλλαγή γραμμής + για συνθήκη Ή. +antennaUsersDescription: Παραθέστε ένα όνομα μέλους ανά γραμμή +antennaInstancesDescription: Παραθέστε ένα instance host ανά γραμμή +withReplies: Να περιλαμβάνονται οι απαντήσεις +withFiles: Να περιλαμβάνουν αρχεία +silence: Σιώπηση +silenceConfirm: Θέλετε σίγουρα να σιωπήσετε αυτό το μέλος; +unsilenceConfirm: Σίγουρα θέλετε να αναιρέσετε τη σιώπηση αυτού του μέλους; +securityKeyName: Όνομα κλειδιού +lastUsed: Τελευταία χρήση +unregister: Απεγγραφή +notFoundDescription: Δεν ήταν δυνατό να βρεθεί σελίδα που να ανταποκρίνεται σε αυτή + τη διεύθυνση URL. +signinHistory: Ιστορικό συνδέσεων +disableAnimatedMfm: Απενεργοποίηση του MFM με κίνηση +dayOverDayChanges: Αλλαγές την τελευταία ημέρα +promotion: Προμοταρισμένα +promote: Προμοτάρισμα +squareAvatars: Εμφάνιση τετραγωνισμένων άβαταρ +aboutIceshrimp: Σχετικά με το Iceshrimp +maintainerName: Προγραμματιστής/στρια +uploadFromUrlRequested: Το ανέβασμα ζητήθηκε +themeForLightMode: Θέμα για τη Φωτεινή Λειτουργία +circularReferenceFolder: Ο φάκελος του προορισμού είναι υποφάκελος του φακέλου που + θέλετε να μετακινήσετε. +backgroundImageUrl: Διεύθυνση URL εικόνας φόντου +pinnedUsersDescription: Παραθέστε τα ονόματα μελών που θα είναι καρφιτσωμένα στην + καρτέλα "Εξερεύνηση" χωρίζοντάς τα με αλλαγή γραμμής. +openImageInNewTab: Άνοιγμα εικόνων σε νέα καρτέλα +weekOverWeekChanges: Αλλαγές την τελευταία εβδομάδα +exploreFediverse: Εξερευνήστε το Fediverse +unsilence: Αναίρεση σιώπησης +administrator: Διαχειριστής/στρια +passwordLessLogin: Σύνδεση χωρίς κωδικό +reduceUiAnimation: Ελάττωση των κινούμενων εικόνων +serviceworkerInfo: Πρέπει να είναι ενεργοποιημένο για ειδοποιήσεις push. +expandTweet: Διεύρυνση τουιτ +themeEditor: Επεξεργασία θεμάτων +deck: Ντεκ +undeck: Έξοδος από το Ντεκ +useFullReactionPicker: Χρήση επιλογέα αντιδράσεων πλήρους μεγέθους +tokenRequested: Παροχή πρόσβασης στον λογαριασμό +emailServer: Σέρβερ email +enableEmail: Ενεργοποίηση του email distribution +emailAddress: Διεύθυνση email +emailConfigInfo: Χρησιμοποιείται για επιβεβαίωση του email σας κατά την εγγραφή ή + αν ξεχάσετε τον κωδικό σας +regenerateLoginToken: Επαναδημιουργία token σύνδεσης +fileIdOrUrl: Ταυτότητα αρχείου (ID) ή διεύθυνση URL +typingUsers: '{users} πληκτρολογεί' +yourAccountSuspendedDescription: Αυτός ο λογαριασμός έχει αποβληθεί λόγω μη συμμόρφωσης + με τους κανόνες του σέρβερ ή κάτι παρόμοιο. Επικοινωνήστε με τον διαχειριστή/στρια + αν θα θέλατε έναν πιο λεπτομερή λόγο. Παρακαλούμε μη δημιουργήσετε νέο λογαριασμό. +inboxUrl: Διεύθυνση URL των Εισερχομένων +generateAccessToken: Δημιουργία token πρόσβασης +emptyToDisableSmtpAuth: Αφήστε το όνομα μέλους και τον κωδικό άδεια για να απενεργοποιήσετε + την επαλήθευση SMTP +instanceMute: Σιγάσεις instance +userSaysSomethingReason: '{name} είπε {reason}' +logs: Αρχεία καταγραφής +abuseReported: Η αναφορά σας στάλθηκε. Ευχαριστούμε πολύ. +reporter: Έκανε την αναφορά +reporteeOrigin: Καταγωγή αναφερόμενου λογαριασμού +reporterOrigin: Καταγωγή λογαριασμού που έκανε την αναφορά +forwardReport: Προώθηση της αναφοράς στο απομακρυσμένο instance +openInSideView: Άνοιγμα σε προβολή παράθεσης +delayed: Με καθυστέρηση +useGlobalSettingDesc: Αν ενεργοποιηθεί, οι ρυθμίσεις ειδοποιήσεων του λογαριασμού + σας θα χρησιμοποιηθούν. Αν απενεργοποιηθεί, μπορούν να γίνουν ανεξάρτητες ρυθμίσεις. +fillAbuseReportDescription: Παρακαλούμε συμπληρώστε λεπτομέρειες σχετικά με αυτή την + αναφορά. Αν πρόκειται για συγκεκριμένη δημοσίευση, παρακαλούμε συμπεριλάβετε τη + διεύθυνση URL της δημοσίευσης. +forwardReportIsAnonymous: Αντί για τον λογαριασμό σας, μία ανώνυμη αναφορά από λογαριασμό + του συστήματος θα εμφανιστεί στο απομακρυσμένο instance. diff --git a/locales/en-US.yml b/locales/en-US.yml new file mode 100644 index 0000000..5283416 --- /dev/null +++ b/locales/en-US.yml @@ -0,0 +1,2228 @@ +_lang_: "English" +headlineFrozenFriendsYume: "An open source, decentralized social media platform that's free + forever! 🚀" +introFrozenFriendsYume: "Welcome! FrozenFriendsYume is an open source, decentralized social media + platform that's free forever! 🚀" +monthAndDay: "{month}/{day}" +search: "Search" +searchPlaceholder: "Search the Fediverse" +notifications: "Notifications" +username: "Username" +password: "Password" +forgotPassword: "Forgot password" +fetchingAsApObject: "Fetching from the Fediverse" +ok: "OK" +gotIt: "Got it!" +cancel: "Cancel" +noThankYou: "No thank you" +enterUsername: "Enter username" +renotedBy: "Boosted by {user}" +noNotes: "No posts" +noNotifications: "No notifications" +instance: "Server" +settings: "Settings" +basicSettings: "Basic Settings" +otherSettings: "Other Settings" +openInWindow: "Open in window" +profile: "Profile" +timeline: "Timeline" +noAccountDescription: "This user has not written their bio yet." +login: "Sign In" +loggingIn: "Signing In" +logout: "Sign Out" +signup: "Sign Up" +uploading: "Uploading…" +save: "Save" +users: "Users" +addUser: "Add a user" +addInstance: "Add a server" +favorite: "Add to bookmarks" +favorites: "Bookmarks" +calls: "Calls" +memoriet: "Memoriet" +reversi: "Reversi" +shogi: "Shogi" +videoService: "Video Service" +audioService: "Audio Service" +imageService: "Image Service" +karaokeService: "Karaoke" +lua4frozen: "Lua4Frozen" +yumeFortune: "Yume Fortune" +yumeFortuneToday: "Today's Yume Fortune" +yumeFortuneDraw: "Draw fortune" +yumeFortuneShuffle: "Draw again" +yumeFortuneBeforeTitle: "Not drawn yet" +yumeFortuneBeforeText: "Press the button to draw a small fortune for today." +yumeFortuneResultBright: "Spark Day" +yumeFortuneResultBrightText: "A small idea may travel further than expected today." +yumeFortuneResultBrightHint: "Try posting one idea that has been waiting in your drafts." +yumeFortuneResultCalm: "Tidy Day" +yumeFortuneResultCalmText: "Moving slowly and clearing one visible thing should improve the flow." +yumeFortuneResultCalmHint: "Notifications, follows, and lists are worth a quick pass." +yumeFortuneResultDeep: "Deep Dive Day" +yumeFortuneResultDeepText: "Digging into something you were curious about may start a good conversation." +yumeFortuneResultDeepHint: "Longer notes or clip cleanup should fit the mood." +yumeFortuneResultPlay: "Playful Day" +yumeFortuneResultPlayText: "A different emoji or phrase can soften the room today." +yumeFortuneResultPlayHint: "Try one reaction you do not usually use." +yumeFortuneResultFresh: "New Window Day" +yumeFortuneResultFreshText: "A page you have not opened lately may have a useful discovery." +yumeFortuneResultFreshHint: "Take a short walk through Explore, Gallery, or Channels." +unfavorite: "Remove from bookmarks" +favorited: "Added to bookmarks." +alreadyFavorited: "Already added to bookmarks." +cantFavorite: "Couldn't add to bookmarks." +pin: "Pin to profile" +unpin: "Unpin from profile" +copyContent: "Copy contents" +copyLink: "Copy link" +delete: "Delete" +deleted: "Deleted" +deleteAndEdit: "Delete and edit" +deleteAndEditConfirm: "Are you sure you want to delete this post and edit it? You + will lose all reactions, boosts and replies to it." +editNote: "Edit note" +edited: "Edited at {date} {time}" +addToList: "Add to list" +sendMessage: "Send a message" +copyUsername: "Copy username" +searchUser: "Search for a user" +reply: "Reply" +jumpToPrevious: "Jump to previous" +loadMore: "Load more" +showMore: "Show more" +newer: "newer" +older: "older" +expandAllCws: "Show content for all replies" +collapseAllCws: "Hide content for all replies" +showLess: "Show less" +youGotNewFollower: "followed you" +receiveFollowRequest: "Follow request received" +followRequestAccepted: "Follow request accepted" +mention: "Mention" +mentions: "Mentions" +directNotes: "Direct messages" +cw: "Content warning" +importAndExport: "Import/Export Data" +import: "Import" +export: "Export" +files: "Files" +download: "Download" +driveFileDeleteConfirm: "Are you sure you want to delete the file \"{name}\"? It will + be removed from all posts that contain it as an attachment." +unfollowConfirm: "Are you sure that you want to unfollow {name}?" +exportRequested: "You've requested an export. This may take a while. It will be added + to your Drive once completed." +importRequested: "You've requested an import. This may take a while." +lists: "Lists" +listsDesc: "Lists let you create timelines with specified users. They can be accessed + from the timelines page." +noLists: "You don't have any lists" +note: "Post" +notes: "Posts" +following: "Following" +followers: "Followers" +followsYou: "Follows you" +createList: "Create list" +manageLists: "Manage lists" +error: "Error" +somethingHappened: "An error has occurred" +retry: "Retry" +pageLoadError: "An error occurred loading the page." +pageLoadErrorDescription: "This is normally caused by network errors or the browser's + cache. Try clearing the cache or waiting a little while and reloading." +serverIsDead: "This server is not responding. Please wait for a while and try again." +youShouldUpgradeClient: "To view this page, please refresh to update your client." +enterListName: "Enter a name for the list" +privacy: "Privacy" +makeFollowManuallyApprove: "Follow requests require approval" +defaultNoteVisibility: "Default visibility" +follow: "Follow" +followRequest: "Follow Request" +followRequests: "Follow requests" +unfollow: "Unfollow" +followRequestPending: "Follow request pending" +enterEmoji: "Enter an emoji" +renote: "Boost" +unrenote: "Take back boost" +renoted: "Boosted." +cantRenote: "This post can't be boosted." +cantReRenote: "A boost can't be boosted." +quote: "Quote" +pinnedNote: "Pinned post" +pinned: "Pin to profile" +you: "You" +clickToShow: "Click to show" +sensitive: "Sensitive" +add: "Add" +reaction: "Reactions" +removeReaction: "Remove your reaction" +enableEmojiReactions: "Enable emoji reactions" +showEmojisInReactionNotifications: "Show emojis in reaction notifications" +reactionSetting: "Reactions to show in the reaction picker" +reactionSettingDescription2: "Drag to reorder, click to delete, press \"+\" to add." +rememberNoteVisibility: "Remember post visibility settings" +attachCancel: "Remove attachment" +markAsSensitive: "Mark as sensitive" +unmarkAsSensitive: "Unmark as sensitive" +enterFileName: "Enter filename" +mute: "Mute" +unmute: "Unmute" +renoteMute: "Mute boosts" +renoteUnmute: "Unmute boosts" +block: "Block" +unblock: "Unblock" +suspend: "Suspend" +unsuspend: "Unsuspend" +blockConfirm: "Are you sure that you want to block this account?" +unblockConfirm: "Are you sure that you want to unblock this account?" +suspendConfirm: "Are you sure that you want to suspend this account?" +unsuspendConfirm: "Are you sure that you want to unsuspend this account?" +selectList: "Select a list" +selectAntenna: "Select an antenna" +selectWidget: "Select a widget" +selectChannel: "Select a channel" +editWidgets: "Edit widgets" +editWidgetsExit: "Done" +customEmojis: "Custom Emoji" +emoji: "Emoji" +emojis: "Emoji" +emojiName: "Emoji name" +emojiUrl: "Emoji URL" +addEmoji: "Add" +settingGuide: "Recommended settings" +cacheRemoteFiles: "Cache remote files" +cacheRemoteFilesDescription: "When this setting is disabled, remote files are loaded + directly from the remote server. Disabling this will decrease storage usage, but + increase traffic, as thumbnails will not be generated." +flagAsBot: "Mark this account as a bot 🤖" +flagAsBotDescription: "Enable this option if this account is controlled by a program. + If enabled, it will act as a flag for other developers to prevent endless interaction + chains with other bots and adjust FrozenFriendsYume's internal systems to treat this account + as a bot." +flagAsCat: "Are you a cat? 😺" +flagAsCatDescription: "You'll get cat ears and speak like a cat!" +flagSpeakAsCat: "Speak as a cat" +flagSpeakAsCatDescription: "Your posts will get nyanified when in cat mode" +flagShowTimelineReplies: "Show replies in timeline" +flagShowTimelineRepliesDescription: "Shows replies of users to posts of other users + in the timeline if turned on." +autoAcceptFollowed: "Automatically approve follow requests from users you're following" +addAccount: "Add account" +loginFailed: "Failed to sign in" +showOnRemote: "Open original page" +general: "General" +accountMoved: "User has moved to a new account:" +wallpaper: "Wallpaper" +setWallpaper: "Set wallpaper" +removeWallpaper: "Remove wallpaper" +searchWith: "Search: {q}" +youHaveNoLists: "You don't have any lists" +followConfirm: "Are you sure that you want to follow {name}?" +proxyAccount: "Proxy Account" +proxyAccountDescription: "A proxy account is an account that acts as a remote follower + for users under certain conditions. For example, when a user adds a remote user + to the list, the remote user's activity will not be delivered to the server if no + local user is following that user, so the proxy account will follow instead." +host: "Host" +selectUser: "Select a user" +selectInstance: "Select an server" +recipient: "Recipient(s)" +annotation: "Comments" +federation: "Federation" +instances: "Servers" +registeredAt: "Registered at" +latestRequestSentAt: "Last request sent" +latestRequestReceivedAt: "Last request received" +latestStatus: "Latest status" +storageUsage: "Storage usage" +charts: "Charts" +perHour: "Per Hour" +perDay: "Per Day" +stopActivityDelivery: "Stop sending activities" +blockThisInstance: "Block this server" +silenceThisInstance: "Silence this server" +operations: "Operations" +software: "Software" +version: "Version" +metadata: "Metadata" +monitor: "Monitor" +jobQueue: "Job Queue" +cpuAndMemory: "CPU and Memory" +network: "Network" +disk: "Disk" +instanceInfo: "Server Information" +statistics: "Statistics" +clearQueue: "Clear queue" +clearQueueConfirmTitle: "Are you sure that you want to clear the queue?" +clearQueueConfirmText: "Any undelivered posts remaining in the queue will not be federated. + Usually this operation is not needed." +clearCachedFiles: "Clear cache" +clearCachedFilesConfirm: "Are you sure that you want to delete all cached remote files?" +blockedInstances: "Blocked Servers" +blockedInstancesDescription: "List the hostnames of the servers that you want to block. + Listed servers will no longer be able to communicate with this servers." +silencedInstances: "Silenced Servers" +silencedInstancesDescription: "List the hostnames of the servers that you want to + silence. Accounts in the listed servers are treated as \"Silenced\", can only make + follow requests, and cannot mention local accounts if not followed. This will not + affect the blocked servers." +hiddenTags: "Hidden Hashtags" +hiddenTagsDescription: "List the hashtags (without the #) of the hashtags you wish + to hide from trending and explore. Hidden hashtags are still discoverable via other + means." +muteAndBlock: "Mutes and Blocks" +mutedUsers: "Muted users" +blockedUsers: "Blocked users" +noUsers: "There are no users" +noInstances: "There are no servers" +editProfile: "Edit profile" +noteDeleteConfirm: "Are you sure you want to delete this post?" +pinLimitExceeded: "You cannot pin any more posts" +intro: "Installation of FrozenFriendsYume has been finished! Please create an admin user." +done: "Done" +processing: "Processing…" +preview: "Preview" +default: "Default" +defaultValueIs: "Default: {value}" +noCustomEmojis: "There are no emoji" +noJobs: "There are no jobs" +federating: "Federating" +blocked: "Blocked" +silenced: "Silenced" +suspended: "Suspended" +all: "All" +subscribing: "Subscribing" +publishing: "Publishing" +notResponding: "Not responding" +instanceFollowing: "Following on server" +instanceFollowers: "Followers of server" +instanceUsers: "Users of this server" +changePassword: "Change password" +security: "Security" +retypedNotMatch: "The inputs do not match." +currentPassword: "Current password" +newPassword: "New password" +newPasswordRetype: "Retype new password" +attachFile: "Attach files" +more: "More" +featured: "Featured" +usernameOrUserId: "Username or user id" +noSuchUser: "User not found" +lookup: "Lookup" +announcements: "Announcements" +imageUrl: "Image URL" +remove: "Delete" +removed: "Successfully deleted" +removeAreYouSure: "Are you sure you want to remove \"{x}\"?" +deleteAreYouSure: "Are you sure you want to delete \"{x}\"?" +resetAreYouSure: "Are you sure you want to reset?" +saved: "Saved" +messaging: "Chat" +upload: "Upload" +keepOriginalUploading: "Keep original image" +keepOriginalUploadingDescription: "Saves the originally uploaded image as-is. If turned + off, a version to display on the web will be generated on upload." +fromDrive: "From Drive" +fromUrl: "From URL" +uploadFromUrl: "Upload from a URL" +uploadFromUrlDescription: "URL of the file you want to upload" +uploadFromUrlRequested: "Upload requested" +uploadFromUrlMayTakeTime: "It may take some time until the upload is complete." +explore: "Explore" +messageRead: "Read" +noMoreHistory: "There is no further history" +startMessaging: "Start a new chat" +manageGroups: "Manage groups" +nUsersRead: "read by {n}" +agreeTo: "I agree to {0}" +tos: "Terms of Service" +start: "Begin" +home: "Home" +remoteUserCaution: "Information from remote users may be incomplete." +activity: "Activity" +images: "Images" +birthday: "Birthday" +yearsOld: "{age} years old" +registeredDate: "Joined on" +location: "Location" +theme: "Themes" +themeForLightMode: "Theme to use in Light Mode" +themeForDarkMode: "Theme to use in Dark Mode" +light: "Light" +dark: "Dark" +lightThemes: "Light themes" +darkThemes: "Dark themes" +syncDeviceDarkMode: "Sync Dark Mode with your device settings" +drive: "Drive" +fileName: "Filename" +selectFile: "Select a file" +selectFiles: "Select files" +selectFolder: "Select a folder" +selectFolders: "Select folders" +renameFile: "Rename file" +folderName: "Folder name" +createFolder: "Create a folder" +renameFolder: "Rename this folder" +deleteFolder: "Delete this folder" +addFile: "Add a file" +emptyDrive: "Your Drive is empty" +emptyFolder: "This folder is empty" +unableToDelete: "Unable to delete" +inputNewFileName: "Enter a new filename" +inputNewDescription: "Enter new caption" +inputNewFolderName: "Enter a new folder name" +circularReferenceFolder: "The destination folder is a subfolder of the folder you + wish to move." +hasChildFilesOrFolders: "Since this folder is not empty, it can not be deleted." +copyUrl: "Copy URL" +rename: "Rename" +avatar: "Avatar" +banner: "Banner" +nsfw: "Sensitive" +whenServerDisconnected: "When losing connection to the server" +disconnectedFromServer: "Connection to server has been lost" +reload: "Refresh" +doNothing: "Ignore" +reloadConfirm: "Would you like to refresh the timeline?" +watch: "Watch" +unwatch: "Stop watching" +accept: "Accept" +reject: "Reject" +normal: "Normal" +instanceName: "Server name" +instanceDescription: "Server description" +maintainerName: "Maintainer" +maintainerEmail: "Maintainer email" +tosUrl: "Terms of Service URL" +thisYear: "Year" +thisMonth: "Month" +today: "Today" +dayX: "{day}" +monthX: "{month}" +yearX: "{year}" +pages: "Pages" +integration: "Integrations" +connectService: "Connect" +disconnectService: "Disconnect" +enableLocalTimeline: "Enable local timeline" +enableGlobalTimeline: "Enable global timeline" +enableRecommendedTimeline: "Enable recommended timeline" +disablingTimelinesInfo: "Adminstrators and Moderators will always have access to all + timelines, even if they are not enabled." +registration: "Register" +enableRegistration: "Enable new user registration" +invite: "Invite" +driveCapacityPerLocalAccount: "Drive capacity per local user" +driveCapacityPerRemoteAccount: "Drive capacity per remote user" +inMb: "In megabytes" +iconUrl: "Icon URL" +bannerUrl: "Banner image URL" +backgroundImageUrl: "Background image URL" +basicInfo: "Basic info" +pinnedUsers: "Pinned users" +pinnedUsersDescription: "List usernames separated by line breaks to be pinned in the + \"Explore\" tab." +pinnedPages: "Pinned Pages" +pinnedPagesDescription: "Enter the paths of the Pages you want to pin to the top page + of this server, separated by line breaks." +pinnedClipId: "ID of the clip to pin" +pinnedNotes: "Pinned posts" +hcaptcha: "hCaptcha" +enableHcaptcha: "Enable hCaptcha" +hcaptchaSiteKey: "Site key" +hcaptchaSecretKey: "Secret key" +recaptcha: "reCAPTCHA" +enableRecaptcha: "Enable reCAPTCHA" +recaptchaSiteKey: "Site key" +recaptchaSecretKey: "Secret key" +avoidMultiCaptchaConfirm: "Using multiple Captcha systems may cause interference between + them. Would you like to disable the other Captcha systems currently active? If you + would like them to stay enabled, press cancel." +antennas: "Antennas" +antennasDesc: "Antennas display new posts matching the criteria you set!\n They can + be accessed from the timelines page." +manageAntennas: "Manage Antennas" +name: "Name" +antennaSource: "Antenna source" +antennaKeywords: "Keywords to listen to" +antennaExcludeKeywords: "Keywords to exclude" +antennaKeywordsDescription: "Separate with spaces for an AND condition or with line + breaks for an OR condition." +notifyAntenna: "Notify about new posts" +withFileAntenna: "Only posts with files" +enableServiceworker: "Enable Push-Notifications for your Browser" +antennaUsersDescription: "List one username per line" +antennaInstancesDescription: "List one server host per line" +antennaTimelineHint: "Antennas display matching posts in order they have been received + in, which is not necessarily chronological." +caseSensitive: "Case sensitive" +withReplies: "Include replies" +connectedTo: "Following account(s) are connected" +notesAndReplies: "Posts and replies" +withFiles: "With attachments" +silence: "Silence" +silenceConfirm: "Are you sure that you want to silence this user?" +unsilence: "Undo silencing" +unsilenceConfirm: "Are you sure that you want to undo the silencing of this user?" +popularUsers: "Popular users" +recentlyUpdatedUsers: "Recently active users" +recentlyRegisteredUsers: "Newly joined users" +recentlyDiscoveredUsers: "Newly discovered users" +exploreUsersCount: "There are {count} users" +exploreFediverse: "Explore the Fediverse" +popularTags: "Popular tags" +userList: "Lists" +about: "About" +aboutFrozenFriendsYume: "About FrozenFriendsYume" +administrator: "Administrator" +token: "Token" +twoStepAuthentication: "Two-factor authentication" +moderator: "Moderator" +moderation: "Moderation" +nUsersMentioned: "Mentioned by {n} users" +securityKey: "Security key" +securityKeyName: "Key name" +registerSecurityKey: "Register a security key" +lastUsed: "Last used" +unregister: "Unregister" +passwordLessLogin: "Password-less login" +resetPassword: "Reset password" +newPasswordIs: "The new password is \"{password}\"" +reduceUiAnimation: "Reduce UI animations" +share: "Share" +notFound: "Not found" +notFoundDescription: "No page corresponding to this URL could be found." +uploadFolder: "Default folder for uploads" +cacheClear: "Clear cache" +markAsReadAllNotifications: "Mark all notifications as read" +markAsReadAllUnreadNotes: "Mark all posts as read" +markAsReadAllTalkMessages: "Mark all messages as read" +help: "Help" +inputMessageHere: "Enter message here" +close: "Close" +group: "Group" +groups: "Groups" +createGroup: "Create a group" +ownedGroups: "Owned Groups" +joinedGroups: "Joined groups" +invites: "Invites" +groupName: "Group name" +members: "Members" +transfer: "Transfer" +messagingWithUser: "Private chat" +messagingWithGroup: "Group chat" +title: "Title" +text: "Text" +enable: "Enable" +next: "Next" +retype: "Enter again" +noteOf: "Post by {user}" +inviteToGroup: "Invite to group" +quoteAttached: "Quote" +quoteQuestion: "Append as quote?" +noMessagesYet: "No messages yet" +newMessageExists: "There are new messages" +onlyOneFileCanBeAttached: "You can only attach one file to a message" +signinRequired: "Please register or sign in before continuing" +invitations: "Invites" +invitationCode: "Invitation code" +checking: "Checking…" +available: "Available" +unavailable: "Not available" +usernameInvalidFormat: "You can use upper- and lowercase letters, numbers, and underscores." +tooShort: "Too short" +tooLong: "Too long" +weakPassword: "Weak password" +normalPassword: "Average password" +strongPassword: "Strong password" +passwordMatched: "Matches" +passwordNotMatched: "Does not match" +signinWith: "Sign in with {x}" +signinFailed: "Unable to sign in. The entered username or password is incorrect." +tapSecurityKey: "Tap your security key" +or: "Or" +language: "Language" +uiLanguage: "User interface language" +groupInvited: "You've been invited to a group" +aboutX: "About {x}" +useOsNativeEmojis: "Use OS native Emoji" +disableDrawer: "Don't use drawer-style menus" +youHaveNoGroups: "You have no groups" +joinOrCreateGroup: "Get invited to a group or create your own." +noHistory: "No history available" +signinHistory: "Login history" +disableAnimatedMfm: "Disable MFM with animation" +doing: "Processing…" +category: "Category" +tags: "Tags" +docSource: "Source of this document" +createAccount: "Create account" +existingAccount: "Existing account" +regenerate: "Regenerate" +fontSize: "Font size" +noFollowRequests: "You don't have any pending follow requests" +openImageInNewTab: "Open images in new tab" +dashboard: "Dashboard" +local: "Local" +remote: "Remote" +total: "Total" +weekOverWeekChanges: "Changes to last week" +dayOverDayChanges: "Changes to yesterday" +appearance: "Appearance" +accessibility: "Accessibility" +clientSettings: "Client Settings" +accountSettings: "Account Settings" +promotion: "Promoted" +promote: "Promote" +numberOfDays: "Number of days" +hideThisNote: "Hide this post" +showFeaturedNotesInTimeline: "Show featured posts in timelines" +objectStorage: "Object Storage" +useObjectStorage: "Use object storage" +objectStorageBaseUrl: "Base URL" +objectStorageBaseUrlDesc: "The URL used as reference. Specify the URL of your CDN + or Proxy if you are using either.\nFor S3 use 'https://.s3.amazonaws.com' + and for GCS or equivalent services use 'https://storage.googleapis.com/', + etc." +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "Please specify the bucket name used at your provider." +objectStoragePrefix: "Prefix" +objectStoragePrefixDesc: "Files will be stored under directories with this prefix." +objectStorageEndpoint: "Endpoint" +objectStorageEndpointDesc: "Leave this empty if you are using AWS S3, otherwise specify + the endpoint as '' or ':', depending on the service you are using." +objectStorageRegion: "Region" +objectStorageRegionDesc: "Specify a region like 'xx-east-1'. If your service does + not distinguish between regions, leave this blank or enter 'us-east-1'." +objectStorageUseSSL: "Use SSL" +objectStorageUseSSLDesc: "Turn this off if you are not going to use HTTPS for API + connections" +objectStorageUseProxy: "Connect over Proxy" +objectStorageUseProxyDesc: "Turn this off if you are not going to use a Proxy for + API connections" +objectStorageSetPublicRead: "Set \"public-read\" on upload" +serverLogs: "Server logs" +deleteAll: "Delete all" +showFixedPostForm: "Display the posting form at the top of the timeline" +newNoteRecived: "There are new posts" +sounds: "Sounds" +listen: "Listen" +none: "None" +showInPage: "Show in page" +popout: "Pop-out" +volume: "Volume" +masterVolume: "Master volume" +details: "Details" +chooseEmoji: "Select an emoji" +unableToProcess: "The operation could not be completed" +recentUsed: "Recently used" +install: "Install" +uninstall: "Uninstall" +installedApps: "Authorized Applications" +nothing: "There's nothing to see here" +installedDate: "Authorized at" +lastUsedDate: "Last used at" +state: "State" +sort: "Sort" +ascendingOrder: "Ascending" +descendingOrder: "Descending" +scratchpad: "Scratchpad" +scratchpadDescription: "The scratchpad provides an environment for AiScript experiments. + You can write, execute, and check the results of it interacting with FrozenFriendsYume in + it." +output: "Output" +script: "Script" +disablePagesScript: "Disable AiScript on Pages" +expandOnNoteClick: "Open post on click" +expandOnNoteClickDesc: "If disabled, you can still open posts in the right-click menu + or by clicking the timestamp." +updateRemoteUser: "Update remote user information" +deleteAllFiles: "Delete all files" +deleteAllFilesConfirm: "Are you sure that you want to delete all files?" +removeAllFollowing: "Unfollow all followed users" +removeAllFollowingDescription: "Executing this unfollows all accounts from {host}. + Please run this if the server e.g. no longer exists." +userSuspended: "This user has been suspended." +userSilenced: "This user is silenced." +yourAccountSuspendedTitle: "This account is suspended" +yourAccountSuspendedDescription: "This account has been suspended due to breaking + the server's terms of services or similar. Contact the administrator if you would + like to know a more detailed reason. Please do not create a new account." +menu: "Menu" +divider: "Divider" +addItem: "Add Item" +relays: "Relays" +addRelay: "Add Relay" +inboxUrl: "Inbox URL" +addedRelays: "Added Relays" +serviceworkerInfo: "Must be enabled for push notifications." +deletedNote: "Deleted post" +invisibleNote: "Invisible post" +enableInfiniteScroll: "Automatically load more" +visibility: "Visiblility" +cannotChangeScopeWhenEditing: "You can't change visibility of this post while editing" +poll: "Poll" +useCw: "Hide content" +enablePlayer: "Open video player" +disablePlayer: "Close video player" +expandTweet: "Expand tweet" +themeEditor: "Theme editor" +description: "Description" +describeFile: "Add caption" +enterFileDescription: "Enter caption" +author: "Author" +leaveConfirm: "There are unsaved changes. Do you want to discard them?" +manage: "Management" +plugins: "Plugins" +preferencesBackups: "Preference backups" +deck: "Deck" +undeck: "Leave Deck" +useBlurEffectForModal: "Use blur effect for modals" +useFullReactionPicker: "Use full-size reaction picker" +width: "Width" +height: "Height" +xl: "XL" +large: "Big" +medium: "Medium" +small: "Small" +generateAccessToken: "Generate access token" +permission: "Permissions" +enableAll: "Enable all" +disableAll: "Disable all" +tokenRequested: "Grant access to account" +pluginTokenRequestedDescription: "This plugin will be able to use the permissions + set here." +notificationType: "Notification type" +edit: "Edit" +emailServer: "Email server" +enableEmail: "Enable email distribution" +emailConfigInfo: "Used to confirm your email during sign-up or if you forget your + password" +email: "Email" +emailAddress: "Email address" +smtpConfig: "SMTP Server Configuration" +smtpHost: "Host" +smtpPort: "Port" +smtpUser: "Username" +smtpPass: "Password" +emptyToDisableSmtpAuth: "Leave username and password empty to disable SMTP verification" +smtpSecure: "Use implicit SSL/TLS for SMTP connections" +smtpSecureInfo: "Turn this off when using STARTTLS" +testEmail: "Test email delivery" +wordMute: "Word mute" +regexpError: "Regular Expression error" +regexpErrorDescription: "An error occurred in the regular expression on line {line} + of your {tab} word mutes:" +instanceMute: "Server Mutes" +userSaysSomething: "{name} said something" +userSaysSomethingReason: "{name} said {reason}" +userSaysSomethingReasonReply: "{name} replied to a post containing {reason}" +userSaysSomethingReasonRenote: "{name} boosted a post containing {reason}" +userSaysSomethingReasonQuote: "{name} quoted a post containing {reason}" +makeActive: "Activate" +display: "Display" +copy: "Copy" +metrics: "Metrics" +overview: "Overview" +logs: "Logs" +delayed: "Delayed" +database: "Database" +channel: "Channels" +channelFederationWarn: "Channels do not yet federate to other servers" +create: "Create" +notificationSetting: "Notification settings" +notificationSettingDesc: "Select the types of notification to display." +useGlobalSetting: "Use global settings" +useGlobalSettingDesc: "If turned on, your account's notification settings will be + used. If turned off, individual configurations can be made." +other: "Other" +regenerateLoginToken: "Regenerate login token" +regenerateLoginTokenDescription: "Regenerates the token used internally during login. + Normally this action is not necessary. If regenerated, all devices will be logged + out." +setMultipleBySeparatingWithSpace: "Separate multiple entries with spaces." +fileIdOrUrl: "File ID or URL" +behavior: "Behavior" +sample: "Sample" +abuseReports: "Reports" +reportAbuse: "Report" +reportAbuseOf: "Report {name}" +fillAbuseReportDescription: "Please fill in details regarding this report. If it is + about a specific post, please include its URL." +abuseReported: "Your report has been sent. Thank you very much." +reporter: "Reporter" +reporteeOrigin: "Reportee Origin" +reporterOrigin: "Reporter Origin" +forwardReport: "Forward report to remote server" +forwardReportIsAnonymous: "Instead of your account, an anonymous system account will + be displayed as reporter at the remote server." +send: "Send" +abuseMarkAsResolved: "Mark report as resolved" +openInNewTab: "Open in new tab" +openInSideView: "Open in side view" +defaultNavigationBehaviour: "Default navigation behavior" +editTheseSettingsMayBreakAccount: "Editing these settings may damage your account." +instanceTicker: "Server information of posts" +waitingFor: "Waiting for {x}" +random: "Random" +system: "System" +switchUi: "Switch layout" +desktop: "Desktop" +clip: "Clip" +createNew: "Create new" +optional: "Optional" +createNewClip: "Create new clip" +unclip: "Unclip" +confirmToUnclipAlreadyClippedNote: "This post is already part of the \"{name}\" clip. + Do you want to remove it from this clip instead?" +public: "Public" +i18nInfo: "FrozenFriendsYume is being translated into various languages by volunteers. You + can help at {link}." +manageAccessTokens: "Manage access tokens" +accountInfo: "Account Info" +notesCount: "Number of posts" +repliesCount: "Number of replies sent" +renotesCount: "Number of boosts sent" +repliedCount: "Number of replies received" +renotedCount: "Number of boosts received" +followingCount: "Number of followed accounts" +followersCount: "Number of followers" +sentReactionsCount: "Number of sent reactions" +receivedReactionsCount: "Number of received reactions" +pollVotesCount: "Number of sent poll votes" +pollVotedCount: "Number of received poll votes" +yes: "Yes" +no: "No" +driveFilesCount: "Number of Drive files" +driveUsage: "Drive space usage" +noCrawle: "Reject crawler indexing" +noCrawleDescription: "Ask search engines to not index your profile page, posts, Pages, + etc." +lockedAccountInfo: "Unless you set your post visiblity to \"Followers only\", your + posts will be visible to anyone, even if you require followers to be manually approved." +alwaysMarkSensitive: "Mark as sensitive by default" +loadRawImages: "Load original images instead of showing thumbnails" +disableShowingAnimatedImages: "Don't play animated images" +verificationEmailSent: "A verification email has been sent. Please follow the included + link to complete verification." +notSet: "Not set" +emailVerified: "Email has been verified" +noteFavoritesCount: "Number of bookmarked posts" +pageLikesCount: "Number of liked Pages" +pageLikedCount: "Number of received Page likes" +contact: "Contact" +useSystemFont: "Use the system's default font" +clips: "Clips" +clipsDesc: "Clips are like share-able categorized bookmarks. You can create clips + from the menu of individual posts." +experimentalFeatures: "Experimental features" +developer: "Developer" +makeExplorable: "Make account visible in \"Explore\"" +makeExplorableDescription: "If you turn this off, your account will not show up in + the \"Explore\" section." +showGapBetweenNotesInTimeline: "Show a gap between posts on the timeline" +duplicate: "Duplicate" +left: "Left" +center: "Center" +wide: "Wide" +narrow: "Narrow" +reloadToApplySetting: "This setting will only apply after a page reload. Reload now?" +needReloadToApply: "A reload is required for this to be reflected." +showTitlebar: "Show title bar" +clearCache: "Clear cache" +onlineUsersCount: "{n} users are online" +nUsers: "{n} Users" +nNotes: "{n} Posts" +sendErrorReports: "Send error reports" +sendErrorReportsDescription: "When turned on, detailed error information will be shared + with FrozenFriendsYume when a problem occurs, helping to improve the quality of FrozenFriendsYume.\n + This will include information such the version of your OS, what browser you're using, + your activity in FrozenFriendsYume, etc." +myTheme: "My theme" +backgroundColor: "Background color" +accentColor: "Accent color" +textColor: "Text color" +saveAs: "Save as…" +advanced: "Advanced" +value: "Value" +createdAt: "Created at" +updatedAt: "Updated at" +saveConfirm: "Save changes?" +deleteConfirm: "Really delete?" +invalidValue: "Invalid value." +registry: "Registry" +closeAccount: "Close account" +currentVersion: "Current version" +latestVersion: "Newest version" +youAreRunningUpToDateClient: "You are using the newest version of your client." +newVersionOfClientAvailable: "There is a newer version of your client available." +usageAmount: "Usage" +capacity: "Capacity" +inUse: "Used" +editCode: "Edit code" +apply: "Apply" +receiveAnnouncementFromInstance: "Receive notifications from this server" +emailNotification: "Email notifications" +publish: "Publish" +inChannelSearch: "Search in channel" +useReactionPickerForContextMenu: "Open reaction picker on right-click" +typingUsers: "{users} is typing" +jumpToSpecifiedDate: "Jump to specific date" +showingPastTimeline: "Currently displaying an old timeline" +clear: "Clear" +markAllAsRead: "Mark all as read" +goBack: "Back" +unlikeConfirm: "Really remove your like?" +fullView: "Full view" +quitFullView: "Exit full view" +addDescription: "Add description" +userPagePinTip: "You can display posts here by selecting \"Pin to profile\" from the + menu of individual posts." +notSpecifiedMentionWarning: "This post contains mentions of users not included as + recipients" +info: "About" +userInfo: "User information" +unknown: "Unknown" +onlineStatus: "Online status" +hideOnlineStatus: "Hide online status" +hideOnlineStatusDescription: "Hiding your online status reduces the convenience of + some features such as the search." +online: "Online" +active: "Active" +offline: "Offline" +notRecommended: "Not recommended" +botProtection: "Bot Protection" +instanceBlocking: "Federation Management" +selectAccount: "Select account" +switchAccount: "Switch account" +enabled: "Enabled" +disabled: "Disabled" +quickAction: "Quick actions" +user: "User" +administration: "Management" +accounts: "Accounts" +switch: "Switch" +noMaintainerInformationWarning: "Maintainer information is not configured." +noBotProtectionWarning: "Bot protection is not configured." +configure: "Configure" +postToGallery: "Create new gallery post" +gallery: "Gallery" +recentPosts: "Recent pages" +popularPosts: "Popular pages" +shareWithNote: "Share with post" +ads: "Advertisements" +expiration: "Deadline" +memo: "Memo" +priority: "Priority" +high: "High" +middle: "Medium" +low: "Low" +emailNotConfiguredWarning: "Email address not set." +ratio: "Ratio" +secureMode: "Secure Mode (Authorized Fetch)" +instanceSecurity: "Server Security" +secureModeInfo: "When requesting from other servers, do not send back without proof." +privateMode: "Private Mode" +privateModeInfo: "When enabled, only the listed servers can federate with your server. + All posts will be hidden from the public." +allowedInstances: "Allowlisted Servers" +allowedInstancesDescription: "Hosts of servers to be allowed to federate with, each + separated by a new line (only applies in private mode)." +previewNoteText: "Show preview" +customCss: "Custom CSS" +customCssWarn: "This setting should only be used if you know what it does. Entering + improper values may cause the client to stop functioning normally." +global: "Global" +recommended: "Recommended" +squareAvatars: "Display squared avatars" +seperateRenoteQuote: "Separate boost and quote buttons" +sent: "Sent" +received: "Received" +searchResult: "Search results" +hashtags: "Hashtags" +troubleshooting: "Troubleshooting" +useBlurEffect: "Use blur effects in the UI" +learnMore: "Learn more" +iceshrimpUpdated: "FrozenFriendsYume has been updated!" +iceshrimpUpdatedWithVersion: "Updated! Version {version}" +whatIsNew: "Show changes" +translate: "Translate" +translatedFrom: "Translated from {x}" +accountDeletionInProgress: "Account deletion is currently in progress" +usernameInfo: "A name that identifies your account from others on this server. You + can use the alphabet (a~z, A~Z), digits (0~9) or underscores (_). Usernames cannot + be changed later." +aiChanMode: "Ai-chan in Classic UI" +keepCw: "Keep content warnings" +pubSub: "Pub/Sub Accounts" +lastCommunication: "Last communication" +resolved: "Resolved" +unresolved: "Unresolved" +breakFollow: "Remove follower" +breakFollowConfirm: "Are you sure want to remove follower?" +itsOn: "Enabled" +itsOff: "Disabled" +emailRequiredForSignup: "Require email address for sign-up" +unread: "Unread" +filter: "Filter" +controlPanel: "Control Panel" +manageAccounts: "Manage Accounts" +makeReactionsPublic: "Set reaction history to public" +makeReactionsPublicDescription: "This will make the list of all your past reactions + publicly visible." +classic: "Centered" +muteThread: "Mute thread" +unmuteThread: "Unmute thread" +ffVisibility: "Follows/Followers Visibility" +ffVisibilityDescription: "Allows you to configure who can see who you follow and who + follows you." +continueThread: "Continue thread" +deleteAccountConfirm: "This will irreversibly delete your account. Proceed?" +incorrectPassword: "Incorrect password." +voteConfirm: "Confirm your vote for \"{choice}\"?" +hide: "Hide" +alt: "ALT" +leaveGroup: "Leave group" +leaveGroupConfirm: "Are you sure you want to leave \"{name}\"?" +useDrawerReactionPickerForMobile: "Display reaction picker as drawer on mobile" +clickToFinishEmailVerification: "Please click [{ok}] to complete email verification." +overridedDeviceKind: "Device type" +smartphone: "Smartphone" +tablet: "Tablet" +auto: "Auto" +themeColor: "Server Ticker Color" +size: "Size" +numberOfColumn: "Number of columns" +searchByGoogle: "Search" +instanceDefaultLightTheme: "Server-wide default light theme" +instanceDefaultDarkTheme: "Server-wide default dark theme" +instanceDefaultThemeDescription: "Enter the theme JSON." +mutePeriod: "Mute duration" +indefinitely: "Permanently" +tenMinutes: "10 minutes" +oneHour: "One hour" +oneDay: "One day" +oneWeek: "One week" +reflectMayTakeTime: "It may take some time for this to be reflected." +failedToFetchAccountInformation: "Could not fetch account information" +rateLimitExceeded: "Rate limit exceeded" +cropImage: "Crop image" +cropImageAsk: "Do you want to crop this image?" +file: "File" +image: "Image" +video: "Video" +audio: "Audio" +recentNHours: "Last {n} hours" +recentNDays: "Last {n} days" +noEmailServerWarning: "Email server not configured." +thereIsUnresolvedAbuseReportWarning: "There are unsolved reports." +check: "Check" +driveCapOverrideLabel: "Change the drive capacity for this user" +driveCapOverrideCaption: "Reset the capacity to default by inputting a value of 0 + or lower." +requireAdminForView: "You must log in with an administrator account to view this." +isSystemAccount: "This account is created and automatically operated by the system. + Please do not moderate, edit, delete, or otherwise tamper with this account, or + it may break your server." +typeToConfirm: "Please enter {x} to confirm" +deleteAccount: "Delete account" +document: "Documentation" +numberOfPageCache: "Number of cached pages" +numberOfPageCacheDescription: "Increasing this number will improve convenience for + users but cause more server load as well as more memory to be used." +logoutConfirm: "Really log out?" +lastActiveDate: "Last used at" +statusbar: "Status bar" +pleaseSelect: "Select an option" +reverse: "Reverse" +colored: "Colored" +refreshInterval: "Update interval" +label: "Label" +type: "Type" +speed: "Speed" +slow: "Slow" +fast: "Fast" +sensitiveMediaDetection: "Detection of sensitive media" +localOnly: "Local only" +remoteOnly: "Remote only" +failedToUpload: "Upload failed" +cannotUploadBecauseInappropriate: "This file could not be uploaded because parts of + it have been detected as potentially sensitive." +cannotUploadBecauseNoFreeSpace: "Upload failed due to lack of Drive capacity." +cannotUploadBecauseExceedsFileSizeLimit: "This file could not be uploaded because + it exceeds the maximum allowed size." +beta: "Beta" +enableAutoSensitive: "Automatic sensitive-marking" +enableAutoSensitiveDescription: "Allows automatic detection and marking of sensitive + media through Machine Learning where possible. Even if this option is disabled, + it may be enabled server-wide." +activeEmailValidationDescription: "Enables stricter validation of email addresses, + which includes checking for disposable addresses and by whether it can actually + be communicated with. When unchecked, only the format of the email is validated." +navbar: "Navigation bar" +shuffle: "Shuffle" +account: "Account" +move: "Move" +pushNotification: "Push notifications" +subscribePushNotification: "Enable push notifications" +unsubscribePushNotification: "Disable push notifications" +pushNotificationAlreadySubscribed: "Push notifications are already enabled" +pushNotificationNotSupported: "Your browser or server does not support push notifications" +sendPushNotificationReadMessage: "Delete push notifications once the relevant notifications + or messages have been read" +sendPushNotificationReadMessageCaption: "A notification containing the text \"{emptyPushNotificationMessage}\"\ + \ will be displayed for a short time. This may increase the battery usage of your + device, if applicable." +showAds: "Show ads" +enterSendsMessage: "Press Return in Messaging to send message (off is Ctrl + Return)" +adminCustomCssWarn: "This setting should only be used if you know what it does. Entering + improper values may cause EVERYONE'S clients to stop functioning normally. Please + ensure your CSS works properly by testing it in your user settings." +customMOTD: "Custom MOTD (splash screen messages)" +customMOTDDescription: "Custom messages for the MOTD (splash screen) separated by + line breaks to be shown randomly every time a user loads/reloads the page." +customSplashIcons: "Custom splash screen icons (urls)" +customSplashIconsDescription: "URLs for custom splash screen icons separated by line + breaks to be shown randomly every time a user loads/reloads the page. Please make + sure the images are on a static URL, preferably all resized to 192x192." +showUpdates: "Show a popup when FrozenFriendsYume updates" +recommendedInstances: "Recommended servers" +recommendedInstancesDescription: "Recommended servers separated by line breaks to + appear in the recommended timeline." +caption: "Auto Caption" +splash: "Splash Screen" +updateAvailable: "There might be an update available!" +swipeOnMobile: "Allow swiping between pages" +swipeOnDesktop: "Allow mobile-style swiping on desktop" +logoImageUrl: "Logo image URL" +showAdminUpdates: "Indicate a new FrozenFriendsYume version is avaliable (admin only)" +replayTutorial: "Replay tutorial" +migration: "Migration" +moveTo: "Move current account to new account" +moveToLabel: "Account you're moving to:" +moveAccount: "Move account!" +moveAccountDescription: "This process is irreversible. Make sure you've set up an + alias for this account on your new account before moving. Please enter the tag of + the account formatted like @person@server.com" +moveFrom: "Move to this account from an older account" +moveFromLabel: "Account you're moving from:" +moveFromDescription: "This will set an alias of your old account so that you can move + from that account to this current one. Do this BEFORE moving from your older account. + Please enter the tag of the account formatted like @person@server.com" +migrationConfirm: "Are you absolutely sure you want to migrate your account to {account}? + Once you do this, you won't be able to reverse it, and you won't be able to use + your account normally again.\nAlso, please ensure that you've set this current account + as the account you're moving from." +defaultReaction: "Default emoji reaction for outgoing and incoming posts" +license: "License" +customKaTeXMacro: "Custom KaTeX macros" +customKaTeXMacroDescription: "Set up macros to write mathematical expressions easily! + The notation conforms to the LaTeX command definitions and is written as \\newcommand{\\ + name}{content} or \\newcommand{\\name}[number of arguments]{content}. For example, + \\newcommand{\\add}[2]{#1 + #2} will expand \\add{3}{foo} to 3 + foo. The curly + brackets surrounding the macro name can be changed to round or square brackets. + This affects the brackets used for arguments. One (and only one) macro can be defined + per line, and you can't break the line in the middle of the definition. Invalid + lines are simply ignored. Only simple string substitution functions are supported; + advanced syntax, such as conditional branching, cannot be used here." +enableCustomKaTeXMacro: "Enable custom KaTeX macros" +noteId: "Post ID" +signupsDisabled: "Signups on this server are currently disabled, but you can always + sign up at another server! If you have an invitation code for this server, please + enter it below." +findOtherInstance: "Find another server" +apps: "Apps" +sendModMail: "Send Moderation Notice" +preventAiLearning: "Prevent AI bot scraping" +preventAiLearningDescription: "Request third-party AI language models not to study + content you upload, such as posts and images." +noGraze: "Please disable the \"Graze for Mastodon\" browser extension, as it interferes + with FrozenFriendsYume." +silencedWarning: "This page is showing because these users are from servers your admin + silenced, so they may potentially be spam." +isBot: "This account is a bot" +isLocked: "This account has follow approvals" +isModerator: "Moderator" +isAdmin: "Administrator" +isPatron: "FrozenFriendsYume Patron" +reactionPickerSkinTone: "Preferred emoji skin tone" +enableServerMachineStats: "Enable server hardware statistics" +enableIdenticonGeneration: "Enable Identicon generation" +showPopup: "Notify users with popup" +showWithSparkles: "Show with sparkles" +youHaveUnreadAnnouncements: "You have unread announcements" +donationLink: "Link to donation page" +neverShow: "Don't show again" +remindMeLater: "Maybe later" +removeQuote: "Remove quote" +removeRecipient: "Remove recipient" +removeMember: "Remove member" +verifiedLink: "Verified link" +minorBadgeKDescription: "K badge marks an account intended for younger-safe interactions. Only one of K/T/E can be enabled." +minorBadgeTDescription: "T badge marks an account intended for teen-oriented interactions. Only one of K/T/E can be enabled." +minorBadgeEDescription: "E badge marks an account that should not be shown to minors. Users with K or T badges cannot see E-badged users." +openInMainColumn: "Open in main column" +searchNotLoggedIn_1: "You have to be authenticated in order to use full text search." +searchNotLoggedIn_2: "However, you can search using hashtags, and search users." +searchEmptyQuery: "Please enter a search term." +bite: "Bite" +biteBack: "Bite back" +bitYou: "bit you" +bitYouBack: "bit you back" +bitYourNote: "bit your note" + +_sensitiveMediaDetection: + description: "Reduces the effort of server moderation through automatically recognizing + sensitive media via Machine Learning. This will slightly increase the load on + the server." + sensitivity: "Detection sensitivity" + sensitivityDescription: "Reducing the sensitivity will lead to fewer misdetections + (false positives) whereas increasing it will lead to fewer missed detections (false + negatives)." + setSensitiveFlagAutomatically: "Mark as sensitive" + setSensitiveFlagAutomaticallyDescription: "The results of the internal detection + will be retained even if this option is turned off." + analyzeVideos: "Enable analysis of videos" + analyzeVideosDescription: "Analyzes videos in addition to images. This will slightly + increase the load on the server." +_emailUnavailable: + used: "This email address is already being used" + format: "The format of this email address is invalid" + disposable: "Disposable email addresses may not be used" + mx: "This email server is invalid" + smtp: "This email server is not responding" +_ffVisibility: + public: "Public" + followers: "Visible to followers only" + private: "Private" +_signup: + almostThere: "Almost there" + emailAddressInfo: "Please enter your email address. It will not be made public." + emailSent: "A confirmation email has been sent to your email address ({email}). + Please click the included link to complete account creation." +_accountDelete: + accountDelete: "Delete account" + mayTakeTime: "As account deletion is a resource-heavy process, it may take some + time to complete depending on how much content you have created and how many files + you have uploaded." + sendEmail: "Once account deletion has been completed, an email will be sent to the + email address registered to this account." + requestAccountDelete: "Request account deletion" + started: "Deletion has been started." + inProgress: "Account deletion is in progress" +_ad: + back: "Back" + reduceFrequencyOfThisAd: "Show this ad less" +_forgotPassword: + enterEmail: "Enter the email address you used to register. A link with which you + can reset your password will then be sent to it." + ifNoEmail: "If you did not use an email during registration, please contact the + server administrator instead." + contactAdmin: "This server does not support using email addresses, please contact + the server administrator to reset your password instead." +_gallery: + my: "My Gallery" + liked: "Liked Posts" + like: "Like" + unlike: "Remove like" +_email: + _follow: + title: "You've got a new follower" + _receiveFollowRequest: + title: "You've received a follow request" +_plugin: + install: "Install plugins" + installWarn: "Please do not install untrustworthy plugins." + manage: "Manage plugins" +_preferencesBackups: + list: "Created backups" + saveNew: "Save new backup" + loadFile: "Load from file" + apply: "Apply to this device" + save: "Save changes" + inputName: "Please enter a name for this backup" + cannotSave: "Saving failed" + nameAlreadyExists: "A backup called \"{name}\" already exists. Please enter a different + name." + applyConfirm: "Do you really want to apply the \"{name}\" backup to this device? + Existing settings of this device will be overwritten." + saveConfirm: "Save backup as {name}?" + deleteConfirm: "Delete the {name} backup?" + renameConfirm: "Rename this backup from \"{old}\" to \"{new}\"?" + noBackups: "No backups exist. You may backup your client settings on this server + by using \"Create new backup\"." + createdAt: "Created at: {date} {time}" + updatedAt: "Updated at: {date} {time}" + cannotLoad: "Loading failed" + invalidFile: "Invalid file format" + delete: "Delete backup" +_registry: + scope: "Scope" + key: "Key" + keys: "Keys" + domain: "Domain" + createKey: "Create key" +_aboutFrozenFriendsYume: + about: "FrozenFriendsYume is yet another fork of Misskey, bringing you no-nonsense fixes, + features & improvements you actually want since 2023." + contributors: "Main contributors" + allContributors: "All contributors" + source: "FrozenFriendsYume development" + translation: "Translations" + chatroom: "Chat room" + documentation: "Documentation" + roadmap: "Roadmap" + changelog: "Changelog" + donate: "Donate to FrozenFriendsYume" + donateTitle: "Enjoying FrozenFriendsYume?" + pleaseDonateToFrozenFriendsYume: "Please consider donating to FrozenFriendsYume to support its development." + pleaseDonateToHost: "Please also consider donating to your home server, {host}, + to help support its operation costs." + donateHost: "Donate to {host}" + morePatrons: "We also appreciate the support of many other helpers not listed here. + Thank you! 🥰" + sponsors: "FrozenFriendsYume sponsors" + patrons: "FrozenFriendsYume patrons" + patronsList: "Listed chronologically, not by donation size. Donate with the link + above to get your name on here!" +_nsfw: + respect: "Hide sensitive media" + ignore: "Don't hide sensitive media" + force: "Hide all media" +_mfm: + play: "Play MFM" + stop: "Stop MFM" + warn: "MFM may contain rapidly moving or flashy animations" + alwaysPlay: "Always autoplay all animated MFM" + cheatSheet: "MFM Cheatsheet" + intro: "MFM is a markup language used on FrozenFriendsYume, Misskey, Akkoma, and more that + can be used in posts and chats. Here you can view a list of all available MFM + syntax." + dummy: "FrozenFriendsYume expands the world of the Fediverse" + advanced: "Advanced MFM" + advancedDescription: "If disabled, only allows for basic markup unless animated + MFM is playing" + mention: "Mention" + mentionDescription: "You can specify a user by using an At-Symbol and a username." + hashtag: "Hashtag" + hashtagDescription: "You can specify a hashtag using a number sign and text." + url: "URL" + urlDescription: "URLs can be displayed." + link: "Link" + linkDescription: "Specific parts of text can be displayed as a URL." + bold: "Bold" + boldDescription: "Highlights letters by making them thicker." + small: "Small" + smallDescription: "Displays content small and thin." + center: "Center" + centerDescription: "Displays content centered." + inlineCode: "Code (Inline)" + inlineCodeDescription: "Displays inline syntax highlighting for (program) code." + blockCode: "Code (Block)" + blockCodeDescription: "Displays syntax highlighting for multi-line (program) code + in a block." + inlineMath: "Math (Inline)" + inlineMathDescription: "Display math formulas (KaTeX) in-line" + blockMath: "Math (Block)" + blockMathDescription: "Display math formulas (KaTeX) in a block" + quote: "Quote" + quoteDescription: "Displays content as a quote." + emoji: "Custom Emoji" + emojiDescription: "By surrounding a custom emoji name with colons, a custom emoji + can be displayed." + search: "Search" + searchDescription: "Displays a search box with pre-entered text." + flip: "Flip" + flipDescription: "Flips content horizontally or vertically." + jelly: "Animation (Jelly)" + jellyDescription: "Gives content a jelly-like animation." + tada: "Animation (Tada)" + tadaDescription: "Gives content a \"Tada!\"-like animation." + jump: "Animation (Jump)" + jumpDescription: "Gives content a jumping animation." + bounce: "Animation (Bounce)" + bounceDescription: "Gives content a bouncy animation." + shake: "Animation (Shake)" + shakeDescription: "Gives content a shaking animation." + twitch: "Animation (Twitch)" + twitchDescription: "Gives content a strongly twitching animation." + spin: "Animation (Spin)" + spinDescription: "Gives content a spinning animation." + x2: "Big" + x2Description: "Displays content bigger." + x3: "Very big" + x3Description: "Displays content even bigger." + x4: "Unbelievably big" + x4Description: "Displays content even bigger than bigger than big." + blur: "Blur" + blurDescription: "Blurs content. It will be displayed clearly when hovered over." + font: "Font" + fontDescription: "Sets the font to display content in." + rainbow: "Rainbow" + rainbowDescription: "Makes the content appear in rainbow colors." + sparkle: "Sparkle" + sparkleDescription: "Gives content a sparkling particle effect." + rotate: "Rotate" + rotateDescription: "Turns content by a specified angle." + fade: "Fade" + fadeDescription: "Fades content in and out." + position: "Position" + positionDescription: "Move content by a specified amount." + crop: "Crop" + cropDescription: "Crop content." + scale: "Scale" + scaleDescription: "Scale content by a specified amount." + foreground: "Foreground color" + foregroundDescription: "Change the foreground color of text." + background: "Background color" + backgroundDescription: "Change the background color of text." + plain: "Plain" + plainDescription: "Deactivates the effects of all MFM contained within this MFM + effect." + border: "Border" + borderDescription: "Adds a border around content." + ruby: "Ruby" + rubyDescription: "Render a small annotation above text, Usually used for showing pronounciations of East Asian characters." + unixtime: "Unix Time" + unixtimeDescription: "Convert a number of seconds since 1st of January, 1970 to a readable date." + followmouse: "Follow Mouse" + followmouseDescription: "Make content follow the mouse cursor." + followmouseToggle: "Toggle preview" +_instanceTicker: + none: "Never show" + remote: "Show for remote users" + always: "Always show" +_serverDisconnectedBehavior: + reload: "Automatically reload" + dialog: "Show warning dialog" + quiet: "Show unobtrusive warning" + nothing: "Do nothing" +_channel: + create: "Create channel" + edit: "Edit channel" + setBanner: "Set banner" + removeBanner: "Remove banner" + featured: "Trending" + owned: "Owned" + following: "Followed" + usersCount: "{n} Participants" + notesCount: "{n} Posts" + nameAndDescription: "Name and description" + nameOnly: "Name only" +_messaging: + dms: "Private" + groups: "Groups" +_menuDisplay: + sideFull: "Side" + sideIcon: "Side (Icons)" + top: "Top" + hide: "Hide" +_wordMute: + muteWords: "Muted words" + muteWordsDescription: "Separate with spaces for an AND condition or with line breaks + for an OR condition." + muteWordsDescription2: "Surround keywords with slashes to use regular expressions." + softDescription: "Hide posts that fulfil the set conditions from the timeline." + hardDescription: "Prevents posts fulfilling the set conditions from being added + to the timeline. In addition, these posts will not be added to the timeline even + if the conditions are changed." + soft: "Soft" + hard: "Hard" + mutedNotes: "Muted posts" +_instanceMute: + instanceMuteDescription: "This will mute any posts/boosts from the listed servers, + including those of users replying to a user from a muted server." + instanceMuteDescription2: "Separate with newlines" + title: "Hides posts from listed servers." + heading: "List of servers to be muted" +_theme: + explore: "Explore Themes" + install: "Install a theme" + manage: "Manage themes" + code: "Theme code" + description: "Description" + installed: "{name} has been installed" + installedThemes: "Installed themes" + builtinThemes: "Built-in themes" + alreadyInstalled: "This theme is already installed" + invalid: "The format of this theme is invalid" + make: "Make a theme" + base: "Base" + addConstant: "Add constant" + constant: "Constant" + defaultValue: "Default value" + color: "Color" + refProp: "Reference a property" + refConst: "Reference a constant" + key: "Key" + func: "Functions" + funcKind: "Function type" + argument: "Argument" + basedProp: "Referenced property" + alpha: "Opacity" + darken: "Darken" + lighten: "Lighten" + inputConstantName: "Enter a name for this constant" + importInfo: "If you enter theme code here, you can import it to the theme editor" + deleteConstantConfirm: "Do you really want to delete the constant {const}?" + keys: + accent: "Accent" + bg: "Background" + fg: "Text" + focus: "Focus" + indicator: "Indicator" + panel: "Panel" + shadow: "Shadow" + header: "Header" + navBg: "Sidebar background" + navFg: "Sidebar text" + navHoverFg: "Sidebar text (Hover)" + navActive: "Sidebar text (Active)" + navIndicator: "Sidebar indicator" + link: "Link" + hashtag: "Hashtag" + mention: "Mention" + mentionMe: "Mentions (Me)" + renote: "Boost" + modalBg: "Modal background" + divider: "Divider" + scrollbarHandle: "Scrollbar handle" + scrollbarHandleHover: "Scrollbar handle (Hover)" + dateLabelFg: "Date label text" + infoBg: "Information background" + infoFg: "Information text" + infoWarnBg: "Warning background" + infoWarnFg: "Warning text" + cwBg: "CW button background" + cwFg: "CW button text" + cwHoverBg: "CW button background (Hover)" + toastBg: "Notification background" + toastFg: "Notification text" + buttonBg: "Button background" + buttonHoverBg: "Button background (Hover)" + inputBorder: "Input field border" + listItemHoverBg: "List item background (Hover)" + driveFolderBg: "Drive folder background" + wallpaperOverlay: "Wallpaper overlay" + badge: "Badge" + messageBg: "Chat background" + accentDarken: "Accent (Darkened)" + accentLighten: "Accent (Lightened)" + fgHighlighted: "Highlighted Text" +_sfx: + note: "New post" + noteMy: "Own post" + notification: "Notifications" + chat: "Chat" + chatBg: "Chat (Background)" + antenna: "Antennas" + channel: "Channel notifications" +_ago: + future: "Future" + justNow: "Just now" + secondsAgo: "{n}s ago" + minutesAgo: "{n}m {n2}s ago" + hoursAgo: "{n}h {n2}m ago" + daysAgo: "{n}d {n2}h ago" + weeksAgo: "{n}w {n2}d ago" + monthsAgo: "{n}mo {n2}w ago" + yearsAgo: "{n}y {n2}mo ago" +_time: + second: "Second(s)" + minute: "Minute(s)" + hour: "Hour(s)" + day: "Day(s)" +_filters: + _dialog: + title: "Search filter syntax" + learnMore: "View filter syntax" + wordFilters: "Filter by post text" + inFilters: "Filter by bookmark and/or favorite status" + miscFilters: "Filter by following relationship and/or note type" + userDomain: "Filter by author, mentioned users, reply user or instance domain" + postDate: "Filter by post date" + exclusivity: "Note that the before: filter is exclusive, while the after: filter + is inclusive." + word: "word" + phrase: "literal phrase that contains (arbitrary) characters" + attachmentType: "Filter by attachment type(s)" + matchOptions: "Change case sensitivity and/or enable whole word matching" + info: "Nomenclature" + info1: "Text in brackets signifies available optional filter parameters. Parameter + options are signified by a pipe character." + info2: "A dash enclosed in brackets denotes the ability to invert/negate a filter + with the dash character." + infoEnd: "Filter aliases" + infoEnd1: "For convenience and typo-prevention, some filters have aliases, which + are listed below." + fromUser: "From user" + replyTo: "Replying to" + mentioning: "Mentioning" + inFavorites: "Favorited" + inBookmarks: "Bookmarked" + withFile: "Has attachment" + fromDomain: "Specific instance only" + notesBefore: "Posts before" + notesAfter: "Posts after" + followingOnly: "Following only" + followersOnly: "Followers only" + repliesOnly: "Replies only" + excludeReplies: "Exclude replies" + excludeRenotes: "Exclude boosts" + caseSensitive: "Case sensitive" + matchWords: "Match whole words" +_tutorial: + title: "How to use FrozenFriendsYume" + step1_1: "Welcome!" + step1_2: "Let's get you set up. You'll be up and running in no time!" + step2_1: "First, please fill out your profile." + step2_2: "Providing some information about who you are will make it easier for others + to tell if they want to see your posts or follow you." + step3_1: "Now it's time to follow some people!" + step3_2: "Your home and social timelines are based off of who you follow, so try + following a couple accounts to get started.\nClick the plus circle on the top + right of a profile to follow them." + step4_1: "Let's get you out there." + step4_2: "For your first post, some people like to make an {introduction} post or + a simple \"Hello world!\"" + step5_1: "Timelines, timelines everywhere!" + step5_2: "Your server has {timelines} different timelines enabled." + step5_3: "The Home {icon} timeline is where you can see posts from the accounts + you follow." + step5_4: "The Local {icon} timeline is where you can see posts from everyone else + on this server." + step5_5: "The Social {icon} timeline is a combination of the Home and Local timelines." + step5_6: "The Recommended {icon} timeline is where you can see posts from servers + the admins recommend." + step5_7: "The Global {icon} timeline is where you can see posts from every other + connected server." + step6_1: "So, what is this place?" + step6_2: "Well, you didn't just join FrozenFriendsYume. You joined a portal to the Fediverse, + an interconnected network of thousands of servers." + step6_3: "Each server works in different ways, and not all servers run FrozenFriendsYume. + This one does though! It's a bit complicated, but you'll get the hang of it in + no time." + step6_4: "Now go, explore, and have fun!" +_2fa: + alreadyRegistered: "You have already registered a 2-factor authentication device." + registerTOTP: "Register authenticator app" + step1: "First, install an authentication app (such as {a} or {b}) on your device." + step2: "Then, scan the QR code displayed on this screen." + step2Click: "Clicking on this QR code will allow you to register 2FA to your security + key or phone authenticator app." + step2Url: "You can also enter this URL if you're using a desktop program:" + step3Title: "Enter an authentication code" + step3: "Enter the token provided by your app to finish setup." + step4: "From now on, any future login attempts will ask for such a login token." + securityKeyNotSupported: "Your browser does not support security keys." + registerTOTPBeforeKey: "Please set up an authenticator app to register a security + or pass key." + securityKeyInfo: "Besides fingerprint or PIN authentication, you can also setup + authentication via hardware security keys that support FIDO2 to further secure + your account." + chromePasskeyNotSupported: "Chrome passkeys are currently not supported." + registerSecurityKey: "Register a security or pass key" + securityKeyName: "Enter a key name" + tapSecurityKey: "Please follow your browser to register the security or pass key" + removeKey: "Remove security key" + removeKeyConfirm: "Really delete the {name} key?" + whyTOTPOnlyRenew: "The authenticator app cannot be removed as long as a security + key is registered." + renewTOTP: "Reconfigure authenticator app" + renewTOTPConfirm: "This will cause verification codes from your previous app to + stop working" + renewTOTPOk: "Reconfigure" + renewTOTPCancel: "Cancel" + token: "2FA Token" +_permissions: + "read:account": "View your account information" + "write:account": "Edit your account information" + "read:blocks": "View your list of blocked users" + "write:blocks": "Edit your list of blocked users" + "read:drive": "Access your Drive files and folders" + "write:drive": "Edit or delete your Drive files and folders" + "read:favorites": "View your list of bookmarks" + "write:favorites": "Edit your list of bookmarks" + "read:following": "View information on who you follow" + "write:following": "Follow or unfollow other accounts" + "read:messaging": "View your chats" + "write:messaging": "Compose or delete chat messages" + "read:mutes": "View your list of muted users" + "write:mutes": "Edit your list of muted users" + "write:notes": "Compose or delete posts" + "read:notifications": "View your notifications" + "write:notifications": "Manage your notifications" + "read:reactions": "View your reactions" + "write:reactions": "Edit your reactions" + "write:votes": "Vote on a poll" + "read:pages": "View your page" + "write:pages": "Edit or delete your page" + "read:page-likes": "View your likes on page" + "write:page-likes": "Edit your likes on page" + "read:user-groups": "View your user groups" + "write:user-groups": "Edit or delete your user groups" + "read:channels": "View your channels" + "write:channels": "Edit your channels" + "read:gallery": "View your gallery" + "write:gallery": "Edit your gallery" + "read:gallery-likes": "View your list of liked gallery posts" + "write:gallery-likes": "Edit your list of liked gallery posts" +_auth: + shareAccess: "Would you like to authorize \"{name}\" to access this account?" + shareAccessAsk: "Are you sure you want to authorize this application to access your + account?" + permissionAsk: "This application requests the following permissions:" + pleaseGoBack: "Please go back to the application" + callback: "Returning to the application" + denied: "Access denied" + copyAsk: "Please paste the following authorization code in the application:" + allPermissions: "Full account access" + signedInAs: "Signed in as" + authRequired: "Authorization required" +_antennaSources: + all: "All posts" + homeTimeline: "Posts from followed users" + users: "Posts from specific users" + userList: "Posts from a specified list of users" + userGroup: "Posts from users in a specified group" + instances: "Posts from all users on an server" +_weekday: + sunday: "Sunday" + monday: "Monday" + tuesday: "Tuesday" + wednesday: "Wednesday" + thursday: "Thursday" + friday: "Friday" + saturday: "Saturday" +_widgets: + memo: "Sticky Notes" + notifications: "Notifications" + timeline: "Timeline" + calendar: "Calendar" + trends: "Trending" + clock: "Clock" + rss: "RSS Reader" + rssTicker: "RSS Ticker" + activity: "Activity" + photos: "Photos" + digitalClock: "Digital Clock" + unixClock: "UNIX Clock" + federation: "Federation" + postForm: "Posting Form" + slideshow: "Slideshow" + button: "Button" + onlineUsers: "Online Users" + jobQueue: "Job Queue" + serverMetric: "Server Metrics" + aiscript: "AiScript Console" + userList: "User List" + serverInfo: "Server Info" + _userList: + chooseList: "Select a list" + meiliStatus: "Server Status" + meiliSize: "Index size" + meiliIndexCount: "Indexed posts" + +_cw: + hide: "Hide content" + show: "Show content" + chars: "{count} characters" + files: "{count} file(s)" +_poll: + noOnlyOneChoice: "At least two choices are needed" + choiceN: "Choice {n}" + noMore: "You cannot add more choices" + canMultipleVote: "Allow selecting multiple choices" + expiration: "End poll" + infinite: "Never" + at: "End at…" + after: "End after…" + deadlineDate: "End date" + deadlineTime: "Time" + duration: "Duration" + votesCount: "{n} votes" + totalVotes: "{n} votes in total" + vote: "Vote" + showResult: "View results" + voted: "Voted" + closed: "Ended" + remainingDays: "{d} day(s) {h} hour(s) remaining" + remainingHours: "{h} hour(s) {m} minute(s) remaining" + remainingMinutes: "{m} minute(s) {s} second(s) remaining" + remainingSeconds: "{s} second(s) remaining" +_visibility: + public: "Public" + publicDescription: "Your post will be visible in all public timelines" + home: "Unlisted" + homeDescription: "Post to home timeline only" + followers: "Followers" + followersDescription: "Make visible to your followers and mentioned users only" + specified: "Direct" + specifiedDescription: "Make visible for specified users only" + localOnly: "Local only" + localOnlyDescription: "Not visible to remote users" +_postForm: + replyPlaceholder: "Reply to this post…" + quotePlaceholder: "Quote this post…" + channelPlaceholder: "Post to a channel…" + _placeholders: + a: "What are you up to?" + b: "What's happening around you?" + c: "What's on your mind?" + d: "What do you want to say?" + e: "Start writing…" + f: "Waiting for you to write…" +_profile: + name: "Name" + username: "Username" + description: "Bio" + youCanIncludeHashtags: "You can also include hashtags in your bio." + metadata: "Additional Information" + metadataEdit: "Edit additional Information" + metadataDescription: "Using these, you can display additional information fields + in your profile. You can add an {a} tag or {l} tag with {rel} to verify the link + on your profile!" + metadataLabel: "Label" + metadataContent: "Content" + changeAvatar: "Change avatar" + changeBanner: "Change banner" + locationDescription: "If you enter your city first, it will display your local time + to other users." + pronouns: "Pronouns" +_exportOrImport: + allNotes: "All posts" + followingList: "Followed users" + muteList: "Muted users" + blockingList: "Blocked users" + userLists: "User lists" + excludeMutingUsers: "Exclude muted users" + excludeInactiveUsers: "Exclude inactive users" +_charts: + federation: "Federation" + apRequest: "Requests" + usersIncDec: "Difference in the number of users" + usersTotal: "Total number of users" + activeUsers: "Active users" + notesIncDec: "Difference in the number of posts" + localNotesIncDec: "Difference in the number of local posts" + remoteNotesIncDec: "Difference in the number of remote posts" + notesTotal: "Total number of posts" + filesIncDec: "Difference in the number of files" + filesTotal: "Total number of files" + storageUsageIncDec: "Difference in storage usage" + storageUsageTotal: "Total storage usage" +_instanceCharts: + requests: "Requests" + users: "Difference in the number of users" + usersTotal: "Cumulative number of users" + notes: "Difference in the number of posts" + notesTotal: "Cumulative number of posts" + ff: "Difference in the number of followed users / followers " + ffTotal: "Cumulative number of followed users / followers" + cacheSize: "Difference in cache size" + cacheSizeTotal: "Cumulative total cache size" + files: "Difference in the number of files" + filesTotal: "Cumulative number of files" +_timelines: + home: "Home" + local: "Local" + recommended: "Recommended" + social: "Social" + global: "Global" +_pages: + newPage: "Create a new Page" + editPage: "Edit this Page" + readPage: "Viewing this Page's source" + created: "Page successfully created" + updated: "Page successfully edited" + deleted: "Page successfully deleted" + pageSetting: "Page settings" + nameAlreadyExists: "The specified Page URL already exists" + invalidNameTitle: "The specified Page URL is invalid" + invalidNameText: "Make sure the Page title is not empty" + editThisPage: "Edit this Page" + viewSource: "View source" + viewPage: "View your Pages" + like: "Like" + unlike: "Remove like" + my: "My Pages" + liked: "Liked Pages" + featured: "Popular" + inspector: "Inspector" + contents: "Content" + content: "Page block" + variables: "Variables" + title: "Title" + url: "Page URL" + summary: "Page summary" + alignCenter: "Center elements" + hideTitleWhenPinned: "Hide Page title when pinned to profile" + font: "Font" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + eyeCatchingImageSet: "Set thumbnail" + eyeCatchingImageRemove: "Delete thumbnail" + chooseBlock: "Add a block" + selectType: "Select a type" + enterVariableName: "Enter a variable name" + variableNameIsAlreadyUsed: "This variable name is already in use" + contentBlocks: "Content" + inputBlocks: "Input" + specialBlocks: "Special" + blocks: + text: "Text" + textarea: "Text area" + section: "Section" + image: "Images" + button: "Button" + if: "If" + _if: + variable: "Variable" + post: "Posting form" + _post: + text: "Content" + attachCanvasImage: "Attach canvas image" + canvasId: "Canvas ID" + textInput: "Text input" + _textInput: + name: "Variable name" + text: "Title" + default: "Default value" + textareaInput: "Multiline text input" + _textareaInput: + name: "Variable name" + text: "Title" + default: "Default value" + numberInput: "Numeric input" + _numberInput: + name: "Variable name" + text: "Title" + default: "Default value" + canvas: "Canvas" + _canvas: + id: "Canvas ID" + width: "Width" + height: "Height" + note: "Embedded post" + _note: + id: "Post ID" + idDescription: "You can alternatively paste the post URL here." + detailed: "Detailed view" + switch: "Switch" + _switch: + name: "Variable name" + text: "Title" + default: "Default value" + counter: "Counter" + _counter: + name: "Variable name" + text: "Title" + inc: "Step" + _button: + text: "Title" + colored: "Colored" + action: "Behavior when the button is pressed" + _action: + dialog: "Show a dialog" + _dialog: + content: "Content" + resetRandom: "Reset the random seed" + pushEvent: "Send an event" + _pushEvent: + event: "Event name" + message: "Message to display when activated" + variable: "Variable to send" + no-variable: "None" + callAiScript: "Invoke AiScript" + _callAiScript: + functionName: "Function name" + radioButton: "Choice" + _radioButton: + name: "Variable name" + title: "Title" + values: "List of choices separated by line breaks" + default: "Default value" + script: + categories: + flow: "Flow control" + logical: "Logical operation" + operation: "Computation" + comparison: "Comparison" + random: "Random" + value: "Values" + fn: "Functions" + text: "Text operations" + convert: "Transformations" + list: "Lists" + blocks: + text: "Text" + multiLineText: "Text (multiline)" + textList: "Text list" + _textList: + info: "Separate each entry with a line break" + strLen: "Text length" + _strLen: + arg1: "Text" + strPick: "Extract string" + _strPick: + arg1: "Text" + arg2: "String location" + strReplace: "Replacement string" + _strReplace: + arg1: "Text" + arg2: "Text to be replaced" + arg3: "Replace with" + strReverse: "Flip text" + _strReverse: + arg1: "Text" + join: "Text concatenation" + _join: + arg1: "Lists" + arg2: "Separator" + add: "Add" + _add: + arg1: "A" + arg2: "B" + subtract: "Subtract" + _subtract: + arg1: "A" + arg2: "B" + multiply: "Multiply" + _multiply: + arg1: "A" + arg2: "B" + divide: "Divide" + _divide: + arg1: "A" + arg2: "B" + mod: "Remainder" + _mod: + arg1: "A" + arg2: "B" + round: "Decimal rounding" + _round: + arg1: "Number" + eq: "A and B are equal" + _eq: + arg1: "A" + arg2: "B" + notEq: "A and B are different" + _notEq: + arg1: "A" + arg2: "B" + and: "A AND B" + _and: + arg1: "A" + arg2: "B" + or: "A OR B" + _or: + arg1: "A" + arg2: "B" + lt: "< A is less than B" + _lt: + arg1: "A" + arg2: "B" + gt: "> A is larger than B" + _gt: + arg1: "A" + arg2: "B" + ltEq: "<= A is less than or equal to B" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: ">= A is greater than or equal to B" + _gtEq: + arg1: "A" + arg2: "B" + if: "Branch" + _if: + arg1: "If" + arg2: "Then" + arg3: "Else" + not: "NOT" + _not: + arg1: "NOT" + random: "Random" + _random: + arg1: "Probability" + rannum: "Random number" + _rannum: + arg1: "Minimum value" + arg2: "Maximum value" + randomPick: "Randomly choose from list" + _randomPick: + arg1: "List" + dailyRandom: "Random (Changes once a day for each user)" + _dailyRandom: + arg1: "Probability" + dailyRannum: "Random number (Changes once a day for each user)" + _dailyRannum: + arg1: "Minimum value" + arg2: "Maximum value" + dailyRandomPick: "Randomly choose from a list (Changes once a day for each user)" + _dailyRandomPick: + arg1: "List" + seedRandom: "Random (with seed)" + _seedRandom: + arg1: "Seed" + arg2: "Probability" + seedRannum: "Random number (with seed)" + _seedRannum: + arg1: "Seed" + arg2: "Minimum value" + arg3: "Maximum value" + seedRandomPick: "Randomly choose from list (with seed)" + _seedRandomPick: + arg1: "Seed" + arg2: "List" + DRPWPM: "Randomly choose from weighted list (Changes once a day for each user)" + _DRPWPM: + arg1: "Text list" + pick: "Select from list" + _pick: + arg1: "List" + arg2: "Position" + listLen: "Get length of list" + _listLen: + arg1: "List" + number: "Number" + stringToNumber: "Text to number" + _stringToNumber: + arg1: "Text" + numberToString: "Number to text" + _numberToString: + arg1: "Number" + splitStrByLine: "Split text by line breaks" + _splitStrByLine: + arg1: "Text" + ref: "Variable" + aiScriptVar: "AiScript Variable" + fn: "Function" + _fn: + slots: "Slots" + slots-info: "Separate each slot with a line break" + arg1: "Output" + for: "for-Loop" + _for: + arg1: "Number of times to repeat" + arg2: "Action" + typeError: "Slot {slot} accepts values of type \"{expect}\", but the provided + value is of type \"{actual}\"!" + thereIsEmptySlot: "Slot {slot} is empty!" + types: + string: "Text" + number: "Number" + boolean: "Flag" + array: "List" + stringArray: "Text list" + emptySlot: "Empty slot" + enviromentVariables: "Environment variables" + pageVariables: "Page variables" + argVariables: "Input slots" +_relayStatus: + requesting: "Pending" + accepted: "Accepted" + rejected: "Rejected" +_notification: + fileUploaded: "File successfully uploaded" + youGotMention: "{name} mentioned you" + youGotReply: "{name} replied to you" + youGotQuote: "{name} quoted you" + youRenoted: "Boost from {name}" + youGotPoll: "{name} voted on your poll" + youGotMessagingMessageFromUser: "{name} sent you a chat message" + youGotMessagingMessageFromGroup: "A chat message was sent to the {name} group" + youWereFollowed: "followed you" + youReceivedFollowRequest: "You've received a follow request" + yourFollowRequestAccepted: "Your follow request was accepted" + youWereInvitedToGroup: "{userName} invited you to a group" + pollEnded: "Poll results have become available" + emptyPushNotificationMessage: "Push notifications have been updated" + reacted: "reacted to your post" + renoted: "boosted your post" + voted: "voted on your poll" + _types: + all: "All" + follow: "New followers" + mention: "Mentions" + reply: "Replies" + renote: "Boosts" + quote: "Quotes" + reaction: "Reactions" + pollVote: "Votes on polls" + pollEnded: "Polls ending" + receiveFollowRequest: "Received follow requests" + followRequestAccepted: "Accepted follow requests" + groupInvited: "Group invitations" + app: "Notifications from linked apps" + bite: "Bites" + _actions: + followBack: "followed you back" + reply: "Reply" + renote: "Boosts" +_deck: + alwaysShowMainColumn: "Always show main column" + columnAlign: "Align columns" + addColumn: "Add column" + configureColumn: "Column settings" + swapLeft: "Swap with the left column" + swapRight: "Swap with the right column" + swapUp: "Swap with the above column" + swapDown: "Swap with the below column" + stackLeft: "Stack with the left column" + popRight: "Pop column to the right" + profile: "Workspace" + newProfile: "New workspace" + renameProfile: "Rename workspace" + deleteProfile: "Delete workspace" + nameAlreadyExists: "This workspace name already exists." + introduction: "Create the perfect interface for you by arranging columns freely!" + introduction2: "Click on the + on the right of the screen to add new colums whenever + you want." + widgetsIntroduction: "Please select \"Edit widgets\" in the column menu and add + a widget." + _columns: + main: "Main" + widgets: "Widgets" + notifications: "Notifications" + tl: "Timeline" + antenna: "Antenna" + list: "List" + channel: "Channel" + mentions: "Mentions" + direct: "Direct messages" +_dialog: + charactersExceeded: "Max characters exceeded! Current: {current}/Limit: {max}" + charactersBelow: "Not enough characters! Current: {current}/Limit: {min}" +_skinTones: + yellow: "Yellow" + light: "Light" + mediumLight: "Medium Light" + medium: "Medium" + mediumDark: "Medium Dark" + dark: "Dark" +_feeds: + copyFeed: "Copy feed" + rss: "RSS" + atom: "Atom" + jsonFeed: "JSON feed" +cwStyle: "Content Warning appearance" +_cwStyle: + modern: "Modern" + classic: "Classic (Misskey/Foundkey-like)" + alternative: "Alternative (Firefish-like)" +alwaysExpandCws: "Always expand posts with content warnings" +hideFromHome: "Hide from home timeline" +_wellness: + name: "Wellness" + description: "These settings allow you to adjust possibly addictive or anxiety-inducing + aspects of social media. Choose the settings that are ideal for you." + newPostsButton: "Enable new posts alert button" + newPostsGlowOpacity: "New posts glow opacity" + immediacy: "Immediacy" +_biteControls: + name: "Who can bite you" + anyone: "Anyone" + followers: "Followers" + nobody: "Nobody" diff --git a/locales/es-ES.yml b/locales/es-ES.yml new file mode 100644 index 0000000..b432548 --- /dev/null +++ b/locales/es-ES.yml @@ -0,0 +1,2211 @@ +_lang_: "Español" +headlineIceshrimp: "¡Una plataforma de código abierto para redes sociales descentralizadas, + gratis para siempre! 🚀" +introIceshrimp: "¡Bienvenido! ¡Iceshrimp es una plataforma de código abierto para + redes sociales descentralizadas, gratis para siempre! 🚀" +monthAndDay: "{day}/{month}" +search: "Buscar" +notifications: "Notificaciones" +username: "Nombre de usuario" +password: "Contraseña" +forgotPassword: "Olvidé mi contraseña" +fetchingAsApObject: "Recuperando desde el Fediverso" +ok: "OK" +gotIt: "¡Lo tengo!" +cancel: "Cancelar" +enterUsername: "Introduce el nombre de usuario" +renotedBy: "Impulsado por {user}" +noNotes: "No hay publicaciones" +noNotifications: "No hay notificaciones" +instance: "Instancia" +settings: "Configuración" +basicSettings: "Configuración Básica" +otherSettings: "Configuración Avanzada" +openInWindow: "Abrir en una ventana" +profile: "Perfil" +timeline: "Línea de tiempo" +noAccountDescription: "Este usuario todavía no ha escrito su biografía." +login: "Iniciar sesión" +loggingIn: "Iniciando sesión" +logout: "Cerrar sesión" +signup: "Registrarse" +uploading: "Cargando…" +save: "Guardar" +users: "Usuarios" +addUser: "Añadir usuario" +favorite: "Añadir a marcadores" +favorites: "Marcadores" +unfavorite: "Quitar de marcadores" +favorited: "Añadido a marcadores." +alreadyFavorited: "Ya está en marcadores." +cantFavorite: "No se pudo añadir a marcadores." +pin: "Fijar al perfil" +unpin: "Desfijar" +copyContent: "Copiar contenido" +copyLink: "Copiar enlace" +delete: "Borrar" +deleteAndEdit: "Borrar y editar" +deleteAndEditConfirm: "¿Estás seguro de que quieres borrar y editar esta publicación? + Perderás todas las reacciones, impulsos y respuestas." +addToList: "Añadir a una lista" +sendMessage: "Enviar un mensaje" +copyUsername: "Copiar nombre de usuario" +searchUser: "Buscar un usuario" +reply: "Responder" +loadMore: "Ver más" +showMore: "Ver más" +showLess: "Cerrar" +youGotNewFollower: "te ha seguido" +receiveFollowRequest: "Recibiste una petición de seguimiento" +followRequestAccepted: "La petición de seguimiento fue aceptada" +mention: "Mención" +mentions: "Menciones" +directNotes: "Mensajes directos" +importAndExport: "Importar/Exportar Datos" +import: "Importar" +export: "Exportar" +files: "Archivos" +download: "Descargar" +driveFileDeleteConfirm: "¿Estás seguro de querer borrar el archivo \"{name}\"? Será + eliminado de todas las publicaciones que lo incluyan." +unfollowConfirm: "¿Quieres dejar de seguir a {name}?" +exportRequested: "Se ha solicitado la exportación. Puede tomar un tiempo. Cuando termine + la exportación, se añadirá al almacén." +importRequested: "Se ha solicitado la importación. Puede tomar un tiempo." +lists: "Listas" +noLists: "No tienes ninguna lista" +note: "Anotar" +notes: "Notas" +following: "Siguiendo" +followers: "Seguidores" +followsYou: "Te sigue" +createList: "Crear lista" +manageLists: "Administrar listas" +error: "Error" +somethingHappened: "Ocurrió un error" +retry: "Reintentar" +pageLoadError: "Error al cargar la página." +pageLoadErrorDescription: "Normalmente es por errores de red o la caché del navegador. + Prueba a limpiar la caché o inténtalo más tarde." +serverIsDead: "El servidor no responde. Espera un momento y vuelve a intentarlo." +youShouldUpgradeClient: "Para ver esta página, por favor, recarga la página para actualizar + el cliente." +enterListName: "Introduce el nombre de la lista" +privacy: "Privacidad" +makeFollowManuallyApprove: "Aprobar manualmente las peticiones de seguimiento" +defaultNoteVisibility: "Visibilidad por defecto" +follow: "Seguir" +followRequest: "Enviar petición de seguimiento" +followRequests: "Peticiones de seguimiento" +unfollow: "Dejar de seguir" +followRequestPending: "Peticiones de seguimiento pendientes" +enterEmoji: "Introduce un emoji" +renote: "Renotar" +unrenote: "Quitar renota" +renoted: "Renotado." +cantRenote: "No se puede renotar esta publicación." +cantReRenote: "No se puede renotar una renota." +quote: "Citar" +pinnedNote: "Nota fijada" +pinned: "Fijar al perfil" +you: "Tú" +clickToShow: "Click para ver" +sensitive: "Marcado como sensible" +add: "Añadir" +reaction: "Reacciones" +reactionSetting: "Reacciones para mostrar en el menú de reacciones" +reactionSettingDescription2: "Arrastra para reordenar, click para borrar, pulsa \"\ + +\" para añadir." +rememberNoteVisibility: "Recordar la configuración de visibilidad de las notas" +attachCancel: "Quitar adjunto" +markAsSensitive: "Marcar como sensible" +unmarkAsSensitive: "Desmarcar como sensible" +enterFileName: "Introduce el nombre del archivo" +mute: "Silenciar" +unmute: "Dejar de silenciar" +block: "Bloquear" +unblock: "Retirar bloqueo" +suspend: "Suspender" +unsuspend: "Dejar de suspender" +blockConfirm: "¿Quieres bloquear esta cuenta?" +unblockConfirm: "¿Quieres dejar de bloquear esta cuenta?" +suspendConfirm: "¿Quieres suspender esta cuenta?" +unsuspendConfirm: "¿Quieres dejar de suspender esta cuenta?" +selectList: "Selecciona una lista" +selectAntenna: "Selecciona una antena" +selectWidget: "Selecciona un widget" +editWidgets: "Editar widgets" +editWidgetsExit: "Listo" +customEmojis: "Emojis personalizados" +emoji: "Emoji" +emojis: "Emoji" +emojiName: "Nombre del emoji" +emojiUrl: "URL del emoji" +addEmoji: "Añadir emoji" +settingGuide: "Configuración recomendada" +cacheRemoteFiles: "Mantener en caché los archivos remotos" +cacheRemoteFilesDescription: "Si desactiva esta configuración, los archivos remotos + se cargarán desde el servidor remoto sin usar la caché. Con eso se puede ahorrar + almacenamiento del servidor, pero aumentará el tráfico al no crear miniaturas." +flagAsBot: "Marcar esta cuenta como bot 🤖" +flagAsBotDescription: "Si esta cuenta la gestiona un programa, activa esta opción. + Al hacerlo, esta opción servirá para evitar cadenas infinitas de reacciones con + otros bots, y ajustará los sistemas internos de Iceshrimp para que trate a esta + cuenta como un bot." +flagAsCat: "¿Eres un gato? 😺" +flagAsCatDescription: "¡Te daremos orejitas y hablarás como un gato!" +flagShowTimelineReplies: "Mostrar respuestas en la línea de tiempo" +flagShowTimelineRepliesDescription: "Si la activas, se mostrarán las respuestas de + los usuarios a otros posts en la línea de tiempo." +autoAcceptFollowed: "Aceptar automáticamente las peticiones de seguimiento de los + usuarios que sigues" +addAccount: "Añadir cuenta" +loginFailed: "Error al iniciar sesión" +showOnRemote: "Ver en la página original" +general: "General" +wallpaper: "Fondo de pantalla" +setWallpaper: "Establecer fondo de pantalla" +removeWallpaper: "Quitar fondo de pantalla" +searchWith: "Buscar: {q}" +youHaveNoLists: "No tienes listas" +followConfirm: "¿Quieres seguir a {name}?" +proxyAccount: "Cuenta proxy" +proxyAccountDescription: "Una cuenta proxy es una cuenta que actúa como un seguidor + remoto de un usuario bajo ciertas condiciones. Por ejemplo, cuando un usuario añade + un usuario remoto a una lista, si ningún usuario local sigue al usuario agregado + a la lista, el servidor no puede obtener su actividad. Así que la cuenta proxy sigue + al usuario añadido a la lista." +host: "Host" +selectUser: "Elegir usuario" +recipient: "Recipiente(s)" +annotation: "Comentarios" +federation: "Federación" +instances: "Servidores" +registeredAt: "Registrado el" +latestRequestSentAt: "Ultima petición enviada" +latestRequestReceivedAt: "Ultima petición recibida" +latestStatus: "Último estado" +storageUsage: "Almacenamiento usado" +charts: "Gráficos" +perHour: "por hora" +perDay: "por día" +stopActivityDelivery: "Dejar de enviar actividades" +blockThisInstance: "Bloquear este servidor" +operations: "Operaciones" +software: "Software" +version: "Versión" +metadata: "Metadatos" +monitor: "Monitor" +jobQueue: "Cola de trabajos" +cpuAndMemory: "CPU y memoria" +network: "Red" +disk: "Disco" +instanceInfo: "Información del servidor" +statistics: "Estadísticas" +clearQueue: "Limpiar cola" +clearQueueConfirmTitle: "¿Estás seguro de querer limpiar la cola?" +clearQueueConfirmText: "Las publicaciones aún no entregadas no se federarán. Normalmente + no se necesita ejecutar esta operación." +clearCachedFiles: "Limpiar caché" +clearCachedFilesConfirm: "¿Quieres borrar todos los archivos remotos en caché?" +blockedInstances: "Servidores bloqueados" +blockedInstancesDescription: "Escriba los dominios de los servidores que quieres bloquear. + Los servidores bloqueados no podrán comunicarse con este servidor." +muteAndBlock: "Silenciados y bloqueados" +mutedUsers: "Usuarios silenciados" +blockedUsers: "Usuarios bloqueados" +noUsers: "No hay usuarios" +editProfile: "Editar perfil" +noteDeleteConfirm: "¿Quieres borrar esta nota?" +pinLimitExceeded: "Ya no se pueden fijar más publicaciones" +intro: "¡La instalación de Iceshrimp ha terminado! Por favor, crea un usuario administrador." +done: "Terminado" +processing: "Procesando…" +preview: "Vista previa" +default: "Predeterminado" +defaultValueIs: "Predeterminado: {value}" +noCustomEmojis: "No hay emojis personalizados" +noJobs: "No hay trabajos" +federating: "Federando" +blocked: "Bloqueado" +suspended: "Suspendido" +all: "Todo" +subscribing: "Suscribiendo" +publishing: "Publicando" +notResponding: "Sin respuestas" +instanceFollowing: "Siguiendo en este servidor" +instanceFollowers: "Seguidores del servidor" +instanceUsers: "Usuarios de este servidor" +changePassword: "Cambiar contraseña" +security: "Seguridad" +retypedNotMatch: "No hay coincidencias." +currentPassword: "Contraseña actual" +newPassword: "Contraseña nueva" +newPasswordRetype: "Repite la contraseña" +attachFile: "Añadir archivo" +more: "Más" +featured: "Destacados" +usernameOrUserId: "Nombre o ID del usuario" +noSuchUser: "Usuario no encontrado" +lookup: "Búsqueda" +announcements: "Anuncios" +imageUrl: "URL de la imagen" +remove: "Borrar" +removed: "Borrado con éxito" +removeAreYouSure: "¿Estás seguro de querer borrar \"{x}\"?" +deleteAreYouSure: "¿Estás seguro de querer borrar \"{x}\"?" +resetAreYouSure: "¿Estás seguro de que quieres reestablecer?" +saved: "Guardado" +messaging: "Chat" +upload: "Subir" +keepOriginalUploading: "Mantener la imagen original" +keepOriginalUploadingDescription: "Mantener la versión original al cargar imágenes. + Si se desactiva, se generará una versión para mostrar en la web al cargar." +fromDrive: "Desde el almacén" +fromUrl: "Desde la URL" +uploadFromUrl: "Subir desde una URL" +uploadFromUrlDescription: "URL del archivo que quieres subir" +uploadFromUrlRequested: "Subida solicitada" +uploadFromUrlMayTakeTime: "Subir el archivo puede tardar un tiempo." +explore: "Explorar" +messageRead: "Ya leído" +noMoreHistory: "Estás al final del historial" +startMessaging: "Iniciar chat" +nUsersRead: "Leído por {n} personas" +agreeTo: "Estoy de acuerdo con {0}" +tos: "Términos de servicio" +start: "Comenzar" +home: "Inicio" +remoteUserCaution: "La información del usuario remoto tal vez esté incompleta." +activity: "Actividad" +images: "Imágenes" +birthday: "Fecha de nacimiento" +yearsOld: "{age} años" +registeredDate: "Fecha de registro" +location: "Lugar" +theme: "Temas" +themeForLightMode: "Tema para usar en Modo Claro" +themeForDarkMode: "Tema para usar en Modo Oscuro" +light: "Claro" +dark: "Oscuro" +lightThemes: "Temas claros" +darkThemes: "Temas oscuros" +syncDeviceDarkMode: "Sincroniza el Modo Oscuro con la configuración del sistema" +drive: "Almacén" +fileName: "Nombre de archivo" +selectFile: "Elegir archivo" +selectFiles: "Elegir archivos" +selectFolder: "Selecciona una carpeta" +selectFolders: "Selecciona carpetas" +renameFile: "Renombrar archivo" +folderName: "Nombre de la carpeta" +createFolder: "Crear carpeta" +renameFolder: "Renombrar carpeta" +deleteFolder: "Borrar carpeta" +addFile: "Agregar archivo" +emptyDrive: "Tu almacén está vacío" +emptyFolder: "La carpeta está vacía" +unableToDelete: "No se puede borrar" +inputNewFileName: "Escribe un nuevo nombre de archivo" +inputNewDescription: "Escribe la nueva descripción" +inputNewFolderName: "Escribe un nuevo nombre de la carpeta" +circularReferenceFolder: "La carpeta de destino es una sub-carpeta de la carpeta que + quieres mover." +hasChildFilesOrFolders: "No se puede borrar esta carpeta. No está vacía." +copyUrl: "Copiar URL" +rename: "Renombrar" +avatar: "Avatar" +banner: "Banner" +nsfw: "Sensible" +whenServerDisconnected: "Cuando se pierda la conexión con el servidor" +disconnectedFromServer: "Se ha perdido la conexión con el servidor" +reload: "Recargar" +doNothing: "Ignorar" +reloadConfirm: "¿Quieres recargar la línea de tiempo?" +watch: "Ver" +unwatch: "Dejar de ver" +accept: "Aceptar" +reject: "Rechazar" +normal: "Normal" +instanceName: "Nombre del servidor" +instanceDescription: "Descripción del servidor" +maintainerName: "Administrador" +maintainerEmail: "Correo del administrador" +tosUrl: "URL de los términos de servicio" +thisYear: "Año" +thisMonth: "Mes" +today: "Hoy" +dayX: "{day}" +monthX: "{month}" +yearX: "{year}" +pages: "Páginas" +integration: "Integraciones" +connectService: "Conectar" +disconnectService: "Desconectar" +enableLocalTimeline: "Habilitar linea de tiempo local" +enableGlobalTimeline: "Habilitar linea de tiempo global" +disablingTimelinesInfo: "Los administradores y moderadores siempre tendrán acceso + a las líneas de tiempo, incluso si están desactivadas." +registration: "Registro" +enableRegistration: "Permitir nuevos registros" +invite: "Invitar" +driveCapacityPerLocalAccount: "Capacidad del almacén por usuario local" +driveCapacityPerRemoteAccount: "Capacidad del almacén por usuario remoto" +inMb: "En megabytes" +iconUrl: "URL del icono" +bannerUrl: "URL del banner" +backgroundImageUrl: "URL de la imagen de fondo" +basicInfo: "Información básica" +pinnedUsers: "Usuarios fijados" +pinnedUsersDescription: "Lista los nombres de usuarios a fijar en la pastaña \"Explorar\"\ + , separados por líneas nuevas." +pinnedPages: "Páginas fijadas" +pinnedPagesDescription: "Escribe las rutas a las páginas que quieres anclar en el + servidor, separadas por líneas nuevas." +pinnedClipId: "ID del clip a fijar" +pinnedNotes: "Notas fijadas" +hcaptcha: "hCaptcha" +enableHcaptcha: "Activar hCaptcha" +hcaptchaSiteKey: "Clave del sitio" +hcaptchaSecretKey: "Clave secreta" +recaptcha: "reCAPTCHA" +enableRecaptcha: "Activar reCAPTCHA" +recaptchaSiteKey: "Clave del sitio" +recaptchaSecretKey: "Clave secreta" +avoidMultiCaptchaConfirm: "El uso de múltiples Captchas puede causar interferencias + entre sí. ¿Quieres desactivar el otro sistema de Captcha? Puedes mantenerlos ambos + habilitados presionando cancelar." +antennas: "Antenas" +manageAntennas: "Administrar antenas" +name: "Nombre" +antennaSource: "Origen de la antena" +antennaKeywords: "Palabras clave para recibir" +antennaExcludeKeywords: "Palabras clave para excluir" +antennaKeywordsDescription: "Separar con espacios es una condición AND, separar con + una linea nueva es una condición OR." +notifyAntenna: "Notificar acerca de nuevas publicaciones" +withFileAntenna: "Sólo publicaciones con archivos adjuntos" +enableServiceworker: "Activar ServiceWorker" +antennaUsersDescription: "Un nombre de usuario por cada línea" +caseSensitive: "Distinguir mayúsculas de minúsculas" +withReplies: "Incluir respuestas" +connectedTo: "Estas cuentas están conectadas" +notesAndReplies: "Notas y respuestas" +withFiles: "Adjuntos" +silence: "Silenciar" +silenceConfirm: "¿Quieres silenciar al usuario?" +unsilence: "Dejar de silenciar" +unsilenceConfirm: "¿Quieres dejar de silenciar al usuario?" +popularUsers: "Usuarios populares" +recentlyUpdatedUsers: "Usuarios activos recientemente" +recentlyRegisteredUsers: "Usuarios registrados recientemente" +recentlyDiscoveredUsers: "Usuarios descubiertos recientemente" +exploreUsersCount: "Hay {count} usuarios" +exploreFediverse: "Explorar el fediverso" +popularTags: "Etiquetas populares" +userList: "Listas" +about: "Información" +aboutIceshrimp: "Sobre Iceshrimp" +administrator: "Administrador" +token: "Token" +twoStepAuthentication: "Autenticación de dos factores" +moderator: "Moderador" +moderation: "Moderación" +nUsersMentioned: "Mencionado por {n} usuarios" +securityKey: "Clave de seguridad" +securityKeyName: "Nombre de la clave" +registerSecurityKey: "Registrar clave de seguridad" +lastUsed: "Última vez usado" +unregister: "Cancelar registro" +passwordLessLogin: "Iniciar sesión sin contraseña" +resetPassword: "Resetear contraseña" +newPasswordIs: "La nueva contraseña es \"{password}\"" +reduceUiAnimation: "Reducir animaciones de la interfaz" +share: "Compartir" +notFound: "No encontrado" +notFoundDescription: "No se encontró la página correspondiente a la URL." +uploadFolder: "Carpeta por defecto para subidas" +cacheClear: "Borrar caché" +markAsReadAllNotifications: "Marcar todas las notificaciones como leídas" +markAsReadAllUnreadNotes: "Marcar todas las notas como leídas" +markAsReadAllTalkMessages: "Marcar todos los mensajes como leídos" +help: "Ayuda" +inputMessageHere: "Escribe el mensaje aquí" +close: "Cerrar" +group: "Grupo" +groups: "Grupos" +createGroup: "Crear grupo" +ownedGroups: "Tus grupos" +joinedGroups: "Grupos a los que te uniste" +invites: "Invitaciones" +groupName: "Nombre del grupo" +members: "Miembros" +transfer: "Transferir" +messagingWithUser: "Chat privado" +messagingWithGroup: "Chat de grupo" +title: "Título" +text: "Texto" +enable: "Activar" +next: "Siguiente" +retype: "otra vez" +noteOf: "Notas de {user}" +inviteToGroup: "Invitar al grupo" +quoteAttached: "Cita añadida" +quoteQuestion: "¿Quieres añadir una cita?" +noMessagesYet: "Aún no hay mensajes" +newMessageExists: "Tienes un mensaje nuevo" +onlyOneFileCanBeAttached: "Solo se puede añadir un archivo al mensaje" +signinRequired: "Por favor, regístrate o inicia sesión para continuar" +invitations: "Invitaciones" +invitationCode: "Código de invitación" +checking: "Comprobando…" +available: "Disponible" +unavailable: "No disponible" +usernameInvalidFormat: "Puedes usar letras, números, puntos y/o barras bajas." +tooShort: "Demasiado corto" +tooLong: "Demasiado largo" +weakPassword: "Contraseña débil" +normalPassword: "Contraseña aceptable" +strongPassword: "Contraseña fuerte" +passwordMatched: "Coincide" +passwordNotMatched: "No coincide" +signinWith: "Inicia sesión con {x}" +signinFailed: "Inicio de sesión fallido. Asegúrate de haber usado el nombre de usuario + y contraseña correctos." +tapSecurityKey: "Toque la clave de seguridad" +or: "O" +language: "Idioma" +uiLanguage: "Idioma de la interfaz" +groupInvited: "Has sido invitado a un grupo" +aboutX: "Acerca de {x}" +useOsNativeEmojis: "Usa los emojis nativos del sistema" +disableDrawer: "No mostrar los menús en cajones" +youHaveNoGroups: "No hay grupos" +joinOrCreateGroup: "Obtén una invitación para unirte a un grupo o crea el tuyo propio." +noHistory: "No hay historial" +signinHistory: "Historial de inicios de sesión" +disableAnimatedMfm: "Deshabilitar MFM con animaciones" +doing: "Procesando…" +category: "Categoría" +tags: "Etiqueta" +docSource: "Fuente del documento" +createAccount: "Crear cuenta" +existingAccount: "Cuenta existente" +regenerate: "Regenerar" +fontSize: "Tamaño de la letra" +noFollowRequests: "No hay peticiones de seguimiento" +openImageInNewTab: "Abrir imagen en nueva pestaña" +dashboard: "Panel de control" +local: "Local" +remote: "Remoto" +total: "Total" +weekOverWeekChanges: "Cambios semanales" +dayOverDayChanges: "Cambios diarios" +appearance: "Apariencia" +clientSettings: "Configuración del cliente" +accountSettings: "Ajustes de cuenta" +promotion: "Promovido" +promote: "Promover" +numberOfDays: "Cantidad de dias" +hideThisNote: "Ocultar esta nota" +showFeaturedNotesInTimeline: "Mostrar notas destacadas en la línea de tiempo" +objectStorage: "Almacenamiento de objetos" +useObjectStorage: "Usar almacenamiento de objetos" +objectStorageBaseUrl: "URL Base" +objectStorageBaseUrlDesc: "URL usada como referencia. Especifica la URL si estás utilizando + un CDN o Proxy.\nPara S3, utiliza 'https://.s3.amazonaws.com', y para + GCS o servicios equivalentes, usa 'https://storage.googleapis.com/', etcétera." +objectStorageBucket: "Depósito" +objectStorageBucketDesc: "Especifique el nombre del depósito utilizado en el servicio + configurado." +objectStoragePrefix: "Prefijo" +objectStoragePrefixDesc: "Los archivos se almacenarán en el directorio de este prefijo." +objectStorageEndpoint: "Destino" +objectStorageEndpointDesc: "Deje esto en blanco si está utilizando AWS S3; de lo contrario, + especifique el punto final como '' o ': ' de acuerdo con la guía + de servicio que va a utilizar." +objectStorageRegion: "Region" +objectStorageRegionDesc: "Especifica una región como 'xx-east-1'. Si tu servicio no + tiene distinción sobre regiones, déjalo en blanco o completa con 'us-east-1'." +objectStorageUseSSL: "Usar SSL" +objectStorageUseSSLDesc: "Desactiva esto si no vas a usar HTTPS para las conexiones + API" +objectStorageUseProxy: "Conectarse con un proxy" +objectStorageUseProxyDesc: "Desactiva esto si no vas a usar un proxy para las conexiones + API" +objectStorageSetPublicRead: "Seleccionar \"public-read\" al subir" +serverLogs: "Registros del servidor" +deleteAll: "Eliminar todos" +showFixedPostForm: "Mostrar el formulario de las entradas encima de la línea de tiempo" +newNoteRecived: "Hay notas nuevas" +sounds: "Sonidos" +listen: "Escuchar" +none: "Ninguna" +showInPage: "Mostrar en la página" +popout: "Popout" +volume: "Volumen" +masterVolume: "Volumen principal" +details: "Detalles" +chooseEmoji: "Elije un emoji" +unableToProcess: "La operación no se puede llevar a cabo" +recentUsed: "Usado recientemente" +install: "Instalar" +uninstall: "Desinstalar" +installedApps: "Aplicaciones autorizadas" +nothing: "No hay nada que ver aqui" +installedDate: "Autorizado el" +lastUsedDate: "Utilizado por última vez el" +state: "Estado" +sort: "Ordenar" +ascendingOrder: "Ascendente" +descendingOrder: "Descendente" +scratchpad: "Scratchpad" +scratchpadDescription: "Scratchpad proporciona un entorno experimental para AiScript. + Puedes escribir, ejecutar y verificar los resultados que interactúan con Iceshrimp." +output: "Salida" +script: "Script" +disablePagesScript: "Deshabilitar AiScript en páginas" +updateRemoteUser: "Actualizar información del usuario remoto" +deleteAllFiles: "Borrar todos los archivos" +deleteAllFilesConfirm: "¿Quieres borrar todos los archivos?" +removeAllFollowing: "Dejar de seguir a todos los usuarios seguidos" +removeAllFollowingDescription: "Ejecutar esto hará que dejes de seguir todas las cuentas + de {host}. Por favor, ejecuta esto si el server, por ejemplo, deja de existir." +userSuspended: "Este usuario ha sido suspendido." +userSilenced: "Este usuario ha sido silenciado." +yourAccountSuspendedTitle: "Esta cuenta ha sido suspendida" +yourAccountSuspendedDescription: "Esta cuenta ha sido suspendida debido a violaciones + de los términos de servicio del servidor o similar. Para más información, póngase + en contacto con el administrador. Por favor, no cree una nueva cuenta." +menu: "Menú" +divider: "Divisor" +addItem: "Añadir elemento" +relays: "Relés" +addRelay: "Añadir relé" +inboxUrl: "Inbox URL" +addedRelays: "Relés añadidos" +serviceworkerInfo: "Se necesita activar para usar las notificaciones push." +deletedNote: "Nota eliminada" +invisibleNote: "Nota oculta" +enableInfiniteScroll: "Cargar más publicaciones automáticamente" +visibility: "Visibilidad" +poll: "Encuesta" +useCw: "Esconder contenido" +enablePlayer: "Abrir reproductor" +disablePlayer: "Cerrar reproductor" +expandTweet: "Expandir tweet" +themeEditor: "Editor de temas" +description: "Descripción" +describeFile: "Añade una descripción" +enterFileDescription: "Introduce un título" +author: "Autor" +leaveConfirm: "Hay modificaciones sin guardar. ¿Quieres descartarlas?" +manage: "Administrar" +plugins: "Plugins" +preferencesBackups: "Copias de seguridad" +deck: "Deck" +undeck: "Quitar Deck" +useBlurEffectForModal: "Usar efecto borroso en modales" +useFullReactionPicker: "Usa el selector a tamaño completo" +width: "Anchura" +height: "Altura" +large: "Grande" +medium: "Mediano" +small: "Pequeño" +generateAccessToken: "Generar token de acceso" +permission: "Permisos" +enableAll: "Activar todo" +disableAll: "Desactivar todo" +tokenRequested: "Permiso de acceso a la cuenta" +pluginTokenRequestedDescription: "Este plugin podrá usar los permisos descritos aquí." +notificationType: "Tipo de notificación" +edit: "Editar" +emailServer: "Servidor de correo" +enableEmail: "Activar el envío de correos electrónicos" +emailConfigInfo: "Usado para confirmar tu email durante el registro o si olvidas tu + contraseña" +email: "Correo" +emailAddress: "Correo electrónico" +smtpConfig: "Configuración del servidor SMTP" +smtpHost: "Dominio" +smtpPort: "Puerto" +smtpUser: "Nombre de usuario" +smtpPass: "Contraseña" +emptyToDisableSmtpAuth: "Deje el nombre del usuario y la contraseña en blanco para + deshabilitar la autenticación SMTP" +smtpSecure: "Usar SSL/TLS implícito en la conexión SMTP" +smtpSecureInfo: "Desactívalo al usar STARTTLS" +testEmail: "Probar el envío" +wordMute: "Silenciar palabras" +regexpError: "Error en la expresión regular" +regexpErrorDescription: "Ocurrió un error en la expresión regular en la linea {line} + de las palabras muteadas {tab}:" +instanceMute: "Servidores silenciados" +userSaysSomething: "{name} dijo algo" +makeActive: "Activar" +display: "Apariencia" +copy: "Copiar" +metrics: "Métricas" +overview: "Resumen" +logs: "Registros" +delayed: "Atrasado" +database: "Base de datos" +channel: "Canales" +create: "Crear" +notificationSetting: "Ajustes de notificaciones" +notificationSettingDesc: "Por favor, elije el tipo de notificación a mostrar." +useGlobalSetting: "Usar ajustes globales" +useGlobalSettingDesc: "Al activarse, se usará la configuración de notificaciones de + la cuenta. Al desactivarse se pueden hacer configuraciones individuales." +other: "Otro" +regenerateLoginToken: "Regenerar token de inicio de sesión" +regenerateLoginTokenDescription: "Regenerar el token usado internamente durante el + inicio de sesión. No siempre es necesario hacerlo. Al regenerarse, se cerrará la + sesión en todos los dispositivos." +setMultipleBySeparatingWithSpace: "Puedes añadir más de uno, separado por espacios." +fileIdOrUrl: "ID del archivo o URL" +behavior: "Comportamiento" +sample: "Muestra" +abuseReports: "Reportes" +reportAbuse: "Reportar" +reportAbuseOf: "Reportar a {name}" +fillAbuseReportDescription: "Escribe los detalles del reporte. Si hay una publicación + en particular, escribe la URL de esta." +abuseReported: "Se ha enviado el reporte. Muchas gracias." +reporter: "Reportador" +reporteeOrigin: "Origen del reportado" +reporterOrigin: "Origen del reportador" +forwardReport: "Transferir reporte a un servidor remoto" +forwardReportIsAnonymous: "En lugar de tu cuenta, una cuenta anónima del sistema será + mostrada como el reportador en el server remoto." +send: "Enviar" +abuseMarkAsResolved: "Marcar reporte como resuelto" +openInNewTab: "Abrir en una Nueva Pestaña" +openInSideView: "Abrir en una vista lateral" +defaultNavigationBehaviour: "Navegación por defecto" +editTheseSettingsMayBreakAccount: "Editar estas configuraciones puede dañar su cuenta." +instanceTicker: "Información de publicaciones de el servidor" +waitingFor: "Esperando a {x}" +random: "Aleatorio" +system: "Sistema" +switchUi: "Cambiar interfaz de usuario" +desktop: "Escritorio" +clip: "Clip" +createNew: "Crear nuevo" +optional: "Opcional" +createNewClip: "Crear clip nuevo" +unclip: "Quitar clip" +confirmToUnclipAlreadyClippedNote: "Esta nota ya está incluida en el clip \"{name}\"\ + . ¿Quieres quitar la nota del clip?" +public: "Público" +i18nInfo: "Iceshrimp está siendo traducido a varios idiomas gracias a voluntarios. + Puedes colaborar en {link}." +manageAccessTokens: "Administrar tokens de acceso" +accountInfo: "Información de la cuenta" +notesCount: "Cantidad de notas" +repliesCount: "Cantidad de respuestas hechas" +renotesCount: "Cantidad de renotas hechas" +repliedCount: "Cantidad de respuestas recibidas" +renotedCount: "Cantidad de renotas recibidas" +followingCount: "Cantidad de seguidos" +followersCount: "Cantidad de seguidores" +sentReactionsCount: "Cantidad de reacciones hechas" +receivedReactionsCount: "Cantidad de reacciones recibidas" +pollVotesCount: "Cantidad de votaciones hechas" +pollVotedCount: "Cantidad de votaciones recibidas" +yes: "Sí" +no: "No" +driveFilesCount: "Cantidad de archivos en el almacén" +driveUsage: "Uso del almacén" +noCrawle: "Rechazar indexación del crawler" +noCrawleDescription: "Pedir a los motores de búsqueda que no indexen tu perfil, publicaciones, + páginas, etc." +lockedAccountInfo: "A menos que configures la visibilidad de tus publicaciones como + \"Sólo seguidores\", serán visibles para cualquiera, incluso si requieres que los + seguidores sean aprobados manualmente." +alwaysMarkSensitive: "Marcar como contenido sensible por defecto" +loadRawImages: "Cargar las imágenes originales en lugar de mostrar las miniaturas" +disableShowingAnimatedImages: "No reproducir imágenes animadas" +verificationEmailSent: "Se ha enviado un correo de confirmación. Por favor, accede + al enlace proporcionado en el correo para completar la verificación." +notSet: "Sin especificar" +emailVerified: "El correo se ha verificado" +noteFavoritesCount: "Número de notas en marcadores" +pageLikesCount: "Número de páginas favoritas" +pageLikedCount: "Número de favoritos de su página" +contact: "Contacto" +useSystemFont: "Utilizar la fuente de letra por defecto del sistema" +clips: "Clips" +experimentalFeatures: "Características experimentales" +developer: "Desarrolladores" +makeExplorable: "Hacer visible la cuenta en \"Explorar\"" +makeExplorableDescription: "Si desactivas esta opción, tu cuenta no aparecerá en la + sección \"Explorar\"." +showGapBetweenNotesInTimeline: "Mostrar un espacio entre notas en la línea de tiempo" +duplicate: "Duplicar" +left: "Izquierda" +center: "Centrar" +wide: "Ancho" +narrow: "Estrecho" +reloadToApplySetting: "Esta configuración sólo se aplicará después de recargar la + página. ¿Recargar ahora?" +needReloadToApply: "Se requiere un reinicio para la aplicar los cambios." +showTitlebar: "Mostrar la barra de título" +clearCache: "Limpiar caché" +onlineUsersCount: "{n} usuarios en línea" +nUsers: "{n} usuarios" +nNotes: "{n} notas" +sendErrorReports: "Envíar informe de errores" +sendErrorReportsDescription: "Si habilitas esta opción, la información detallada de + los errores será compartida con Iceshrimp cuando ocurra un problema, lo que ayudará + a mejorar la calidad de Iceshrimp. \nEsto incluye información como la versión del + sistema operativo, el tipo de navegador que está utilizando y tu historial en Iceshrimp, + entre otros datos." +myTheme: "Mi tema" +backgroundColor: "Color de fondo" +accentColor: "Color de acento" +textColor: "Color de texto" +saveAs: "Guardar como…" +advanced: "Avanzado" +value: "Valores" +createdAt: "Fecha de creación" +updatedAt: "Actualizado" +saveConfirm: "¿Guardar cambios?" +deleteConfirm: "¿Quieres eliminarlo?" +invalidValue: "Valor inválido." +registry: "Registro" +closeAccount: "Cerrar cuenta" +currentVersion: "Versión actual" +latestVersion: "Última versión" +youAreRunningUpToDateClient: "Estas utilizando la versión más reciente del cliente." +newVersionOfClientAvailable: "Hay una nueva versión del cliente disponible." +usageAmount: "Uso" +capacity: "Capacidad" +inUse: "Usado" +editCode: "Editar código" +apply: "Aplicar" +receiveAnnouncementFromInstance: "Recibir notificaciones de la instancia" +emailNotification: "Notificaciones por correo electrónico" +publish: "Publicar" +inChannelSearch: "Buscar en el canal" +useReactionPickerForContextMenu: "Haz clic con el botón derecho para abrir el menu + de reacciones" +typingUsers: "{users} está escribiendo" +jumpToSpecifiedDate: "Saltar a una fecha específica" +showingPastTimeline: "Mostrar líneas de tiempo antiguas" +clear: "Limpiar" +markAllAsRead: "Marcar todo como leído" +goBack: "Deseleccionar" +unlikeConfirm: "¿Quitar como favorito?" +fullView: "Vista completa" +quitFullView: "Quitar vista completa" +addDescription: "Añadir descripción" +userPagePinTip: "Puedes mantener sus publicaciones visibles aquí seleccionando \"\ + Fijar\" en el menú de publicaciones individuales." +notSpecifiedMentionWarning: "Algunas menciones no están incluidas en el destino" +info: "Información" +userInfo: "Información del usuario" +unknown: "Desconocido" +onlineStatus: "En línea" +hideOnlineStatus: "Mostrarse como desconectado" +hideOnlineStatusDescription: "Mostrarse como desconectado puede reducir la eficacia + de algunas funciones, como la búsqueda." +online: "En línea" +active: "Activo" +offline: "Sin conexión" +notRecommended: "No recomendado" +botProtection: "Protección contra bots" +instanceBlocking: "Gestionar federación" +selectAccount: "Elige una cuenta" +switchAccount: "Cambiar de cuenta" +enabled: "Activado" +disabled: "Desactivado" +quickAction: "Acciones rápidas" +user: "Usuario" +administration: "Administrar" +accounts: "Cuentas" +switch: "Cambiar" +noMaintainerInformationWarning: "Información de administrador no configurada." +noBotProtectionWarning: "Protección contra bots no configurada." +configure: "Configurar" +postToGallery: "Crear una nueva publicación en la galería" +gallery: "Galería" +recentPosts: "Páginas recientes" +popularPosts: "Páginas populares" +shareWithNote: "Compartir en una nota" +ads: "Anuncios" +expiration: "Termina el" +memo: "Memo" +priority: "Prioridad" +high: "Alta" +middle: "Media" +low: "Baja" +emailNotConfiguredWarning: "Correo electrónico no configurado." +ratio: "Proporción" +previewNoteText: "Vista previa" +customCss: "CSS personalizado" +customCssWarn: "Este ajuste sólo debe utilizarse si sabes lo que hace. Introducir + valores inadecuados puede hacer que el cliente deje de funcionar con normalidad." +global: "Global" +squareAvatars: "Mostrar avatares cuadrados" +sent: "Enviado" +received: "Recibido" +searchResult: "Resultados de búsqueda" +hashtags: "Hashtags" +troubleshooting: "Solución de problemas" +useBlurEffect: "Utilizar efecto de desenfoque en la interfaz" +learnMore: "Ver más" +iceshrimpUpdated: "¡Iceshrimp ha sido actualizado!" +whatIsNew: "Mostrar cambios" +translate: "Traducir" +translatedFrom: "Traducido de {x}" +accountDeletionInProgress: "Eliminación de la cuenta en progreso" +usernameInfo: "Un nombre que identifique tu cuenta de otras en este servidor. Puedes + utilizar el alfabeto (a~z, A~Z), números (0~9) o barras bajas (_). Los nombres de + usuario son permanentes y no pueden ser cambiados." +aiChanMode: "Ai-chan en la interfaz clásica" +keepCw: "Mantener la advertencia de contenido" +pubSub: "Cuentas Pub/Sub" +lastCommunication: "Última comunicación" +resolved: "Resuelto" +unresolved: "Sin resolver" +breakFollow: "Eliminar seguidor" +itsOn: "Encendido" +itsOff: "Apagado" +emailRequiredForSignup: "Requerir una dirección de email para registrarse" +unread: "No leído" +filter: "Filtro" +controlPanel: "Panel de control" +manageAccounts: "Administrar cuentas" +makeReactionsPublic: "Hacer el historial de reacciones público" +makeReactionsPublicDescription: "Todas las reacciones que hayas hecho serán públicamente + visibles." +classic: "Centrado" +muteThread: "Silenciar hilo" +unmuteThread: "Mostrar hilo" +ffVisibility: "Visibilidad de seguidores y seguidos" +ffVisibilityDescription: "Te permite configurar quien puede ver a quienes sigues y + quienes te siguen." +continueThread: "Continuar hilo" +deleteAccountConfirm: "La cuenta será eliminada. ¿Estas seguro?" +incorrectPassword: "Contraseña incorrecta." +voteConfirm: "¿Confirmas el voto a {choice}?" +hide: "Ocultar" +leaveGroup: "Dejar el grupo" +leaveGroupConfirm: "¿Quieres salir de {name}?" +useDrawerReactionPickerForMobile: "Mostrar panel de reacciones como un cajón en móviles" +clickToFinishEmailVerification: "Pulsa [{ok}] para completar la verificación." +overridedDeviceKind: "Tipo de dispositivo" +smartphone: "Smartphone" +tablet: "Tablet" +auto: "Automático" +themeColor: "Color del tema" +size: "Tamaño" +numberOfColumn: "Cantidad de columnas" +searchByGoogle: "Buscar" +instanceDefaultLightTheme: "Tema claro por defecto de la instancia" +instanceDefaultDarkTheme: "Tema oscuro por defecto de la instancia" +instanceDefaultThemeDescription: "Inserta el código del tema en formato JSON." +mutePeriod: "Duración del silencio" +indefinitely: "Indefinidamente" +tenMinutes: "10 minutos" +oneHour: "1 hora" +oneDay: "1 día" +oneWeek: "1 semana" +reflectMayTakeTime: "Puede pasar un tiempo hasta que se reflejen los cambios." +failedToFetchAccountInformation: "No se pudo obtener la información de la cuenta" +rateLimitExceeded: "Se excedió el límite de peticiones" +cropImage: "Recortar imagen" +cropImageAsk: "¿Quieres recortar la imagen?" +file: "Archivos" +recentNHours: "Últimas {n} horas" +recentNDays: "Últimos {n} días" +noEmailServerWarning: "Servidor de correo no configurado." +thereIsUnresolvedAbuseReportWarning: "Hay reportes sin resolver." +recommended: "Recomendado" +check: "Verificar" +driveCapOverrideLabel: "Cambiar la capacidad del almacén para este usuario" +driveCapOverrideCaption: "Restablece la capacidad a su predeterminado usando un valor + de 0 o menos." +requireAdminForView: "Necesitas iniciar sesión como administrador para ver esto." +isSystemAccount: "Cuenta creada y operada automáticamente por el sistema. Por favor, + no moderes, edites, elimines o modifiques esta cuenta, o podría romper tu servidor." +typeToConfirm: "Escribe {x} para confirmar" +deleteAccount: "Borrar cuenta" +document: "Documentación" +numberOfPageCache: "Cantidad de páginas en caché" +numberOfPageCacheDescription: "Incrementar este número mejora la conveniencia pero + tambien puede aumentar la carga y la memoria que se usa." +logoutConfirm: "¿Cerrar sesión?" +lastActiveDate: "Utilizado por última vez el" +statusbar: "Barra de estado" +pleaseSelect: "Selecciona una opción" +reverse: "Invertir" +colored: "Color" +refreshInterval: "Intervalo de actualización" +label: "Etiqueta" +type: "Tipo" +speed: "Velocidad" +slow: "Lento" +fast: "Rápido" +sensitiveMediaDetection: "Detección de contenido sensible" +localOnly: "Sólo local" +remoteOnly: "Sólo remoto" +failedToUpload: "La subida falló" +cannotUploadBecauseInappropriate: "Este archivo no se puede subir debido a que algunas + partes han sido detectadas como potencialmente sensibles." +cannotUploadBecauseNoFreeSpace: "La subida falló debido a falta de espacio libre en + el almacén del usuario." +beta: "Beta" +enableAutoSensitive: "Marcar automáticamente contenido sensible" +enableAutoSensitiveDescription: "Permite la detección y marcado automático de contenido + sensible usando 'Machine Learning' cuando sea posible. Incluso si esta opción está + desactivada, puede ser activado para toda la instancia." +activeEmailValidationDescription: "Habilita la validación estricta de direcciones + de correo electrónico, lo cual incluye la revisión de direcciones desechables y + si se puede comunicar con estas. Cuando está deshabilitado, sólo el formato de la + dirección es validado." +navbar: "Barra de navegación" +shuffle: "Aleatorio" +account: "Cuenta" +move: "Mover" +_sensitiveMediaDetection: + description: "Reduce el esfuerzo de la moderación del servidor a través del reconocimiento + automático de contenido sensible usando 'Machine Learning'. Esto puede incrementar + ligeramente la carga en el servidor." + sensitivity: "Sensibilidad de detección" + sensitivityDescription: "Reducir la sensibilidad puede acarrear a varios falsos + positivos, mientras que incrementarla puede reducir las detecciones (falsos negativos)." + setSensitiveFlagAutomatically: "Marcar como sensible" + setSensitiveFlagAutomaticallyDescription: "Los resultados de la detección interna + pueden ser retenidos incluso si la opción está desactivada." + analyzeVideos: "Habilitar el análisis de videos" + analyzeVideosDescription: "Analizar videos en adición a las imágenes. Esto puede + incrementar ligeramente la carga del servidor." +_emailUnavailable: + used: "Este email ya está en uso" + format: "El formato de este correo electrónico no es válido" + disposable: "No se pueden utilizar direcciones de correo electrónico desechables" + mx: "Servidor de correo inválido" + smtp: "Servidor de correo no disponible" +_ffVisibility: + public: "Público" + followers: "Visible solo para seguidores" + private: "Privado" +_signup: + almostThere: "Ya falta poco" + emailAddressInfo: "Introduce tu dirección de email. No se hará pública." + emailSent: "Se envió un correo de verificación a la dirección {email}. Accede al + link enviado en el correo para completar el ingreso." +_accountDelete: + accountDelete: "Eliminar cuenta" + mayTakeTime: "La eliminación de la cuenta es un proceso que requiere recursos. Puede + pasar un tiempo hasta que se complete, dependiendo del contenido creado y los + archivos subidos." + sendEmail: "Cuando se termine de borrar la cuenta, se enviará un correo a la dirección + usada para el registro." + requestAccountDelete: "Pedir la eliminación de la cuenta" + started: "El proceso de eliminación ha comenzado." + inProgress: "La eliminación está en proceso" +_ad: + back: "Deseleccionar" + reduceFrequencyOfThisAd: "Mostrar menos este anuncio" +_forgotPassword: + enterEmail: "Introduce el correo usado para registrar la cuenta. Se enviará un enlace + para reiniciar la contraseña." + ifNoEmail: "Si no utilizó un correo para crear la cuenta, ponte en contacto con + el administrador." + contactAdmin: "Esta instancia no admite el uso de direcciones de correo electrónico, + ponte en contacto con el administrador de la instancia para restablecer tu contraseña." +_gallery: + my: "Mi galería" + liked: "Publicaciones que me gustan" + like: "Me gusta" + unlike: "Ya no me gusta" +_email: + _follow: + title: "Tienes un seguidor nuevo" + _receiveFollowRequest: + title: "Has recibido una petición de seguimiento" +_plugin: + install: "Instalar plugins" + installWarn: "Por favor, no instale plugins que no son de confianza." + manage: "Gestionar plugins" +_preferencesBackups: + list: "Copias de seguridad creadas" + saveNew: "Guardar nueva copia de seguridad" + loadFile: "Cargar desde archivo" + apply: "Aplicar a este dispositivo" + save: "Guardar cambios" + inputName: "Por favor, introduce un nombre para esta copia de seguridad" + cannotSave: "Fallo al guardar" + nameAlreadyExists: "Una copia de seguridad llamada \"{name}\" ya existe. Por favor, + elige un nombre diferente." + applyConfirm: "¿Estás seguro de querer aplicar la copia de seguridad \"{name}\" + a este dispositivo? Las configuraciones existentes serán sobreescritas." + saveConfirm: "¿Guardar copia de seguridad como \"{name}\"?" + deleteConfirm: "¿Borrar la copia de seguridad \"{name}\"?" + renameConfirm: "¿Renombrar esta copia de seguridad de \"{old}\" a \"{new}\"?" + noBackups: "No existen copias de seguridad. Deberás asegurar las configuraciones + del cliente en este servidor usando \"Crear nueva copia de seguridad\"." + createdAt: "Creado en: {date} {time}" + updatedAt: "Actualizado el: {date} {time}" + cannotLoad: "La carga falló" + invalidFile: "Formato de archivo inválido" + delete: Borrar copia de seguridad +_registry: + scope: "Alcance" + key: "Clave" + keys: "Claves" + domain: "Dominio" + createKey: "Crear clave" +_aboutIceshrimp: + about: "Iceshrimp es una bifurcación de Misskey, trayéndote arreglos sin-sinsentidos, + funciones y mejoras que realmente quieres desde 2023." + contributors: "Principales colaboradores" + allContributors: "Todos los colaboradores" + source: "Desarrollo de Iceshrimp" + translation: "Traducir Iceshrimp" + donate: "Donar a Iceshrimp" + morePatrons: "También apreciamos el apoyo de muchos más que no están listados aquí. + ¡Gracias! 🥰" + patrons: "Mecenas de Iceshrimp" + chatroom: Sala de chat + documentation: Documentación + roadmap: Hoja de ruta + changelog: Registro de cambios + donateTitle: ¿Te gusta Iceshrimp? + pleaseDonateToIceshrimp: Por favor, considera donar a Iceshrimp para apoyar su desarrollo. + pleaseDonateToHost: Considera tambien donar a tu servidor, {host}, para ayudar con + los costes de operar. + donateHost: Donar a {host} + sponsors: Sponsors de Iceshrimp + patronsList: Listados cronológicamente, no por cantidad donada. ¡Dona con el enlace + arriba para tener tu nombre aquí! +_nsfw: + respect: "Ocultar multimedia sensible" + ignore: "No ocultar multimedia sensible" + force: "Ocultar toda la multimedia" +_mfm: + cheatSheet: "Hoja de referencia de MFM" + intro: "MFM es un lenguaje de Markdown utilizado en Iceshrimp, Misskey, Akkoma y + demás que puede ser utilizado en muchos lugares. Aquí puedes ver una lista de + toda la sintaxis MFM disponible." + dummy: "Iceshrimp expande el mundo del Fediverso" + mention: "Menciones" + mentionDescription: "Puedes especificar un usuario usando @usuario." + hashtag: "Hashtag" + hashtagDescription: "Puede especificar un hashtag con una almohadilla y el texto." + url: "URL" + urlDescription: "Se pueden mostrar las URL." + link: "Enlace" + linkDescription: "Partes del texto pueden mostrarse como una URL." + bold: "Negrita" + boldDescription: "Muestra el texto con letras más gruesas." + small: "Pequeño" + smallDescription: "Muestra el texto más pequeño y delgado." + center: "Centrar" + centerDescription: "Muestra el texto centrado." + inlineCode: "Código (insertado)" + inlineCodeDescription: "Muestra el código de un programa resaltando su sintaxis." + blockCode: "Código (bloque)" + blockCodeDescription: "Código de resaltado de sintaxis, como programas de varias + líneas con bloques." + inlineMath: "Fórmula (insertado)" + inlineMathDescription: "Muestra fórmulas (KaTeX) insertadas" + blockMath: "Fórmula (bloque)" + blockMathDescription: "Muestra fórmulas (KaTeX) de varias líneas en un bloque" + quote: "Citar" + quoteDescription: "Muestra el contenido como una cita." + emoji: "Emojis personalizados" + emojiDescription: "Muestra los emojis personalizados encerrados entre dos puntos." + search: "Buscar" + searchDescription: "Muestra una caja de búsqueda con texto pre-escrito." + flip: "Girar" + flipDescription: "Gira el contenido hacia arriba / abajo o hacia la izquierda / + derecha." + jelly: "Animación (gelatina)" + jellyDescription: "Aplica un efecto de animación tipo gelatina." + tada: "Animación (tadá)" + tadaDescription: "Aplica un efecto de animación al estilo \"Tadá\"." + jump: "Animación (saltar)" + jumpDescription: "Aplica un efecto de animación tipo salto." + bounce: "Animación (rebotar)" + bounceDescription: "Aplica un efecto de animación tipo rebote." + shake: "Animación (temblor)" + shakeDescription: "Aplica un efecto de animación tipo temblor." + twitch: "Animación (sacudida)" + twitchDescription: "Aplica un efecto de animación tipo sacudida." + spin: "Animación (giro)" + spinDescription: "Aplica un efecto de animación tipo rotación." + x2: "Grande" + x2Description: "Muestra el contenido más grande." + x3: "Muy grande" + x3Description: "Muestra el contenido mucho más grande." + x4: "Totalmente grande" + x4Description: "Muestra el contenido totalmente grande." + blur: "Desenfoque" + blurDescription: "Para desenfocar el contenido. Se muestra claramente al colocar + el puntero encima." + font: "Fuente" + fontDescription: "Elegir la fuente del contenido." + rainbow: "Arcoíris" + rainbowDescription: "Muestra el contenido con los colores del arcoíris." + sparkle: "Parpadeante" + sparkleDescription: "Aplica un efecto de partículas parpadeantes." + rotate: "Rotar" + rotateDescription: "Rota el contenido a un ángulo especificado." + plain: "Plano" + plainDescription: "Desactiva los efectos de todo el contenido MFM con este efecto + MFM." + position: Posición + scaleDescription: Escala el contenido por una cantidad específica. + play: Reproducir MFM + stop: Parar MFM + warn: MFM podría contener animaciones muy rápidas o parpadeantes + alwaysPlay: Reproducir siempre los MFM animados + advanced: MFM avanzado + advancedDescription: Si se desactiva, solo permitirá marcado básico a no ser que + una animación MFM se reproduzca + fade: Difuminado + fadeDescription: Difumina el contenido. + positionDescription: Mueve el contenido una cantidad específica. + crop: Cortar + cropDescription: Cortar contenido. + scale: Escalar + foreground: Color de frente + foregroundDescription: Cambia el color de frente del texto. + background: Color de fondo + backgroundDescription: Cambia el color de fondo del texto. + borderDescription: Añade un borde alrededor del contenido. + rubyDescription: Añade una pequeña anotación encima del texto, usualmente usado + para mostrar pronunciaciones de caracteres asiáticos. + border: Borde + ruby: Rubí + unixtime: Tiempo Unix + unixtimeDescription: Convierte un número de segundos desde el 1 de Enero de 1970 + a una fecha entendible. + followmouse: Seguir el ratón + followmouseDescription: Hacer que el contenido siga al cursor del ratón. + followmouseToggle: Vista previa +_instanceTicker: + none: "No mostrar nunca" + remote: "Mostrar a usuarios remotos" + always: "Mostrar siempre" +_serverDisconnectedBehavior: + reload: "Recargar automáticamente" + dialog: "Mostrar diálogo de advertencia" + quiet: "Advertencia discreta" + nothing: No hacer nada +_channel: + create: "Crear canal" + edit: "Editar canal" + setBanner: "Elegir banner" + removeBanner: "Borrar banner" + featured: "Tendencias" + owned: "Dueño" + following: "Siguiendo" + usersCount: "{n} participantes" + notesCount: "{n} notas" + nameOnly: Sólo nombre + nameAndDescription: Nombre y descripción +_menuDisplay: + sideFull: "Horizontal" + sideIcon: "Horizontal (iconos)" + top: "Arriba" + hide: "Ocultar" +_wordMute: + muteWords: "Palabras silenciadas" + muteWordsDescription: "Separar con espacios indica una condición AND, separar con + nuevas lineas indica una condición OR." + muteWordsDescription2: "Encerrar las palabras clave entre numerales para usar expresiones + regulares." + softDescription: "Ocultar en la linea de tiempo las publicaciones que cumplen las + condiciones." + hardDescription: "Evita que las publicaciones que cumplan las condiciones se añadan + a la línea de tiempo. Además, estas publicaciones no serán añadidas a la línea + de tiempo incluso si las condiciones cambian." + soft: "Suave" + hard: "Duro" + mutedNotes: "Notas silenciadas" +_instanceMute: + instanceMuteDescription: "Esto silenciará todas las publicaciones/impulsos de los + servidores listados, inclutendo los usuarios respondiendo a un usuario de un servidor + silenciado." + instanceMuteDescription2: "Separar por líneas" + title: "Oculta las publicaciones de las instancias listadas." + heading: "Lista de servidores a silenciar" +_theme: + explore: "Explorar temas" + install: "Instalar un tema" + manage: "Gestor de temas" + code: "Código del tema" + description: "Descripción" + installed: "{name} ha sido instalado" + installedThemes: "Temas instalados" + builtinThemes: "Temas integrados" + alreadyInstalled: "Este tema ya está instalado" + invalid: "El formato del tema no es válido" + make: "Crear tema" + base: "Base" + addConstant: "Añadir constante" + constant: "Constante" + defaultValue: "Valor predeterminado" + color: "Color" + refProp: "Hacer referencia a propiedad" + refConst: "Hacer referencia a constante" + key: "Clave" + func: "Funciones" + funcKind: "Tipo de función" + argument: "Argumento" + basedProp: "Nombre de la propiedad referenciada" + alpha: "Opacidad" + darken: "Oscurecer" + lighten: "Enclarecer" + inputConstantName: "Por favor, introduce el nombre de la constante" + importInfo: "Si introduces el código del tema aquí, puedes importarlo al editor" + deleteConstantConfirm: "¿Quieres borrar la constante {const}?" + keys: + accent: "Acento" + bg: "Fondo" + fg: "Texto" + focus: "Enfoque" + indicator: "Indicador" + panel: "Panel" + shadow: "Sombra" + header: "Cabezal" + navBg: "Fondo de la barra lateral" + navFg: "Texto de la barra lateral" + navHoverFg: "Texto de la barra lateral (hover)" + navActive: "Texto de la barra lateral (activo)" + navIndicator: "Indicador de la barra lateral" + link: "Enlace" + hashtag: "Hashtag" + mention: "Mención" + mentionMe: "Menciones (yo)" + renote: "Renotar" + modalBg: "Fondo modal" + divider: "Divisor" + scrollbarHandle: "Cuadro de la barra de desplazamiento" + scrollbarHandleHover: "Cuadro de la barra de desplazamiento (hover)" + dateLabelFg: "Texto de la etiqueta de fecha" + infoBg: "Fondo de información" + infoFg: "Texto de información" + infoWarnBg: "Fondo de advertencias" + infoWarnFg: "Texto de advertencias" + cwBg: "Fondo del botón de aviso de contenido" + cwFg: "Texto del botón aviso de contenido" + cwHoverBg: "Fondo del botón de aviso de contenido (hover)" + toastBg: "Fondo de notificaciones" + toastFg: "Texto de notificaciones" + buttonBg: "Fondo de botón" + buttonHoverBg: "Fondo de botón (hover)" + inputBorder: "Borde de los campos de entrada" + listItemHoverBg: "Fondo de elemento de listas (hover)" + driveFolderBg: "Fondo de capeta del almacén" + wallpaperOverlay: "Transparencia del fondo de pantalla" + badge: "Medalla" + messageBg: "Fondo de chat" + accentDarken: "Acento (oscuro)" + accentLighten: "Acento (claro)" + fgHighlighted: "Texto resaltado" +_sfx: + note: "Nota nueva" + noteMy: "Nota (a mí mism@)" + notification: "Notificaciones" + chat: "Chat" + chatBg: "Chat (Fondo)" + antenna: "Antenas" + channel: "Notificaciones del canal" +_ago: + future: "Futuro" + justNow: "Justo ahora" + secondsAgo: "Hace {n} segundos" + minutesAgo: "Hace {n} minut {n2} segundos" + hoursAgo: "Hace {n} hora {n2} minutos" + daysAgo: "Hace {n} dí {n2} horas" + weeksAgo: "Hace {n} seman {n2} días" + monthsAgo: "Hace {n} mese {n2} semanas" + yearsAgo: "Hace {n} año {n2} meses" +_time: + second: "Segundos" + minute: "Minutos" + hour: "Horas" + day: "Días" +_tutorial: + title: "Cómo usar Iceshrimp" + step1_1: "¡Bienvenido!" + step1_2: "Vamos a configurar tu cuenta. ¡Estarás en marcha enseguida!" + step2_1: "En primer lugar, rellena tu perfil." + step2_2: "Dar algo de información sobre quién eres hará que sea más fácil para los + demás saber si quieren ver tus publicaciones o seguirte." + step3_1: "¡Ahora es el momento de seguir a algunas personas!" + step3_2: "Tu página de inicio y tus líneas de tiempo sociales se basan en quién + sigues, así que intenta seguir un par de cuentas para empezar.\nHaz clic en el + círculo más en la parte superior derecha de un perfil para seguirlos." + step4_1: "Salgamos ahí fuera." + step4_2: "Para tu primera publicación, a algunas personas les gusta hacer un post + de #introducción o un simple \"¡Hola mundo!\"" + step5_1: "¡Líneas de tiempo, líneas de tiempo por todas partes!" + step5_2: "Tu instancia tiene {timelines} diferentes líneas de tiempo habilitadas." + step5_3: "La línea de tiempo Inicio {icon} es donde puedes ver las publicaciones + de tus seguidores." + step5_4: "La línea de tiempo Local {icon} es donde puedes ver las publicaciones + de todos los demás en esta instancia." + step5_5: "La línea de tiempo {icon} recomendada es donde puedes ver las publicaciones + de las instancias que los administradores recomiendan." + step5_6: "La línea de tiempo Social {icon} es donde puedes ver las publicaciones + de los amigos de tus seguidores." + step5_7: "La línea de tiempo Global {icon} es donde puedes ver las publicaciones + de todas las demás instancias conectadas (tambien conocida como Federada)." + step6_1: "Entonces, ¿qué es este sitio?" + step6_2: "Bueno, no sólo te has unido a Iceshrimp. Te has unido a un portal del + Fediverso, una red interconectada de miles de servidores, llamadas \"instancias\"\ + ." + step6_3: "Cada servidor funciona de forma diferente, y no todos los servidores ejecutan + Iceshrimp. Sin embargo, ¡éste lo hace! Es un poco complicado, pero le cogerás + el tranquillo enseguida." + step6_4: "¡Ahora ve, explora y diviértete!" +_2fa: + alreadyRegistered: "Ya has registrado un dispositivo de autenticación de dos factores." + registerTOTP: "Registrar dispositivo" + registerSecurityKey: "Registrar clave" + step1: "Primero, instala en su dispositivo una aplicación de autenticación (como + por ejemplo {a} o {b})." + step2: "Luego, escanea con la aplicación el código QR mostrado en pantalla." + step2Url: "En una aplicación de escritorio se puede introducir la siguiente URL:" + step3: "Para terminar, introduce el token mostrado en la aplicación." + step4: "A partir de ahora cuando inicies sesión, se te pedirá un token como ese." + securityKeyInfo: "Se puede configurar el inicio de sesión usando una clave de seguridad + de hardware que soporte FIDO2 o con un certificado de huella digital o con un + PIN." + step2Click: Hacer click en este código QR the permitirá registrar tu autenticación + de dos factores a tu llave de seguridad o la app de autenticación de tu teléfono. + securityKeyName: Introduce un nombre para la clave + step3Title: Introduce un código de autenticación + securityKeyNotSupported: Tu navegador no soporta llaves de seguridad. + registerTOTPBeforeKey: Por favor, configura una app de autenticación para registrar + una clave de seguridad. + chromePasskeyNotSupported: Las passkeys de Chrome no son soportadas por el momento. + tapSecurityKey: Por favor, sigue tu navegador para registrar la clave de seguridad + removeKey: Eliminar clave de seguridad + removeKeyConfirm: ¿Seguro de querer eliminar la clave {name}? + whyTOTPOnlyRenew: La app de autenticación no se puede eliminar mientras hayan claves + de seguridad registradas. + renewTOTP: Reconfigurar app de autenticación + renewTOTPConfirm: Esto causará que los códigos de verificación previos dejen de + funcionar + renewTOTPOk: Reconfigurar + renewTOTPCancel: Cancelar + token: Token 2FA +_permissions: + "read:account": "Ver información de la cuenta" + "write:account": "Editar información de la cuenta" + "read:blocks": "Ver usuarios bloqueados" + "write:blocks": "Administrar usuarios bloqueados" + "read:drive": "Ver el almacén" + "write:drive": "Administrar el almacén" + "read:favorites": "Ver lista de marcadores" + "write:favorites": "Addministrar marcadores" + "read:following": "Ver información de seguidos" + "write:following": "Seguir o dejar de seguir cuentas" + "read:messaging": "Ver tus chats" + "write:messaging": "Administrar chat" + "read:mutes": "Ver usuarios silenciados" + "write:mutes": "Administrar usuarios silenciados" + "write:notes": "Crear/borrar notas" + "read:notifications": "Ver notificaciones" + "write:notifications": "Administrar notificaciones" + "read:reactions": "Ver reacciones" + "write:reactions": "Administrar reacciones" + "write:votes": "Votar" + "read:pages": "Ver páginas" + "write:pages": "Administrar páginas" + "read:page-likes": "Ver páginas que te gustan" + "write:page-likes": "Administrar páginas que te gustan" + "read:user-groups": "Ver grupos de usuarios" + "write:user-groups": "Administrar grupos de usuarios" + "read:channels": "Ver tus canales" + "write:channels": "Administrar tus canales" + "read:gallery": "Ver tu galería" + "write:gallery": "Editar tu galería" + "read:gallery-likes": "Ver favoritos de la galería" + "write:gallery-likes": "Editar favoritos de la galería" +_auth: + shareAccess: "¿Quieres permitir a \"{name}\" el acceso a esta cuenta?" + shareAccessAsk: "¿Estás seguro de que quieres autorizar esta aplicación a acceder + a tu cuenta?" + permissionAsk: "Esta aplicación solicita los siguientes permisos:" + pleaseGoBack: "Por favor, vuelve a la aplicación" + callback: "Volviendo a la aplicación" + denied: "Acceso denegado" + signedInAs: Sesión iniciada como + copyAsk: 'Por favor, pega el siguiente código de autorización en la aplicación:' + allPermissions: Acceso completo a la cuenta + authRequired: Autorización requerida +_antennaSources: + all: "Todas las publicaciones" + homeTimeline: "Publicaciones de los usuarios que sigues" + users: "Publicaciones de usarios específicos" + userList: "Publicaciones de los usuarios de una lista" + userGroup: "Publicaciones de los usuarios de un grupo" + instances: Publicaciones de todos los usuarios de un servidor +_weekday: + sunday: "Domingo" + monday: "Lunes" + tuesday: "Martes" + wednesday: "Miércoles" + thursday: "Jueves" + friday: "Viernes" + saturday: "Sábado" +_widgets: + memo: "Notas Adhesivas" + notifications: "Notificaciones" + timeline: "Linea de tiempo" + calendar: "Calendario" + trends: "Tendencias" + clock: "Reloj" + rss: "Lector RSS" + rssTicker: "Ticker RSS" + activity: "Actividad" + photos: "Fotos" + digitalClock: "Reloj digital" + unixClock: "Reloj UNIX" + federation: "Federación" + postForm: "Formulario" + slideshow: "Diapositivas" + button: "Botón" + onlineUsers: "Usuarios en linea" + jobQueue: "Cola de trabajos" + serverMetric: "Estadísticas del servidor" + aiscript: "Consola de AiScript" + aichan: "indigo" + userList: Lista de usuarios + _userList: + chooseList: Selecciona una lista + serverInfo: Información del servidor + meiliStatus: Estado del servidor + meiliSize: Tamaño del index + meiliIndexCount: Publicaciones indexadas +_cw: + hide: "Ocultar" + show: "Ver más" + chars: "{count} carácteres" + files: "{count} archivo(s)" +_poll: + noOnlyOneChoice: "Se necesitan al menos 2 opciones" + choiceN: "Opción {n}" + noMore: "No se pueden añadir más" + canMultipleVote: "Permitir más de una respuesta" + expiration: "Termina el" + infinite: "Sin límite de tiempo" + at: "Termina el…" + after: "Termina después de…" + deadlineDate: "Fecha de fin" + deadlineTime: "Tiempo" + duration: "Duración" + votesCount: "{n} votos" + totalVotes: "{n} votos en total" + vote: "Votar" + showResult: "Ver resultados" + voted: "Votado" + closed: "Cerrada" + remainingDays: "Quedan {d} días y {h} horas para que finalice" + remainingHours: "Quedan {h} horas y {m} minutos para que finalice" + remainingMinutes: "Quedan {m} minutos y {s} segundos para que finalice" + remainingSeconds: "Quedan {s} segundos para que finalice" +_visibility: + public: "Público" + publicDescription: "Visible para todos los usuarios" + home: "No listado" + homeDescription: "Visible sólo en la linea de tiempo de inicio" + followers: "Sólo seguidores" + followersDescription: "Visible sólo para tus seguidores" + specified: "Mensaje directo" + specifiedDescription: "Visible sólo para los usuarios elegidos" + localOnly: "Sólo local" + localOnlyDescription: "Oculto para usuarios remotos" +_postForm: + replyPlaceholder: "Responder a esta publicación…" + quotePlaceholder: "Citar esta publicación…" + channelPlaceholder: "Publicar en el canal…" + _placeholders: + a: "¿Qué haces?" + b: "¿Qué ocurre a tu alrededor?" + c: "¿Qué estás pensando?" + d: "¿Algo que quieras decir?" + e: "Escribe aquí…" + f: "Esperando a que escribas algo…" +_profile: + name: "Nombre" + username: "Nombre de usuario" + description: "Descripción" + youCanIncludeHashtags: "Puedes añadir hashtags en tu descripción." + metadata: "Información adicional" + metadataEdit: "Editar información adicional" + metadataDescription: "Muestra la información adicional en el perfil. ¡Puedes añadir + una etiqueta {a} o una etiqueta {l} con {rel} para verificar el enlace en tu perfil!" + metadataLabel: "Etiqueta" + metadataContent: "Contenido" + changeAvatar: "Cambiar avatar" + changeBanner: "Cambiar banner" + locationDescription: Si introduces tu ciudad primero, tu hora local será visible + para otros usuarios. +_exportOrImport: + allNotes: "Todas las notas" + followingList: "Siguiendo" + muteList: "Silenciados" + blockingList: "Bloqueados" + userLists: "Listas" + excludeMutingUsers: "Excluir usuarios silenciados" + excludeInactiveUsers: "Excluir usuarios inactivos" +_charts: + federation: "Federación" + apRequest: "Pedidos" + usersIncDec: "Variación de usuarios" + usersTotal: "Total de usuarios" + activeUsers: "Cantidad de usuarios activos" + notesIncDec: "Variación de la cantidad de notas" + localNotesIncDec: "Variación de la cantidad de notas locales" + remoteNotesIncDec: "Variación de la cantidad de notas remotas" + notesTotal: "Total de notas" + filesIncDec: "Variación de cantidad de archivos" + filesTotal: "Total de archivos" + storageUsageIncDec: "Variación de uso del almacenamiento" + storageUsageTotal: "Total de uso del almacenamiento" +_instanceCharts: + requests: "Pedidos" + users: "Variación de usuarios" + usersTotal: "Total acumulado de usuarios" + notes: "Variación de la cantidad de notas" + notesTotal: "Total acumulado de la cantidad de notas" + ff: "Variación de cantidad de seguidos/seguidores " + ffTotal: "Total acumulado de cantidad de seguidos/seguidores" + cacheSize: "Variación del tamaño de la caché" + cacheSizeTotal: "Total acumulado del tamaño de la caché" + files: "Variación de cantidad de archivos" + filesTotal: "Total acumulado de cantidad de archivos" +_timelines: + home: "Inicio" + local: "Local" + social: "Social" + global: "Global" + recommended: Recomendado +_pages: + newPage: "Crear página" + editPage: "Editar página" + readPage: "Viendo la fuente" + created: "Página creada con éxito" + updated: "La página fue actualizada" + deleted: "Página eliminada con éxito" + pageSetting: "Configurar página" + nameAlreadyExists: "La URL de la página especificada ya existe" + invalidNameTitle: "URL inválida" + invalidNameText: "Verifica que no tenga espacios en blanco" + editThisPage: "Editar esta página" + viewSource: "Ver la fuente" + viewPage: "Ver página" + like: "Me gusta" + unlike: "Ya no me gusta" + my: "Mis páginas" + liked: "Páginas que me gustan" + featured: "Popular" + inspector: "Inspector" + contents: "Contenido" + content: "Bloque de página" + variables: "Variables" + title: "Título" + url: "URL de la página" + summary: "Resumen de la página" + alignCenter: "Centrar" + hideTitleWhenPinned: "Ocultar el título de la página al fijarse" + font: "Fuente" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + eyeCatchingImageSet: "Elegir miniatura" + eyeCatchingImageRemove: "Borrar miniatura" + chooseBlock: "Añadir bloque" + selectType: "Elegir tipo" + enterVariableName: "Introduce el nombre de la variable" + variableNameIsAlreadyUsed: "El nombre de la variable ya está en uso" + contentBlocks: "Contenido" + inputBlocks: "Entrada" + specialBlocks: "Especial" + blocks: + text: "Texto" + textarea: "Área de texto" + section: "Sección" + image: "Imágenes" + button: "Botón" + if: "Si" + _if: + variable: "Variable" + post: "Formulario" + _post: + text: "Contenido" + attachCanvasImage: "Adjuntar imagen canvas" + canvasId: "ID del canvas" + textInput: "Entrada de texto" + _textInput: + name: "Nombre de variable" + text: "Título" + default: "Valor predeterminado" + textareaInput: "Entrada de texto en múltiples lineas" + _textareaInput: + name: "Nombre de variable" + text: "Título" + default: "Valor predeterminado" + numberInput: "Entrada numérica" + _numberInput: + name: "Nombre de variable" + text: "Título" + default: "Valor predeterminado" + canvas: "Canvas" + _canvas: + id: "ID del canvas" + width: "Anchura" + height: "Altura" + note: "Nota embebida" + _note: + id: "ID de la nota" + idDescription: "Alternativamente, puedes pegar la URL de la nota aquí." + detailed: "Ver detalles" + switch: "Interruptor" + _switch: + name: "Nombre de variable" + text: "Título" + default: "Valor predeterminado" + counter: "Contador" + _counter: + name: "Nombre de variable" + text: "Título" + inc: "Aumentar cantidad" + _button: + text: "Título" + colored: "Color" + action: "Acción al presionar el botón" + _action: + dialog: "Mostrar cuadro de diálogo" + _dialog: + content: "Contenido" + resetRandom: "Reiniciar número aleatorio" + pushEvent: "Enviar evento" + _pushEvent: + event: "Nombre del evento" + message: "Mensaje mostrado al apretar" + variable: "Variable a enviar" + no-variable: "Ninguna" + callAiScript: "Invocar AiScript" + _callAiScript: + functionName: "Nombre de la función" + radioButton: "Opción" + _radioButton: + name: "Nombre de variable" + title: "Título" + values: "Opciones separadas por una nueva linea" + default: "Valor predeterminado" + script: + categories: + flow: "Control de flujo" + logical: "Operación lógica" + operation: "Cálculo" + comparison: "Comparar" + random: "Aleatorio" + value: "Valores" + fn: "Funciones" + text: "Manejo de texto" + convert: "Conversiones" + list: "Listas" + blocks: + text: "Texto" + multiLineText: "Texto (multilínea)" + textList: "Lista de texto" + _textList: + info: "Separa cada texto con una linea nueva" + strLen: "Largo del texto" + _strLen: + arg1: "Texto" + strPick: "Extraer carácteres" + _strPick: + arg1: "Texto" + arg2: "Posición del carácter" + strReplace: "Sustituir texto" + _strReplace: + arg1: "Texto" + arg2: "Texto a reemplazar" + arg3: "Texto reemplazado" + strReverse: "Invertir texto" + _strReverse: + arg1: "Texto" + join: "Concatenar texto" + _join: + arg1: "Listas" + arg2: "Separador" + add: "Suma" + _add: + arg1: "A" + arg2: "B" + subtract: "Resta" + _subtract: + arg1: "A" + arg2: "B" + multiply: "Multiplicación" + _multiply: + arg1: "A" + arg2: "B" + divide: "División" + _divide: + arg1: "A" + arg2: "B" + mod: "Resto" + _mod: + arg1: "A" + arg2: "B" + round: "Redondear decimales" + _round: + arg1: "Número" + eq: "A y B son iguales" + _eq: + arg1: "A" + arg2: "B" + notEq: "A y B son distintos" + _notEq: + arg1: "A" + arg2: "B" + and: "A AND B" + _and: + arg1: "A" + arg2: "B" + or: "A OR B" + _or: + arg1: "A" + arg2: "B" + lt: "< A es menor que B" + _lt: + arg1: "A" + arg2: "B" + gt: "> A es mayor que B" + _gt: + arg1: "A" + arg2: "B" + ltEq: "<= A es menor o igual que B" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: ">= A es mayor o igual que B" + _gtEq: + arg1: "A" + arg2: "B" + if: "Si" + _if: + arg1: "Si" + arg2: "Entonces" + arg3: "Si no" + not: "Negación" + _not: + arg1: "Negación" + random: "Aleatorio" + _random: + arg1: "Probabilidad" + rannum: "Número aleatorio" + _rannum: + arg1: "Mínimo" + arg2: "Máximo" + randomPick: "Elegir aleatoriamente de la lista" + _randomPick: + arg1: "Listas" + dailyRandom: "Aleatorio (Diariamente para cada usuario)" + _dailyRandom: + arg1: "Probabilidad" + dailyRannum: "Número aleatorio (Diariamente para cada usuario)" + _dailyRannum: + arg1: "Mínimo" + arg2: "Máximo" + dailyRandomPick: "Elegir aleatoriamente de la lista (Diariamente para cada usuario)" + _dailyRandomPick: + arg1: "Lista" + seedRandom: "Aleatorio (con semilla)" + _seedRandom: + arg1: "Semilla" + arg2: "Probabilidad" + seedRannum: "Número aleatorio (con semilla)" + _seedRannum: + arg1: "Semilla" + arg2: "Mínimo" + arg3: "Máximo" + seedRandomPick: "Elegir aleatoriamente de la lista (con semilla)" + _seedRandomPick: + arg1: "Semilla" + arg2: "Lista" + DRPWPM: "Elegir aleatoriamente de la lista ponderada (Diariamente para cada + usuario)" + _DRPWPM: + arg1: "Lista de texto" + pick: "Elegir de la lista" + _pick: + arg1: "Lista" + arg2: "Posición" + listLen: "Obtener largo de la lista" + _listLen: + arg1: "Lista" + number: "Número" + stringToNumber: "De texto a número" + _stringToNumber: + arg1: "Texto" + numberToString: "De número a texto" + _numberToString: + arg1: "Número" + splitStrByLine: "Separar texto en lineas" + _splitStrByLine: + arg1: "Texto" + ref: "Variable" + aiScriptVar: "Variable de AiScript" + fn: "Función" + _fn: + slots: "Slots" + slots-info: "Separe cada uno de los slots con una linea nueva" + arg1: "Salida" + for: "Repetir" + _for: + arg1: "Cantidad de repeticiones" + arg2: "Acción" + typeError: "El slot {slot} acepta el tipo \"{expect}\" pero fue introducido el + tipo \"{actual}\"" + thereIsEmptySlot: "El slot {slot} está vacío" + types: + string: "Texto" + number: "Número" + boolean: "Booleano" + array: "Lista" + stringArray: "Lista de texto" + emptySlot: "Slot vacío" + enviromentVariables: "Variables de entorno" + pageVariables: "Variables de la página" + argVariables: "Slot de entrada" +_relayStatus: + requesting: "Pendiente" + accepted: "Aceptado" + rejected: "Rechazada" +_notification: + fileUploaded: "Archivo subido" + youGotMention: "{name} te ha mencionado" + youGotReply: "{name} te ha respondido" + youGotQuote: "{name} te ha citado" + youRenoted: "Renotado por {name}" + youGotPoll: "{name} votó en tu encuesta" + youGotMessagingMessageFromUser: "{name} te mandó un mensaje" + youGotMessagingMessageFromGroup: "Un mensaje de chat fue enviado al grupo {name}" + youWereFollowed: "te ha seguido" + youReceivedFollowRequest: "Has recibido una petición de seguimiento" + yourFollowRequestAccepted: "Tu petición de seguimiento fue aceptada" + youWereInvitedToGroup: "{userName} te invitó a un grupo" + pollEnded: "Están disponibles los resultados de la encuesta" + emptyPushNotificationMessage: "Se han actualizado las notificaciones push" + _types: + all: "Todo" + follow: "Seguidores nuevos" + mention: "Menciones" + reply: "Respuestas" + renote: "Renotas" + quote: "Citas" + reaction: "Reacciones" + pollVote: "Votos en encuestas" + pollEnded: "La encuesta terminó" + receiveFollowRequest: "Peticiones de seguimiento recibidas" + followRequestAccepted: "Peticiones de seguimineto aceptadas" + groupInvited: "Invitaciones a grupos" + app: "Notificaciones desde aplicaciones" + _actions: + followBack: "te sigue de vuelta" + reply: "Responder" + renote: "Renotas" + voted: votó en tu encuesta + reacted: reaccionó a tu publicación + renoted: renotó tu publicación +_deck: + alwaysShowMainColumn: "Siempre mostrar la columna principal" + columnAlign: "Alinear columnas" + addColumn: "Añadir columna" + configureColumn: "Ajustes de columna" + swapLeft: "Mover a la izquierda" + swapRight: "Mover a la derecha" + swapUp: "Mover arriba" + swapDown: "Mover abajo" + stackLeft: "Apilar a la izquierda" + popRight: "Sacar a la derecha" + profile: "Perfil" + newProfile: "Nuevo perfil" + deleteProfile: "Eliminar perfil" + introduction: "¡Crea la interfaz perfecta para tí organizando las columnas libremente!" + introduction2: "Presiona en el + de la derecha de la pantalla para añadir nuevas + columnas donde quieras." + widgetsIntroduction: "Por favor selecciona \"Editar Widgets\" en el menú columna + y agrega un widget." + _columns: + main: "Principal" + widgets: "Widgets" + notifications: "Notificaciones" + tl: "Linea de tiempo" + antenna: "Antena" + list: "Lista" + mentions: "Menciones" + direct: "Mensajes directos" + channel: Canal + renameProfile: Renombrar perfil + nameAlreadyExists: Ya hay un perfil con ese nombre. +manageGroups: Administrar grupos +replayTutorial: Repetir tutorial +privateMode: Modo privado +addInstance: Añadir una instancia +renoteMute: Silenciar renotas +renoteUnmute: Dejar de silenciar renotas +flagSpeakAsCat: Habla como un gato +selectInstance: Selecciona un servidor +flagSpeakAsCatDescription: Tus publicaciones se "nyanificarán" cuando estés en modo + gato +allowedInstances: Instancias en la lista blanca +breakFollowConfirm: ¿Estás seguro de que quieres eliminar el seguidor? +subscribePushNotification: Habilitar notificaciones +unsubscribePushNotification: Desactivar notificaciones +pushNotificationAlreadySubscribed: Las notificaciones ya están activadas +pushNotificationNotSupported: Tu navegador o instancia no admite notificaciones +moveAccount: ¡Migrar cuenta! +moveFrom: Migra a esta cuenta desde una cuenta antigua +moveFromLabel: 'Cuenta desde la que migras:' +moveAccountDescription: 'Este proceso es irreversible. Asegúrate de que has configurado + un alias en tu cuenta nueva antes de migrar. Por favor, introduce la etiqueta de + la cuenta en formato @usuario@instancia.com' +license: Licencia +noThankYou: No gracias +userSaysSomethingReason: '{name} dijo {reason}' +hiddenTags: Etiquetas ocultas +noInstances: No hay servidores +accountMoved: 'El usuario ha migrado a otra cuenta:' +caption: Auto subtítulos +showAds: Mostrar anuncios +enterSendsMessage: Presione intro en los mensajes para enviar el mensaje (para apagarlo + es Ctrl + Intro) +recommendedInstances: Instancias recomendadas +instanceSecurity: Seguridad de la instancia +seperateRenoteQuote: Separar botones "Renotar" y "Citar" +_messaging: + groups: Grupos + dms: Privado +pushNotification: Notificaciones Push +apps: Aplicaciones +migration: Migración +silenced: Silenciado +deleted: Borrado +edited: 'Editado el {date} a las {time}' +editNote: Editar nota +silenceThisInstance: Silenciar este servidor +findOtherInstance: Buscar otro servidor +userSaysSomethingReasonRenote: '{name} Renotó una nota que contiene {reason]' +enableRecommendedTimeline: Habilitar línea de tiempo recomendada +searchPlaceholder: Buscar en el Fediverso +listsDesc: Las listas te permiten crear líneas de tiempo con usuarios específicos. + Puedes acceder a ellas desde la pestaña "Línea de tiempo". +removeReaction: Quitar tu reacción +selectChannel: Selecciona un canal +showEmojisInReactionNotifications: Mostrar emojis en notificaciones de reacciones +silencedInstancesDescription: Escriba los dominios de los servidores que quieres bloquear. + Las cuentas en estos servidores serán tratadas como "silenciadas", solo podrán hacer + solicitudes de seguimiento, y no podrán mencionar a usuarios de este servidor si + no les siguen. Esto no afecta los servidores bloqueados. +silencedInstances: Servidores silenciados +hiddenTagsDescription: 'Escribe los hashtags (sin el #) que quieres ocultar de las + secciones de Tendencias y Explorar. Los hashtags ocultos seguirán siendo descubribles + por otros métodos.' +jumpToPrevious: Ver anterior +enableEmojiReactions: Habilitar reacciones con emojis +cw: Aviso de contenido +allowedInstancesDescription: Dominios de los servidores con los que federar, separados + con nuevas líneas (solo aplica en modo privado). +privateModeInfo: Si lo activas, solo los servidores listados pueden federar con el + tuyo. Todas las publicaciones estarán ocultas al público. +expandAllCws: Mostrar contenido de las respuestas +collapseAllCws: Ocultar contenido de las respuestas +newer: más nuevo +older: más antiguo +antennasDesc: "Las antenas muestran posts nuevos segun el criterio establecido.\n + Son accesibles desde la línea de tiempo." +antennaInstancesDescription: Una instancia por cada línea +cannotChangeScopeWhenEditing: No puedes cambiar la visibilidad mientras editas +antennaTimelineHint: Las antenas muestran las publicaciones en el orden que las reciben, + que no tiene que ser necesariamente cronológico. +accessibility: Accesibilidad +expandOnNoteClick: Abrir nota al pulsar +expandOnNoteClickDesc: Si se desactiva, aun podrás abrir notas con el menú del click + derecho o pulsando en la marca de tiempo. +xl: XL +userSaysSomethingReasonReply: '{name} respondió a una publicación que contiene {reason}' +userSaysSomethingReasonQuote: '{name} citó una publicación que contiene {reason}' +channelFederationWarn: Los canales aún no federan con otros servidores +clipsDesc: Los clips son marcadores categorizados y compartibles. Puedes crear clips + desde el menú de cada publicación. +secureMode: Modo seguro (recuperación autorizada) +secureModeInfo: Cuando ocurran solicitudes desde otras instancias, no devolver sin + pruebas. +isModerator: Moderador +openInMainColumn: Abrir en columna principal +isAdmin: Administrador +isBot: Esta cuenta es un bot +_filters: + _dialog: + title: Sintaxis del filtro de búsqueda + learnMore: Ver sintaxis del filtro + inFilters: Filtrar por marcador y/o estado de favorito + exclusivity: 'Aviso: el filtro "antes:" es exclusivo, mientras que el filtro "después:" + es inclusivo.' + info1: El texto en brackets significa paramétros disponibles opcionales del filtro. + Las opciones del parámetro se indican mediante una barra vertical ( | ). + wordFilters: FIltrar por texto de publicación + miscFilters: Filtrar por relación de seguimiento y/o tipo de nota + userDomain: Filtrar por autor, usuarios mencionados, usuario que responde o dominio + de instancia + postDate: Filtrar por fecha de la publicación + word: palabra + phrase: frase literal que contiene carácteres (aleatorios) + attachmentType: Filtrar por tipo de archivo(s) adjunto(s) + matchOptions: Cambia la sensibilidad de mayúsculas y/o activa la coincidencia + de palabra completa + info: Nomenclatura + info2: Un guión encerrado en brackets denota la habilidad de invertir/negar un + filtro con el guión. + infoEnd: Alias del filtro + infoEnd1: Por conveniencia y evitar errores de escritura, algunos filtros tienen + alias, que se listan debajo. + excludeReplies: Excluir respuestas + fromUser: Del usuario + replyTo: Respondiendo a + mentioning: Mencionando + inFavorites: Favorito + inBookmarks: Marcado + withFile: Tiene adjunto(s) + fromDomain: Sólo instancia específica + notesBefore: Notas antes + notesAfter: Notas después + followingOnly: Solo siguiendo + followersOnly: Solo seguidores + repliesOnly: Solo respuestas + excludeRenotes: Excluir renotas + caseSensitive: Distinguir mayúsculas + matchWords: Coincidir palabras completas +searchEmptyQuery: Por favor, introduce un término de búsqueda. +adminCustomCssWarn: Este ajuste solo debe de usarse si sabes lo que hace. Introducir + valores incorrectos podría causar que el cliente de TODOS deje de funcionar con + normalidad. Por favor, asegúrate de que tu CSS funciona apropiadamente probándolo + en tus ajustes de usuario. +customMOTDDescription: Mensajes personalizados para el MOTD (pantalla de bienvenida) + separadas por nuevas líneas para mostrarlas aleatoriamente cada vez que un usuario + carga/recarga la página. +customSplashIconsDescription: URLs para iconos personalizados para la pantalla de + bienvenida, separados por nuevas líneas, para mostrarlos cada vez que un usuario + carga/recarga la página. Asegúrate de que las imágenes estan en una URL estática, + preferiblemente a tamaño 192x192. +customSplashIcons: Iconos de pantalla de bienvenida personalizados (URLs) +showUpdates: Muestra un popup cuando Iceshrimp se actualiza +recommendedInstancesDescription: Instancias recomendadas, separadas por nuevas líneas, + para mostarlas en la línea de tiempo recomendada. +updateAvailable: ¡Puede que haya una actualización disponible! +swipeOnMobile: Permitir deslizarse entre las páginas +swipeOnDesktop: Permitir deslizamiento como en móviles en el escritorio +logoImageUrl: URL de la imagen del logo +showAdminUpdates: Indicar cuando una nueva versión de Iceshrimp esté disponible (sólo + administradores) +moveTo: Migrar cuenta actual a una cuenta nueva +moveToLabel: 'Cuenta a la que migras:' +enableCustomKaTeXMacro: Activar macros KaTeX personalizadas +noteId: ID de la nota +signupsDisabled: Los registros en esta instancia ahora mismo están desactivados, ¡pero + puedes registrarte en otro servidor! Si tienes un código de invitación para esta + instancia, por favor, introdúcelo debajo. +defaultReaction: Reacción emoji por defecto para las publicaciones +customKaTeXMacro: Macros KaTeX personalizadas +enableIdenticonGeneration: Activar generación de Identicon +reactionPickerSkinTone: Tono de piel preferido para emojis +moveFromDescription: Esto creará un alias de tu cuenta antigua para que puedas migrar + desde esa cuenta a esta. Haz esto ANTES de migrar desde tu cuenta antigua. Por favor, + introduce la etiqueta de la cuenta en formato @usuario@instancia.com +migrationConfirm: "¿Estás absolutamente seguro de que quieres migrar tu cuenta a {accoumt}? + Una vez hagas esto, no podrás revertirlo, y no podrás volver a usar tu cuenta normalmente + de nuevo.\nAsegúrate tambien de haber configurado esta cuenta como la cuenta desde + la que migras." +customKaTeXMacroDescription: '¡Configura macros para escribir expresiones matemáticas + con facilidad! La notación es conforme a las definiciones de comandos LaTeX y se + escriben como \nuevocomando{\ nombre}{contenido} o \nuevocomando{\nombre}{número + de argumentos}{contenido}. Por ejemplo, \nuevocomando{\add}{2}{#1 + #2} expandirá + \add{3}{foo} a 3 + foo. Los brackets curvos que rodean el nombre de la macro se + pueden cambiar por brackets sólidos o paréntesis. Esto afecta a los brackets usados + por los argumentos. Una (y solo una) macro puede ser definida por cada línea, y + no puedes saltar de línea en mitad de la definición. Las líneas inválidad son simplemente + ignoradas. Sólo funciones simples de sustitución se soportan aquí, sintaxis avanzada, + como ramificaciones condicionales, no pueden usarse aquí.' +enableServerMachineStats: Activar estadísticas de hardware del servidor +searchNotLoggedIn_1: Tienes que iniciar sesión para usar la búsqueda de texto completo. +sendPushNotificationReadMessage: Eliminar notificaciones push una vez las notificaciones + relevantes han sido leídas +sendPushNotificationReadMessageCaption: Una notificación con el texto "{emptyPushNotificationMessage}" + se mostrará por un corto periodo de tiempo. Esto podría aumentar el uso de batería + de tu dispositivo, si fuera aplicable. +customMOTD: MOTD personalizado (mensajes en la pantalla de bienvenida) +splash: Pantalla de bienvenida +donationLink: Enlace a la página de donación +alt: ALT +image: Imagen +video: Vídeo +audio: Audio +cannotUploadBecauseExceedsFileSizeLimit: Este archivo no se puede subir porque excede + el tamaño máximo permitido. +sendModMail: Enviar un aviso de moderación +preventAiLearning: Prevenir el rascado de bots de IA +preventAiLearningDescription: Pedirle a modelos te IA de terceros que no estudien + el contenido que subes, como las publicaciones y las imágenes. +noGraze: Por favor, deshabilita la extensión "Graze for Mastodon", ya que interfiere + con Iceshrimp. +silencedWarning: Está página se muestra porque estos usuarios son de servidores que + tu administrador silenció, por lo que podrían ser potencialmente spam. +isLocked: Esta cuenta aprueba sus seguidores manualmente +isPatron: Patrón de Iceshrimp +showPopup: Avisar a los usuarios con un popup +showWithSparkles: Mostrar con brillitos +youHaveUnreadAnnouncements: Tienes anuncios sin leer +neverShow: No volver a mostrar +remindMeLater: Tal vez luego +removeQuote: Eliminar cita +removeRecipient: Eliminar receptor +removeMember: Eliminar miembro +verifiedLink: Enlace verificado +searchNotLoggedIn_2: Sin embargo, puedes buscar usando hashtags, y buscar usuarios. +alwaysExpandCws: Expandir siempre las publicaciones con avisos de contenido +_wellness: + newPostsButton: Activa la alerta de nuevas publicaciones + newPostsGlowOpacity: Opacidad del brillo de publicaciones nuevas + name: Bienestar + description: Estos ajustes te permiten ajustar algunos aspectos de las redes sociales + que podrían ser adictivos o productores de ansiedad. Elige los ajustes ideales + para ti. + immediacy: Inmediatez +cwStyle: Aspecto del aviso de contenido +_cwStyle: + modern: Moderno + classic: Clásico (Como Misskey/Foundkey) + alternative: Alternativo (Como Firefish) +hideFromHome: Ocultar de la lína de tiempo Inicio +_feeds: + jsonFeed: Feed JSON + copyFeed: Copiar feed + rss: RSS + atom: Atom +_skinTones: + mediumDark: Medio oscuro + dark: Oscuro + medium: Medio + yellow: Amarillo + light: Claro + mediumLight: Medio claro +_dialog: + charactersExceeded: '¡Carácteres máximos superados! Actualmente: {current}/Límite: + {max}' + charactersBelow: '¡No hay carácteres suficientes! Actualmente: {current} / Mínimo: + {min}' diff --git a/locales/et.yml b/locales/et.yml new file mode 100644 index 0000000..54e94c9 --- /dev/null +++ b/locales/et.yml @@ -0,0 +1 @@ +_lang_: Eesti keel diff --git a/locales/fi.yml b/locales/fi.yml new file mode 100644 index 0000000..be74a33 --- /dev/null +++ b/locales/fi.yml @@ -0,0 +1,981 @@ +_lang_: "Suomi" +username: Käyttäjänimi +fetchingAsApObject: Hae Fedeversestä +gotIt: Selvä! +cancel: Peruuta +enterUsername: Anna käyttäjänimi +renotedBy: Buustannut {user} +noNotes: Ei lähetyksiä +noNotifications: Ei ilmoituksia +instance: Instanssi +settings: Asetukset +basicSettings: Perusasetukset +otherSettings: Muut asetukset +openInWindow: Avaa ikkunaan +profile: Profiili +timeline: Aikajana +noAccountDescription: Käyttäjä ei ole vielä kirjoittanut kuvaustaan vielä. +login: Kirjaudu sisään +loggingIn: Kirjautuu sisään +logout: Kirjaudu ulos +uploading: Tallentaa ylös... +save: Tallenna +favorites: Kirjanmerkit +unfavorite: Poista kirjanmerkeistä +favorited: Lisätty kirjanmerkkeihin. +alreadyFavorited: Lisätty jo kirjanmerkkeihin. +cantFavorite: Ei voitu lisätä kirjanmerkkeihin. +pin: Kiinnitä profiiliin +unpin: Irroita profiilista +delete: Poista +forgotPassword: Unohtunut salasana +search: Etsi +notifications: Ilmoitukset +password: Salasana +ok: OK +noThankYou: Ei kiitos +signup: Rekisteröidy +users: Käyttäjät +addUser: Lisää käyttäjä +addInstance: Lisää instanssi +favorite: Lisää kirjanmerkkeihin +copyContent: Kopioi sisältö +deleteAndEdit: Poista ja muokkaa +copyLink: Kopioi linkki +makeFollowManuallyApprove: Seuraajapyyntö vaatii hyväksymistä +follow: Seuraa +pinned: Kiinnitä profiiliin +followRequestPending: Seuraajapyyntö odottaa +you: Sinä +unrenote: Peruuta buustaus +reaction: Reaktiot +reactionSettingDescription2: Vedä uudelleenjärjestelläksesi, napsauta poistaaksesi, + paina "+" lisätäksesi. +attachCancel: Poista liite +enterFileName: Anna tiedostonimi +mute: Hiljennä +unmute: Poista hiljennys +headlineIceshrimp: Avoimen lähdekoodin, hajautettu sosiaalisen median alusta, joka + on ikuisesti ilmainen! 🚀 +monthAndDay: '{day}/{month}' +deleteAndEditConfirm: Oletko varma, että haluat poistaa tämän lähetyksen ja muokata + sitä? Menetät kaikki reaktiot, buustaukset ja vastaukset lähetyksestäsi. +addToList: Lisää listaan +sendMessage: Lähetä viesti +reply: Vastaa +loadMore: Lataa enemmän +showMore: Näytä enemmän +receiveFollowRequest: Seuraajapyyntö vastaanotettu +followRequestAccepted: Seuraajapyyntö hyväksytty +mentions: Maininnat +importAndExport: Tuo/Vie Tietosisältö +import: Tuo +export: Vie +files: Tiedostot +download: Lataa +unfollowConfirm: Oletko varma, ettet halua seurata enää käyttäjää {name}? +noLists: Sinulla ei ole listoja +note: Viesti +notes: Viestit +following: Seuraa +createList: Luo lista +manageLists: Hallitse listoja +error: Virhe +somethingHappened: On tapahtunut virhe +retry: Yritä uudelleen +pageLoadError: Virhe ladattaessa sivua. +serverIsDead: Tämä palvelin ei vastaa. Yritä hetken kuluttua uudelleen. +youShouldUpgradeClient: Nähdäksesi tämän sivun, virkistä päivittääksesi asiakasohjelmasi. +privacy: Tietosuoja +defaultNoteVisibility: Oletusnäkyvyys +followRequest: Seuraajapyyntö +followRequests: Seuraajapyynnöt +unfollow: Poista seuraaminen +enterEmoji: Syötä emoji +renote: Buustaa +renoted: Buustattu. +cantRenote: Tätä lähetystä ei voi buustata. +cantReRenote: Buustausta ei voi buustata. +quote: Lainaus +pinnedNote: Lukittu lähetys +clickToShow: Napsauta nähdäksesi +sensitive: Herkkää sisältöä (NSFW) +add: Lisää +enableEmojiReactions: Ota käyttöön emoji-reaktiot +showEmojisInReactionNotifications: Näytä emojit reaktioilmoituksissa +reactionSetting: Reaktiot näytettäväksi reaktiovalitsimessa +rememberNoteVisibility: Muista lähetyksen näkyvyysasetukset +markAsSensitive: Merkitse herkäksi sisällöksi (NSFW) +unmarkAsSensitive: Poista merkintä herkkää sisältöä (NSFW) +renoteMute: Hiljennä buustit +renoteUnmute: Poista buustien hiljennys +block: Estä +unblock: Poista esto +unsuspend: Poista keskeytys +suspend: Keskeytys +blockConfirm: Oletko varma, että haluat estää tämän tilin? +unblockConfirm: Oletko varma, että haluat poistaa tämän tilin eston? +selectAntenna: Valitse antenni +selectWidget: Valitse vimpain +editWidgets: Muokkaa vimpaimia +editWidgetsExit: Valmis +emoji: Emoji +emojis: Emojit +emojiName: Emojin nimi +emojiUrl: Emojin URL-linkki +cacheRemoteFiles: Taltioi etätiedostot välimuistiin +flagAsBot: Merkitse tili botiksi +flagAsBotDescription: Ota tämä vaihtoehto käyttöön, jos tätä tiliä ohjaa ohjelma. + Jos se on käytössä, se toimii lippuna muille kehittäjille, jotta estetään loputtomat + vuorovaikutusketjut muiden bottien kanssa ja säädetään Iceshrimpn sisäiset järjestelmät + käsittelemään tätä tiliä botina. +flagAsCat: Oletko kissa? 🐱 +flagAsCatDescription: Saat kissan korvat ja puhut kuin kissa! +flagSpeakAsCat: Puhu kuin kissa +flagShowTimelineReplies: Näytä vastaukset aikajanalla +addAccount: Lisää tili +loginFailed: Kirjautuminen epäonnistui +showOnRemote: Katsele etäinstanssilla +general: Yleistä +accountMoved: 'Käyttäjä on muuttanut uuteen tiliin:' +wallpaper: Taustakuva +setWallpaper: Aseta taustakuva +searchWith: 'Etsi: {q}' +youHaveNoLists: Sinulla ei ole listoja +followConfirm: Oletko varma, että haluat seurata käyttäjää {name}? +host: Isäntä +selectUser: Valitse käyttäjä +annotation: Kommentit +registeredAt: Rekisteröity +latestRequestReceivedAt: Viimeisin pyyntö vastaanotettu +latestRequestSentAt: Viimeisin pyyntö lähetetty +storageUsage: Tallennustilan käyttö +charts: Kaaviot +stopActivityDelivery: Lopeta toimintojen lähettäminen +blockThisInstance: Estä tämä instanssi +operations: Toiminnot +metadata: Metatieto +monitor: Seuranta +jobQueue: Työjono +cpuAndMemory: Prosessori ja muisti +network: Verkko +disk: Levy +clearCachedFiles: Tyhjennä välimuisti +clearCachedFilesConfirm: Oletko varma, että haluat tyhjentää kaikki välimuistiin tallennetut + etätiedostot? +blockedInstances: Estetyt instanssit +hiddenTags: Piilotetut asiatunnisteet +mention: Maininta +copyUsername: Kopioi käyttäjänimi +searchUser: Etsi käyttäjää +showLess: Sulje +youGotNewFollower: seurasi sinua +directNotes: Yksityisviestit +driveFileDeleteConfirm: Oletko varma, että haluat poistaa tiedoston " {name}"? Se + poistetaan kaikista viesteistä, jotka sisältävät sen liitetiedostona. +importRequested: Olet pyytänyt viemistä. Tämä voi viedä hetken. +exportRequested: Olet pyytänyt tuomista. Tämä voi viedä hetken. Se lisätään asemaan + kun tuonti valmistuu. +lists: Listat +followers: Seuraajat +followsYou: Seuraa sinua +pageLoadErrorDescription: Tämä yleensä johtuu verkkovirheistä tai selaimen välimuistista. + Kokeile tyhjentämällä välimuisti ja yritä sitten hetken kuluttua uudelleen. +enterListName: Anna listalle nimi +instanceInfo: Instanssin tiedot +clearQueue: Tyhjennä jono +suspendConfirm: Oletko varma, että haluat keskeyttää tämän tilin? +unsuspendConfirm: Oletko varma, että haluat poistaa tämän tilin keskeytyksen? +selectList: Valitse lista +customEmojis: Kustomoitu Emoji +addEmoji: Lisää +settingGuide: Suositellut asetukset +cacheRemoteFilesDescription: Kun tämä asetus ei ole käytössä, etätiedostot on ladattu + suoraan etäinstanssilta. Asetuksen poistaminen käytöstä vähentää tallennustilan + käyttöä, mutta lisää verkkoliikennettä kun pienoiskuvat eivät muodostu. +flagSpeakAsCatDescription: Lähetyksesi nyanifioidaan, kun olet kissatilassa +flagShowTimelineRepliesDescription: Näyttää käyttäjien vastaukset muiden käyttäjien + lähetyksiin aikajanalla, jos se on päällä. +autoAcceptFollowed: Automaattisesti hyväksy seuraamispyynnöt käyttäjiltä, joita seuraat +perHour: Tunnissa +removeWallpaper: Poista taustakuva +recipient: Vastaanottaja(t) +federation: Federaatio +software: Ohjelmisto +proxyAccount: Proxy-tili +proxyAccountDescription: Välitystili (Proxy-tili) on tili, joka toimii käyttäjien + etäseuraajana tietyin edellytyksin. Kun käyttäjä esimerkiksi lisää etäkäyttäjän + luetteloon, etäkäyttäjän toimintaa ei toimiteta instanssiin, jos yksikään paikallinen + käyttäjä ei seuraa kyseistä käyttäjää, joten välitystili seuraa sen sijaan. +latestStatus: Viimeisin tila +selectInstance: Valitse instanssi +instances: Instanssit +perDay: Päivässä +version: Versio +statistics: Tilastot +clearQueueConfirmTitle: Oletko varma, että haluat tyhjentää jonon? +introIceshrimp: Tervetuloa! Iceshrimp on avoimen lähdekoodin, hajautettu sosiaalisen + median alusta, joka on ikuisesti ilmainen! 🚀 +clearQueueConfirmText: Mitkään välittämättömät lähetykset, jotka ovat jonossa, eivät + federoidu. Yleensä tätä toimintoa ei tarvita. +blockedInstancesDescription: Lista instanssien isäntänimistä, jotka haluat estää. + Listatut instanssit eivät kykene kommunikoimaan enää tämän instanssin kanssa. +security: Turvallisuus +retypedNotMatch: Syöte ei kelpaa. +fromDrive: Asemasta +keepOriginalUploading: Säilytä alkuperäinen kuva +uploadFromUrlDescription: Tiedoston URL, jonka haluat ylösladata +themeForLightMode: Teema vaaleassa tilassa +theme: Teemat +themeForDarkMode: Teema tummassa tilassa +drive: Asema +darkThemes: Tummat teemat +copyUrl: Kopioi URL-linkki +rename: Uudelleennimeä +maintainerName: Ylläpitäjä +maintainerEmail: Ylläpitäjän sähköposti +tosUrl: Palvelun ehdot URL-linkki +thisYear: Vuosi +backgroundImageUrl: Taustakuvan URL-linkki +basicInfo: Perustiedot +pinnedPagesDescription: Kirjoita niiden sivujen polut, jotka haluat liittää tämän + instanssin yläsivulle rivinvaihdoin erotettuna. +hcaptchaSiteKey: Sivuston avain +hcaptchaSecretKey: Salausavain +silencedInstances: Hiljennetyt instanssit +muteAndBlock: Hiljennykset ja estetyt +mutedUsers: Hiljennetyt käyttäjät +blockedUsers: Estetyt käyttäjät +noUsers: Ei yhtään käyttäjää +noInstances: Ei yhtään instanssia +editProfile: Muokkaa profiilia +noteDeleteConfirm: Oletko varma, että haluat poistaa tämän viestin? +pinLimitExceeded: Et voi kiinnittää enempää viestejä +intro: Iceshrimp -asennus valmis! Ole hyvä ja luo admin-käyttäjä. +done: Valmis +processing: Suorittaa +preview: Esikatselu +default: Oletus +defaultValueIs: 'Oletus: {value}' +noCustomEmojis: Ei emojia +noJobs: Ei töitä +federating: Federoi +blocked: Estetty +silenced: Hiljennetty +suspended: Keskeytetty +all: Kaikki +publishing: Julkaisee +subscribing: Tilaa +notResponding: Ei vastaa +instanceFollowing: Seuraa instanssia +instanceFollowers: Instanssin seuraajat +instanceUsers: Instanssin käyttäjät +changePassword: Muuta salasana +newPasswordRetype: Uudelleensyötä uusi salasana +more: Lisää! +featured: Esillä +usernameOrUserId: Käyttäjänimi tai käyttäjä id +noSuchUser: Käyttäjää ei löydy +lookup: Hae +announcements: Tiedoitteet +imageUrl: Kuva URL-linkki +removed: Onnistuneesti poistettu +removeAreYouSure: Oletko varma, että haluat poistaa " {x}"? +resetAreYouSure: Haluatko nollata? +saved: Tallennettu +messaging: Juttele +upload: Lataa ylös +fromUrl: URL:stä +uploadFromUrl: Ylöslataa URL:stä +uploadFromUrlRequested: Ylöslataus pyydetty +uploadFromUrlMayTakeTime: Voi viedä hetki, kun ylöslataus on valmis. +explore: Tutustu +messageRead: Lue +noMoreHistory: Ei lisää historiaa +startMessaging: Aloita uusi juttelu +manageGroups: Hallitse ryhmiä +nUsersRead: lukenut {n} +agreeTo: Hyväksyn {0} +tos: Palvelun ehdot +start: Aloita +home: Koti +remoteUserCaution: Etäkäyttäjän tiedot saattavat olla puutteellisia. +light: Vaalea +dark: Tumma +lightThemes: Vaaleat teemat +syncDeviceDarkMode: Synkronoi tumma tila laitteen asetuksen mukaan +fileName: Tiedostonimi +selectFile: Valitse tiedosto +selectFiles: Valitse tiedostot +selectFolder: Valitse kansio +selectFolders: Valitse kansiot +renameFile: Uudelleennimeä tiedosto +folderName: Kansionimi +createFolder: Luo kansio +renameFolder: Uudelleennimeä kansio +deleteFolder: Poista kansio +addFile: Lisää tiedosto +emptyDrive: Asemasi on tyhjä +emptyFolder: Tämä kansio on tyhjä +unableToDelete: Ei voitu poistaa +inputNewFileName: Syötä uusi tiedostonimi +inputNewDescription: Syötä uusi kuvateksti +inputNewFolderName: Syötä uusi kansionimi +hasChildFilesOrFolders: Koska kansio ei ole tyhjä, sitä ei voi poistaa. +avatar: Kuvake +banner: Banneri +nsfw: Herkkää sisältöä (NSFW) +whenServerDisconnected: Kun yhteys palvelimeen menetetään +disconnectedFromServer: Yhteys palvelimeen katkennut +reload: Päivitä +doNothing: Hylkää +reloadConfirm: Haluaisitko päivittää aikajanan? +unwatch: Lopeta katselu +watch: Katsele +accept: Hyväksy +reject: Hylkää +normal: Normaali +instanceName: Instanssin nimi +thisMonth: Kuukausi +today: Tänään +monthX: '{month}' +connectService: Yhdistä +disconnectService: Katkaise yhteys +enableLocalTimeline: Ota käyttöön paikallinen aikajana +enableGlobalTimeline: Ota käyttöön globaali aikajana +enableRecommendedTimeline: Ota käyttöön suositellut -aikajana +registration: Rekisteröinti +enableRegistration: Ota käyttöön uuden käyttäjän rekisteröinti +driveCapacityPerLocalAccount: Aseman kapasiteetti paikallista käyttäjää kohti +driveCapacityPerRemoteAccount: Aseman kapasiteetti etäkäyttäjää kohti +inMb: megatavuissa +bannerUrl: Bannerikuvan URL-linkki +pinnedUsers: Kiinnitetyt käyttäjät +pinnedPages: Kiinnitetyt sivut +pinnedClipId: Kiinnitettävän leikkeen ID +enableHcaptcha: Ota käyttöön hCaptcha-tunnistus +recaptcha: CAPTCHA uudelleen +enableRecaptcha: Ota käyttöön CAPTCHA uudelleen +recaptchaSiteKey: Sivuston avain +recaptchaSecretKey: Salausavain +silenceThisInstance: Hiljennä tämä instanssi +silencedInstancesDescription: Lista isäntänimistä, joka haluat hiljentää. Tilejä listassa + kohdellaan "hiljennettynä", ne voivat tehdä seuraajapyyntöjä ja eivät voi tehdä + mainintoja paikallistileistä jossei seurattu. Tämä ei vaikuta estettyihin instansseihin. +hiddenTagsDescription: 'Listaa aihetunnisteet (ilman #-merkkiä) aihetunnisteet, jotka + haluat piilottaa trendaavista ja Tutustu-osiosta. Piilotetut aihetunnisteet ovat + kuitenkin löydettävissä muilla keinoilla. Estetyt instanssit eivät vaikuta, vaikka + listattu tähän.' +currentPassword: Nykyinen salasana +newPassword: Uusi salasana +attachFile: Liitetyt tiedostot +keepOriginalUploadingDescription: Tallentaa alkuperäisen kuvan sellaisenaan. Jos kytketty + päältä, webissä näytettävä versio luodaan ylöslatauksen yhteydessä. +remove: Poista +circularReferenceFolder: Kohdekansio on kansion alikansio, jonka haluat siirtää. +deleteAreYouSure: Oletko varma, että haluat poistaa kokonaan" {x}"? +yearsOld: '{age} vuotias' +activity: Aktiivisuus +images: Kuvat +birthday: Syntymäpäivä +registeredDate: Liittynyt +location: Sijainti +disablingTimelinesInfo: Järjestelmänvalvojilla ja moderaattoreilla on aina pääsy kaikille + aikajanoille, vaikka olisikin poistettu käytöstä. +dayX: '{day}' +yearX: '{year}' +pages: Sivut +integration: Integraatiot +instanceDescription: Instanssin kuvaus +invite: Kutsu +iconUrl: Ikoni URL-linkki +pinnedUsersDescription: Listaa käyttäjänimet eroteltuna rivivaihdoin kiinnittääksesi + ne "Tutustu" välilehteen. +pinnedNotes: Kiinnitetyt viestit +hcaptcha: hCaptcha-tunnistus +antennaSource: Antennin lähde +invitationCode: Kutsukoodi +checking: Tarkistetaan... +passwordNotMatched: Ei vastaa +doing: Käsittelee... +category: Kategoria +tags: Tagit +disableAnimatedMfm: Poista MFM -animaatiot käytöstä +openImageInNewTab: Avaa kuvat uuteen välilehteen +dashboard: Kojelauta +local: Paikallinen +remote: Etä +total: Yhteensä +weekOverWeekChanges: Muutokset viime viikkoon +objectStorageRegion: Alue +popout: Ulosvedettävä +volume: Äänenvoimakkuus +masterVolume: Master äänenvoimakkuus +details: Yksityiskohdat +chooseEmoji: Valitse emoji +descendingOrder: Laskevasti +scratchpad: Raaputusalusta +output: Ulostulo +invisibleNote: Näkymätön viesti +enableInfiniteScroll: Lataa enemmän automaattisesti +visibility: Näkyvyys +useCw: Piilota sisältö +poll: Kysely +enablePlayer: Avaa videotoistimeen +enterFileDescription: Syötä tiedostokuvaus +author: Kirjoittaja +manage: Hallinta +description: Kuvaus +describeFile: Lisää tiedostokuvaus +height: Korkeus +large: Suuri +medium: Keskikokoinen +small: Pieni +other: Muu +create: Luo +regenerateLoginTokenDescription: Luo uudelleen kirjautumisen aikana sisäisesti käytettävän + tunnuksen. Normaalisti tämä toiminto ei ole tarpeen. Jos tunniste luodaan uudelleen, + kaikki laitteet kirjautuvat ulos. +setMultipleBySeparatingWithSpace: Erottele useat merkinnät välilyönneillä. +fileIdOrUrl: Tiedosto ID tai URL-linkki +behavior: Käytös +instanceTicker: Viestejä koskevat instanssitiedot +waitingFor: Odottaa {x} +random: Satunnainen +system: Järjestelmä +switchUi: Ulkoasu +createNew: Luo uusi +followersCount: Seuraajien määrä +renotedCount: Saatujen buustausten määrä +followingCount: Seurattujen tilien määrä +notSet: Ei asetettu +nUsers: '{n} Käyttäjää' +nNotes: '{n} Viestiä' +sendErrorReports: Lähetä virheraportteja +backgroundColor: Taustaväri +accentColor: Korostusväri +textColor: Tekstin väri +advanced: Edistynyt +saveAs: Tallenna nimellä... +invalidValue: Epäkelpo arvo. +registry: Rekisteri +closeAccount: Sulje tili +currentVersion: Nykyinen versio +capacity: Kapasiteetti +clear: Palaa +_theme: + explore: Tutustu teemoihin +silenceConfirm: Oletko varma, että haluat hiljentää tämän käyttäjän? +notesAndReplies: Viestit ja vastaukset +withFiles: Tiedostot sisältyvät +silence: Hiljennä +popularTags: Suositut tagit +userList: Listat +about: Tietoja +aboutIceshrimp: Tietoja Iceshrimpstä +exploreFediverse: Tutustu fediverseen +recentlyUpdatedUsers: Vastikään lisätyt käyttäjät +recentlyRegisteredUsers: Uudet liittyneet jäyttäjät +recentlyDiscoveredUsers: Vastikään löydetyt käyttäjät +exploreUsersCount: Täällä on {count} käyttäjää +share: Jaa +moderation: Sisällön valvonta +nUsersMentioned: Mainittu {n} käyttäjältä +securityKey: Turva-avain +securityKeyName: Avainnimi +registerSecurityKey: Rekisteröi turva-avain +lastUsed: Viimeksi käytetty +unregister: Poista rekisteröinti +passwordLessLogin: Salasanaton sisäänkirjautuminen +cacheClear: Tyhjennä välimuisti +markAsReadAllNotifications: Merkitse kaikki ilmoitukset luetuksi +markAsReadAllUnreadNotes: Merkitse kaikki viestit luetuiksi +uploadFolder: Oletuskansio ylöslatauksille +createGroup: Luo ryhmä +group: Ryhmä +groups: Ryhmät +ownedGroups: Omistetut ryhmät +help: Apua +inputMessageHere: Syötä viesti tähän +close: Sulje +joinedGroups: Liittyneet ryhmät +invites: Kutsut +groupName: Ryhmänimi +members: Jäsenet +language: Kieli +signinHistory: Kirjautumishistoria +docSource: Tämän dokumentin lähde +createAccount: Luo tili +existingAccount: Olemassa oleva tili +promotion: Edistetty +promote: Edistää +numberOfDays: Päivien määrä +accountSettings: Tilin asetukset +objectStorage: Objektitallennus +useObjectStorage: Käytä objektitallennusta +objectStorageBaseUrl: Perus URL-linkki +objectStorageBaseUrlDesc: "Viitteenä käytetty URL-linkki. Määritä CDN:n tai välityspalvelimen + URL-linkki, jos käytät kumpaakin.\nKäytä S3:lle 'https://.s3.amazonaws.com' + ja GCS:lle tai vastaaville palveluille 'https://storage.googleapis.com/' + jne." +objectStorageBucket: Kauha +newNoteRecived: Uusia viestejä +smtpPort: Portti +instanceMute: Instanssin mykistys +repliesCount: Lähetettyjen vastausten määrä +updatedAt: Päivitetty +notFound: Ei löydy +useOsNativeEmojis: Käytä käyttöjärjestelmän natiivi-Emojia +joinOrCreateGroup: Tule kutsutuksi ryhmään tai luo oma ryhmä. +text: Teksti +usernameInvalidFormat: Käytä isoja ja pieniä kirjaimia, numeroita ja erikoismerkkejä. +unsilenceConfirm: Oletko varma, että haluat poistaa käyttäjän hiljennyksen? +popularUsers: Suositut käyttäjät +moderator: Moderaattori +twoStepAuthentication: Kaksivaiheinen tunnistus +notFoundDescription: URL-linkkiin liittyvää sivua ei löytynyt. +antennaKeywords: Kuunneltavat avainsanat +antennaExcludeKeywords: Poislasketut avainsanat +antennaKeywordsDescription: Erottele välilyönneillä AND-ehtoa varten tai rivinvaihdolla + OR-ehtoa varten. +notifyAntenna: Ilmoita uusista viesteistä +withFileAntenna: Vain viestit tiedoston kanssa +enableServiceworker: Ota käyttöön Push-notifikaatiot selaimessasi +antennaUsersDescription: Luettele yksi käyttäjänimi rivi kohti +antennaInstancesDescription: Luettele yksi instanssi riviä kohti +caseSensitive: Isot ja pienet kirjaimet +withReplies: Sisällytä vastaukset +connectedTo: Seuraavat tili(t) on yhdistetty +unsilence: Poista hiljennys +administrator: Järjestelmänvalvoja +token: Merkki +resetPassword: Resetoi salasana +reduceUiAnimation: Vähennä käyttöliittymän animaatioita +transfer: Siirrä +messagingWithUser: Yksityisjuttelu +title: Otsikko +enable: Ota käyttöön +next: Seuraava +retype: Syötä uudelleen +noteOf: Lähettänyt {user} +inviteToGroup: Kutsu ryhmään +quoteAttached: Lainaus +quoteQuestion: Liitä lainauksena? +noMessagesYet: Ei vielä viestejä +newMessageExists: Uusia viestejä +onlyOneFileCanBeAttached: Voit liittää vain yhden tiedoston viestiin +signinRequired: Ole hyvä ja rekisteröidy tai kirjaudu sisään jatkaaksesi +invitations: Kutsut +available: Saatavilla +unavailable: Ei saatavissa +tooShort: Liian lyhyt +tooLong: Liian pitkä +weakPassword: Heikko salasana +normalPassword: Kohtalainen salasana +strongPassword: Vahva salasana +passwordMatched: Vastaa +signinWith: Kirjaudu sisään {x} +signinFailed: Ei voitu kirjautua sisään. Annettu käyttäjänimi tai salasana virheellinen. +tapSecurityKey: Napsauta turva-avaintasi +or: Tai +uiLanguage: Anna käyttöliittymän kieli +groupInvited: Sinut on kutsuttu ryhmään +aboutX: Tietoja {x} +disableDrawer: Älä käytä laatikkotyyppisiä valikoita +youHaveNoGroups: Sinulla ei ole ryhmiä +noHistory: Ei historiaa saatavilla +regenerate: Uudelleenluo +fontSize: Kirjasinkoko +dayOverDayChanges: Muutokset eiliseen +clientSettings: Asiakkaan asetukset +hideThisNote: Piilota tämä viesti +showFeaturedNotesInTimeline: Näytä esillä olevat viestit aikajanalla +objectStorageBucketDesc: Määritä palveluntarjoajasi käyttämä kauhan nimi. +objectStoragePrefix: Etuliite +objectStorageEndpoint: Päätepiste +objectStorageRegionDesc: Määritä alue, kuten "xx-east-1". Jos palvelusi ei tee eroa + alueiden välillä, jätä tämä kohta tyhjäksi tai kirjoita "us-east-1". +objectStorageUseSSL: Käytä SSL-salausta +objectStorageUseSSLDesc: Poista tämä käytöstä, jos et aio käyttää HTTPS:ää API-yhteyksissä +objectStorageUseProxy: Yhdistä välityspalvelimen kautta +objectStorageUseProxyDesc: Poista tämä käytöstä, jos et aio käyttää välityspalvelinta + API-yhteyksiä varten +objectStorageSetPublicRead: Aseta "public-read" ylöslataukseen +serverLogs: Palvelimen lokit +deleteAll: Poista kaikki +showFixedPostForm: Näytä viesti-ikkuna aikajanan yläpuolella +sounds: Äänet +listen: Kuuntele +none: Ei mitään +showInPage: Näytä sivulla +recentUsed: Vastikään käytetty +install: Asenna +uninstall: Poista asennus +installedApps: Hyväksytyt sovellukset +nothing: Ei nähtävää täällä +state: Tila +sort: Järjestä +ascendingOrder: Nousevasti +scratchpadDescription: Raaputusalusta tarjoaa ympäristön AiScript-kokeiluja varten. + Voit kirjoittaa, suorittaa ja tarkistaa sen tulokset vuorovaikutuksessa siinä olevan + Iceshrimpn kanssa. +script: Skripti +disablePagesScript: Poista AiScript käytöstä sivuilla +updateRemoteUser: Päivitä etäkäyttäjän tiedot +deleteAllFiles: Poista kaikki tiedostot +deleteAllFilesConfirm: Oletko varma, että haluat poistaa kaikki tiedostot? +removeAllFollowing: Poista seuraaminen kaikista seuratuista käyttäjistä +removeAllFollowingDescription: Tämän suorittaminen poistaa kaikki {host}:n tilit. + Suorita tämä, jos instanssia ei esimerkiksi enää ole olemassa. +userSuspended: Tämä käyttäjä on hyllytetty. +userSilenced: Tämä käyttäjä on hiljennetty. +yourAccountSuspendedTitle: Tämä tili on hyllytetty +yourAccountSuspendedDescription: Tämä tili on hyllytetty palvelimen palveluehtojen + tai vastaavien rikkomisen vuoksi. Ota yhteyttä ylläpitäjään, jos haluat tietää tarkemman + syyn. Älä luo uutta tiliä. +menu: Valikko +divider: Jakaja +addItem: Lisää kohde +relays: Releet +addRelay: Lisää rele +inboxUrl: Saavuneen postin URL +addedRelays: Lisätyt releet +serviceworkerInfo: Pitää ottaa käyttöön Push-notifikaatioissa. +deletedNote: Poistetut viestit +disablePlayer: Sulje videotoistin +expandTweet: Laajenna twiittiä +themeEditor: Teemaeditori +leaveConfirm: Tallentamattomia muutoksia olemassa. Hylätäänkö ne? +plugins: Liitännäiset +preferencesBackups: Asetusten varmuuskopiot +deck: Kansi +undeck: Jätä kansi +useBlurEffectForModal: Käytä blur-efektiä modaaleissa +useFullReactionPicker: Käytä täysikokoista reaktiovalitsinta +width: Leveys +generateAccessToken: Luo käyttöoikeustunniste +enableAll: Ota käyttöön kaikki +disableAll: Poista käytöstä kaikki +tokenRequested: Myönnä oikeus tiliin +notificationType: Ilmoituksen tyyppi +edit: Muokkaa +emailServer: Sähköpostipalvelin +enableEmail: Ota sähköpostin jakelu käyttöön +emailConfigInfo: Käytetään vahvistamaan sähköpostiosoitteesi rekisteröitymisen yhteydessä + tai jos unohdat salasanasi +email: Sähköposti +smtpHost: Isäntä +smtpUser: Käyttäjänimi +smtpPass: Salasana +emptyToDisableSmtpAuth: Jätä käyttäjänimi ja salasana tyhjäksi ohittaaksesi SMTP verifioinnin +smtpSecureInfo: Kytke tämä päältä kun käytät STARTTLS +testEmail: Kokeile email-lähetystä +wordMute: Sanan hiljennys +regexpError: Säännöllinen lausekevirhe +userSaysSomething: '{name} sanoi jotakin' +userSaysSomethingReason: '{name} sanoi {reason}' +makeActive: Aktivoi +display: Näyttö +copy: Kopioi +metrics: Mittarit +overview: Yleiskatsaus +logs: Lokit +delayed: Viivästynyt +database: Tietokanta +channel: Kanavat +notificationSetting: Ilmoitusasetukset +notificationSettingDesc: Valitse näytettävät ilmoitustyypit. +useGlobalSetting: Käytä globaaleja asetuksia +regenerateLoginToken: Luo kirjautumistunniste uudelleen +sample: Näyte +abuseReports: Raportit +reportAbuse: Raportti +reportAbuseOf: Raportti {name} +fillAbuseReportDescription: Täytä tätä raporttia koskevat tiedot. Jos se koskee tiettyä + viestiä, ilmoita sen URL-linkki. +abuseReported: Raporttisi on lähetetty. Kiitoksia paljon. +reporter: Raportoija +reporteeOrigin: Ilmoittajan alkuperä +reporterOrigin: Raportoijan alkuperä +forwardReport: Välitä raportti etäinstanssille +forwardReportIsAnonymous: Tilisi sijasta anonyymi järjestelmätili näytetään toimittajana + etäinstanssissa. +send: Lähetä +abuseMarkAsResolved: Merkitse raportti ratkaistuksi +openInNewTab: Avaa uuteen välilehteen +openInSideView: Avaa sivunäkymään +defaultNavigationBehaviour: Navigoinnin oletuskäyttäytyminen +editTheseSettingsMayBreakAccount: Näiden asetusten muuttaminen voi vahingoittaa tiliäsi. +desktop: Työpöytä +clip: Leike +optional: Vaihtoehtoinen +createNewClip: Luo uusi leike +unclip: Poista leike +confirmToUnclipAlreadyClippedNote: Tämä viesti on jo osa "{name}"-leikettä. Haluatko + sen sijaan poistaa sen tästä leikkeestä? +manageAccessTokens: Hallitse käyttöoikeuskoodeja +accountInfo: Tilin tiedot +notesCount: Viestien määrä +renotesCount: Lähetettyjen buustausten määrä +repliedCount: Saatujen vastausten määrä +sentReactionsCount: Lähetettyjen reaktioiden määrä +receivedReactionsCount: Saatujen reaktioiden määrä +pollVotesCount: Lähetettyjen kyselyäänien määrä +pollVotedCount: Saatujen kyselyäänien määrä +yes: Kyllä +no: Ei +driveFilesCount: Tiedostojen määrä asemalla +driveUsage: Aseman tilankäyttö +noCrawle: Hylkää hakukoneindeksointi +noCrawleDescription: Pyydä hakukoneita olemaan indeksoimatta profiilisivuasi, viestejäsi, + sivujasi jne. +alwaysMarkSensitive: Merkitse oletusarvoisesti herkäksi sisällöksi (NSFW) +loadRawImages: Alkuperäisten kuvien lataaminen pikkukuvien näyttämisen sijaan +disableShowingAnimatedImages: Älä näytä animoituja kuvia +verificationEmailSent: Vahvistussähköposti on lähetetty. Seuraa mukana olevaa linkkiä + suorittaaksesi vahvistuksen loppuun. +emailVerified: Sähköposti on vahvistettu +noteFavoritesCount: Kirjanmerkittyjen viestien määrä +pageLikedCount: Saatujen Sivu-tykkäysten määrä +pageLikesCount: Sivut-tykkäysten määrä +contact: Yhteystieto +useSystemFont: Käytä järjestelmän oletuskirjasinta +clips: Leikkeet +experimentalFeatures: Kokeiluluontoiset ominaisuudet +developer: Kehittäjä +makeExplorable: Tee tili näkyväksi osiossa "Tutustu" +makeExplorableDescription: Jos otat tämän pois käytöstä, tilisi ei näy "Tutustu"-osiossa. +showGapBetweenNotesInTimeline: Näytä väli viestien välissä aikajanalla +duplicate: Monista +left: Vasen +center: Keskellä +wide: Leveä +narrow: Kapea +reloadToApplySetting: Asetus otetaan käyttöön vain uudelleenladattaessa. Ladataanko + uudelleen nyt? +showTitlebar: Näytä otsikkorivi +clearCache: Tyhjennä välimuisti +onlineUsersCount: '{n} käyttäjää online-tilassa' +myTheme: Minun teemani +value: Arvo +saveConfirm: Tallenna muutokset? +deleteConfirm: Poistetaanko tosiaan? +latestVersion: Uusin versio +newVersionOfClientAvailable: Asiakasohjelmiston uudempi versio saatavilla. +usageAmount: Käyttö +inUse: Käytetty +editCode: Muokkaa koodia +apply: Käytä +receiveAnnouncementFromInstance: Vastaanota ilmoituksia tästä instanssista +emailNotification: Sähköposti-ilmoitukset +publish: Julkaise +inChannelSearch: Etsi kanavalta +useReactionPickerForContextMenu: Avaa reaktiovalitsin napsauttamalla oikeaa +typingUsers: '{users} kirjoittaa' +jumpToSpecifiedDate: Hyppää tiettyyn päivään +markAllAsRead: Merkitse kaikki luetuksi +goBack: Takaisin +unlikeConfirm: Poistatko todella tykkäyksesi? +fullView: Täysi koko +quitFullView: Poistu täydestä koosta +addDescription: Lisää kuvaus +markAsReadAllTalkMessages: Merkitse kaikki yksityisviestit luetuiksi +appearance: Ulkonäkö +messagingWithGroup: Ryhmäjuttelu +newPasswordIs: Uusi salasana on "{password}" +noFollowRequests: Sinulla ei ole odottavia seuraajapyyntöjä +objectStoragePrefixDesc: Tiedostot tallennetaan hakemistoihin tällä etuliitteellä. +objectStorageEndpointDesc: Jätä tämä tyhjäksi, jos käytät AWS S3:a. Muuten määritä + päätepisteeksi '' tai ':' käyttämästäsi palvelusta riippuen. +unableToProcess: Toimenpidettä ei voida suorittaa loppuun +installedDate: Hyväksynyt +lastUsedDate: Viimeksi käytetty +pluginTokenRequestedDescription: Tämä litännäinen voi käyttää tässä asetettuja käyttöoikeuksia. +permission: Oikeudet +smtpConfig: Lähtevän sähköpostin palvelimen (SMTP) asetukset +regexpErrorDescription: 'Säännöllisessä lausekkeessa tapahtui virhe rivillä {line} + sanan {tab} sanan mykistäminen rivillä {line}:' +emailAddress: Sähköpostiosoite +smtpSecure: Käytä implisiittistä SSL/TLS:ää SMTP-yhteyksissä +useGlobalSettingDesc: Jos se on päällä, käytetään tilisi ilmoitusasetuksia. Jos se + on pois päältä, voit tehdä yksilöllisiä asetuksia. +public: Julkinen +i18nInfo: Vapaaehtoiset kääntävät Iceshrimpta eri kielille. Voit auttaa osoitteessa + {link}. +lockedAccountInfo: Ellet aseta postauksen näkyvyydeksi "Vain seuraajille", postauksesi + näkyvät kaikille, vaikka vaatisitkin seuraajilta manuaalista hyväksyntää. +sendErrorReportsDescription: "Kun tämä on päällä, yksityiskohtaiset virhetiedot jaetaan + Iceshrimpn kanssa ongelman ilmetessä, mikä auttaa parantamaan Iceshrimpn laatua.\n + Näihin tietoihin sisältyy esimerkiksi käyttöjärjestelmäversio, käyttämäsi selain, + toimintasi Iceshrimpssä jne." +createdAt: Luotu +youAreRunningUpToDateClient: Käytössäsi on asiakasohjelman uusin versio. +needReloadToApply: Uudelleenlataus vaaditaan, jotta tämä näkyy. +showingPastTimeline: Näytetään parhaillaan vanhaa aikajanaa +userPagePinTip: Voit näyttää viestit täällä valitsemalla yksittäisten viestien valikosta + "Kiinnitä profiiliin". +notSpecifiedMentionWarning: Tämä viesti sisältää mainintoja käyttäjistä, joita ei + ole mainittu vastaanottajina +name: Nimi +allowedInstances: Sallitut (allowlisted) instanssit +hashtags: Aihetunnisteet +troubleshooting: Vianetsintä +received: Vastaanotettu +searchResult: Hakutulokset +filter: Suodatin +antennas: Antennit +noMaintainerInformationWarning: Ylläpitäjän tietoja ei ole konfiguroitu. +controlPanel: Hallintapaneeli +manageAccounts: Hallitse tilejä +makeReactionsPublic: Aseta reaktiohistoria julkiseksi +unread: Lukematon +deleted: Poistettu +editNote: Muokkaa viestiä +edited: 'Muokattu klo {date} {time}' +avoidMultiCaptchaConfirm: Useiden Captcha-järjestelmien käyttö voi aiheuttaa häiriöitä + niiden välillä. Haluatko poistaa käytöstä muut tällä hetkellä käytössä olevat Captcha-järjestelmät? + Jos haluat, että ne pysyvät käytössä, paina peruutusnäppäintä. +manageAntennas: Hallitse antenneja +info: Tietoja +userInfo: Käyttäjätiedot +unknown: Tuntematon +onlineStatus: Online-tila +hideOnlineStatus: Piilota Online-tila +hideOnlineStatusDescription: Online-tilasi piilottaminen vähentää joidenkin toimintojen, + kuten haun, käyttömukavuutta. +online: Online +active: Aktiivinen +offline: Offline +botProtection: Botti-suojaus +instanceBlocking: Federaatio Esto/Hiljennys +enabled: Otettu käyttöön +quickAction: Pikatoiminnot +user: Käyttäjä +accounts: Tilit +switch: Vaihda +noBotProtectionWarning: Botti-suojausta ei ole konfiguroitu. +configure: Konfiguroi +postToGallery: Luo uusi galleriaviesti +gallery: Galleria +recentPosts: Viimeaikaiset sivut +popularPosts: Suositut sivut +ads: Mainokset +expiration: Aikaraja +memo: Muistio +priority: Prioriteetti +high: Korkea +middle: Keskitaso +low: Alhainen +emailNotConfiguredWarning: Sähköpostiosoitetta ei ole asetettu. +ratio: Suhde +secureMode: Suojattu moodi (Valtuutettu nouto) +instanceSecurity: Instanssiturvallisuus +allowedInstancesDescription: Federaatiota varten sallitulle listalle (allowlisted) + otettavien instanssien isännät, kukin erotettuna uudella rivillä (sovelletaan vain + yksityisessä tilassa). +previewNoteText: Näytä esikatselu +customCss: Kustomoitu CSS +customCssWarn: Tätä asetusta tulisi käyttää vain, jos tiedät, mitä se tekee. Vääränlaisten + arvojen syöttäminen voi aiheuttaa sen, että asiakasohjelma lakkaa toimimasta normaalisti. +recommended: Suositeltu +squareAvatars: Näytä neliön malliset kuvakkeet +seperateRenoteQuote: Erilliset buustaa ja lainaa -napit +sent: Lähetetty +useBlurEffect: Käytä blur-efektejä käyttöliittymässä +iceshrimpUpdated: Iceshrimp on päivitetty! +whatIsNew: Näytä muutokset +translate: Käännä +translatedFrom: Käännetty kielestä {x} +accountDeletionInProgress: Tilin poistaminen on parhaillaan menossa +usernameInfo: Nimi, joka erottaa tilisi muista tällä palvelimella olevista tileistä. Voit + käyttää aakkosia (a~z, A~Z), numeroita (0~9) tai alaviivoja (_). Käyttäjätunnuksia + ei voi muuttaa myöhemmin. +aiChanMode: Ai-chan klassisessa käyttöliittymässä +keepCw: Pidä sisältövaroitukset +pubSub: Pub/Sub tilit +lastCommunication: Viimeisin kommunikaatio +unresolved: Ratkaisematon +breakFollow: Poista seuraaja +breakFollowConfirm: Oletko varma, että haluat poistaa seuraajan? +itsOn: Otettu käyttöön +itsOff: Poistettu käytöstä +emailRequiredForSignup: Vaadi sähköpostiosoitetta sisäänkirjautumiseen +makeReactionsPublicDescription: Tämä laittaa viimeisimmät reaktiosi julkisesti näkyväksi. +classic: Klassinen +muteThread: Mykistä lanka +unmuteThread: Poista langan mykistys +ffVisibility: Seurataan/Seurattavien näkyvyys +notRecommended: Ei suositeltu +disabled: Poistettu käytöstä +selectAccount: Valitse tili +switchAccount: Vaihda tili +administration: Hallinta +shareWithNote: Jaa viestin kanssa +secureModeInfo: Kun pyydät muista instansseista, älä lähetä takaisin ilman todisteita. +privateMode: Yksityinen moodi +privateModeInfo: Kun tämä on käytössä, vain sallittujen (allowlisted) luetteloon merkityt + instanssit voivat liittyä instansseihisi. Kaikki viestit piilotetaan yleisöltä. +global: Globaali +resolved: Ratkaistu +learnMore: Opi lisää +continueThread: Jatka lankaa +file: Tiedosto +cropImageAsk: Haluatko rajata tätä kuvaa? +recentNHours: Viimeiset {n} tuntia +rateLimitExceeded: Nopeusraja ylittynyt +cropImage: Rajaa kuvaa +socialTimeline: Sosiaalinen aikajana +themeColor: Instanssi Ticker Väri +check: Tarkista +ffVisibilityDescription: Antaa sinun konfiguroida, kuka voi nähdä ketä seuraat ja + kuka seuraa sinua. +homeTimeline: Koti aikajana +size: Koko +showLocalPosts: 'Näytä paikalliset viestit:' +oneDay: Päivä +instanceDefaultDarkTheme: Instanssikattava tumma oletusteema +recentNDays: Viimeiset {n} päivää +reflectMayTakeTime: Voi kestää jonkin aikaa, ennen kuin tämä näkyy. +failedToFetchAccountInformation: Ei voitu hakea tietoja +requireAdminForView: Sinun tulee kirjautua järjestelmänvalvojana nähdäksesi tämän. +driveCapOverrideCaption: Resetoi oletusarvoon syöttämällä arvo 0 tai alempi. +isSystemAccount: Järjestelmän luoma ja automaattisesti käyttämä tili. +userSaysSomethingReasonReply: '{name} vastasi viestiin sisältäen {reason}' +userSaysSomethingReasonRenote: '{name} buustasi viestiin sisältäen {reason}' +voteConfirm: Vahvista äänesi vaihtoehdolle "{choice}"? +hide: Piilota +leaveGroup: Poistu ryhmästä +leaveGroupConfirm: Oletko varma, että haluat poistua ryhmästä "{name}"? +clickToFinishEmailVerification: Klikkaa [{ok}] viimeistelläksesi sähköpostivahvistuksen. +overridedDeviceKind: Laitetyyppi +tablet: Tabletti +numberOfColumn: Sarakkeiden määrä +searchByGoogle: Etsi +mutePeriod: Vaiennuksen kesto +indefinitely: Pysyvästi +tenMinutes: 10 minuuttia +oneHour: Tunti +thereIsUnresolvedAbuseReportWarning: On ratkaisemattomia raportteja. +driveCapOverrideLabel: Muuta aseman kapasiteetti tälle käyttäjälle +userSaysSomethingReasonQuote: '{name} lainasi viestiä sisältäen {reason}' +deleteAccountConfirm: Tämä peruuttamattomasti poistaa tilisi. Jatketaanko? +incorrectPassword: Väärä salasana. +useDrawerReactionPickerForMobile: Näytä reaktiovalitsin mobiilissa laatikkomallisena +smartphone: Älypuhelin +auto: Automaattinen +oneWeek: Viikko +instanceDefaultLightTheme: Instanssin kattava vaalea oletusteema +instanceDefaultThemeDescription: Anna teemakoodi objektiformaatille. +noEmailServerWarning: Sähköpostipalvelinta ei konfiguroituna. +bite: Puraise +bitYourNote: puraisi postaustasi +_sensitiveMediaDetection: + description: Pienentää moderaatiovaateita tunnistamalla mediasensitiivisyyttä koneoppimista + käyttäen. Tämä kasvattaa serverin vaatimaa suorituskykyä hieman. + sensitivity: Tunnistussensitiivisyys + sensitivityDescription: Sensitiivisyyden pienennys aiheuttaa vähemmän väärintunnistuksia + ja kasvatus aiheuttaa vähemmän tunnistamattajättöä. + setSensitiveFlagAutomatically: Merkkaa sensitiiviseksi +biteBack: Puraise takaisin +bitYou: puraisi sinua +bitYouBack: puraisi sinua takaisin diff --git a/locales/fil.yml b/locales/fil.yml new file mode 100644 index 0000000..4c75aff --- /dev/null +++ b/locales/fil.yml @@ -0,0 +1,26 @@ +_lang_: "Filipino" +monthAndDay: '{month}/{day}' +noThankYou: Hindi, salamat +you: Ikaw +yearX: '{year}' +language: Wika +latestVersion: Pinakabagong bersiyon +textColor: Kulay ng teksto +_weekday: + monday: Lunes + tuesday: Martes + wednesday: Miyerkules + thursday: Huwebes + friday: Biyernes + saturday: Sabado +_widgets: + notifications: Mga Abiso + clock: Orasan + calendar: Kalendaryo +notifications: Mga Abiso +currentVersion: Kasalukuyang bersiyon +_tutorial: + step1_1: Maligayang pagdating! +_mfm: + url: URL +monthX: '{month}' diff --git a/locales/fr-FR.yml b/locales/fr-FR.yml new file mode 100644 index 0000000..bd25f32 --- /dev/null +++ b/locales/fr-FR.yml @@ -0,0 +1,2321 @@ +_lang_: "Français" +headlineIceshrimp: "Une plateforme de réseaux sociaux décentralisé, libre pour toujours + ! 🚀" +introIceshrimp: "Bienvenue ! Iceshrimp est un service de microblogage décentralisé, + libre et ouvert.\nPubliez des textes et médias dans le fédiverse 📡\nBoostez et/ou + citez les publications les plus intéressantes 📣\nAjoutez des réactions par émojis + aux publications des autres utilisateur·rice·s ❤️⭐️👍🎉🤣" +monthAndDay: "{day}/{month}" +search: "Rechercher" +notifications: "Notifications" +username: "Nom d’utilisateur·rice" +password: "Mot de passe" +forgotPassword: "Mot de passe oublié" +fetchingAsApObject: "Récupération depuis le fédiverse" +ok: "OK" +gotIt: "J’ai compris !" +cancel: "Annuler" +enterUsername: "Entrer un nom d’utilisateur·rice" +renotedBy: "Boosté par {user}" +noNotes: "Aucune publication" +noNotifications: "Aucune notification" +instance: "Instance" +settings: "Paramètres" +basicSettings: "Paramètres généraux" +otherSettings: "Paramètres avancés" +openInWindow: "Ouvrir dans une nouvelle fenêtre" +profile: "Profil" +timeline: "Fil" +noAccountDescription: "L’utilisateur·rice n’a pas encore renseigné de biographie de + présentation sur son profil." +login: "Se connecter" +loggingIn: "Connexion en cours" +logout: "Se déconnecter" +signup: "S’inscrire" +uploading: "Envoi en cours…" +save: "Enregistrer" +users: "Utilisateur·rice·s" +addUser: "Ajouter un·e utilisateur·rice" +favorite: "Ajouter aux favoris" +favorites: "Favoris" +unfavorite: "Retirer des favoris" +favorited: "Ajouté à mes favoris." +alreadyFavorited: "Déjà ajouté aux favoris." +cantFavorite: "Impossible d’ajouter aux favoris." +pin: "Épingler sur le profil" +unpin: "Désépingler" +copyContent: "Copier le contenu" +copyLink: "Copier le lien" +delete: "Supprimer" +deleteAndEdit: "Supprimer et réécrire" +deleteAndEditConfirm: "Êtes-vous sûr·e de vouloir supprimer cette publication et la + reformuler ? Vous perdrez toutes les réactions, boosts et réponses liées." +addToList: "Ajouter à une liste" +sendMessage: "Envoyer un message" +copyUsername: "Copier le nom d’utilisateur·rice" +searchUser: "Chercher un·e utilisateur·rice" +reply: "Répondre" +loadMore: "Charger plus" +showMore: "Afficher plus" +showLess: "Fermer" +youGotNewFollower: "Vous suit" +receiveFollowRequest: "Demande d’abonnement reçue" +followRequestAccepted: "La demande d’abonnement a été acceptée" +mention: "Mentionner" +mentions: "Mentions" +directNotes: "Messages directs" +importAndExport: "Import et export" +import: "Importer" +export: "Exporter" +files: "Fichiers" +download: "Télécharger" +driveFileDeleteConfirm: "Êtes-vous sûr·e de vouloir supprimer le fichier \"{name}\"\ + \ ? Il sera retiré de toutes les publications qui le contiennent comme pièce-jointe." +unfollowConfirm: "Désirez-vous vous désabonner de {name} ?" +exportRequested: "Vous avez demandé une exportation. L’opération pourrait prendre + un peu de temps. Une terminée, le fichier résultant sera ajouté au Drive." +importRequested: "Vous avez initié un import. Cela pourrait prendre un peu de temps." +lists: "Listes" +noLists: "Vous n’avez aucune liste" +note: "Publier" +notes: "Publications" +following: "Abonnements" +followers: "Abonné·e·s" +followsYou: "Vous suit" +createList: "Créer une liste" +manageLists: "Gérer les listes" +error: "Erreur" +somethingHappened: "Une erreur est survenue" +retry: "Réessayer" +pageLoadError: "Le chargement de la page a échoué." +pageLoadErrorDescription: "Cela est généralement causé par le cache du navigateur + ou par un problème réseau. Veuillez vider votre cache ou attendre un peu et réessayer." +serverIsDead: "Le serveur ne répond pas. Patientez quelques instants puis essayez + à nouveau." +youShouldUpgradeClient: "Si la page ne s’affiche pas correctement, rechargez-la pour + mettre votre client à jour." +enterListName: "Nom de la liste" +privacy: "Confidentialité" +makeFollowManuallyApprove: "Accepter manuellement les demandes d’abonnement" +defaultNoteVisibility: "Visibilité des publications par défaut" +follow: "S’abonner" +followRequest: "Demande d’abonnement" +followRequests: "Demandes d’abonnement" +unfollow: "Se désabonner" +followRequestPending: "Demande d’abonnement en attente de confirmation" +enterEmoji: "Insérer un émoji" +renote: "Booster" +unrenote: "Annuler le boost" +renoted: "Boosté." +cantRenote: "Cette publication ne peut pas être boosté." +cantReRenote: "Impossible de partager ce boost." +quote: "Citer" +pinnedNote: "Publication épinglée" +pinned: "Épingler sur le profil" +you: "Vous" +clickToShow: "Cliquer pour afficher" +sensitive: "Contenu sensible" +add: "Ajouter" +reaction: "Réactions" +reactionSetting: "Réactions à afficher dans le sélecteur de réactions" +reactionSettingDescription2: "Déplacer pour réorganiser, cliquer pour effacer, utiliser + « + » pour ajouter." +rememberNoteVisibility: "Réutiliser le paramètre de visibilité utilisée lors de la + publication précédente" +attachCancel: "Supprimer le fichier attaché" +markAsSensitive: "Marquer comme sensible (NSFW)" +unmarkAsSensitive: "Supprimer le marquage comme sensible (NSFW)" +enterFileName: "Entrer le nom du fichier" +mute: "Masquer" +unmute: "Ne plus masquer" +block: "Bloquer" +unblock: "Débloquer" +suspend: "Suspendre" +unsuspend: "Annuler la suspension" +blockConfirm: "Êtes-vous sûr·e de vouloir bloquer ce compte ?" +unblockConfirm: "Êtes-vous sûr·e de vouloir débloquer ce compte ?" +suspendConfirm: "Êtes-vous sûr·e de vouloir suspendre ce compte ?" +unsuspendConfirm: "Êtes-vous sûr·e de vouloir annuler la suspension de ce compte ?" +selectList: "Sélectionner une liste" +selectAntenna: "Sélectionner une antenne" +selectWidget: "Sélectionner un widget" +editWidgets: "Modifier les widgets" +editWidgetsExit: "Valider les modifications" +customEmojis: "Émojis personnalisés" +emoji: "Émoji" +emojis: "Émoji" +emojiName: "Nom de l’émoji" +emojiUrl: "URL de l’émoji" +addEmoji: "Ajouter un émoji" +settingGuide: "Configuration proposée" +cacheRemoteFiles: "Mise en cache des fichiers distants" +cacheRemoteFilesDescription: "Lorsque cette option est désactivée, les fichiers distants + sont chargés directement depuis l’instance distante. La désactiver diminuera certes + l’utilisation de l’espace de stockage local mais augmentera le trafic réseau puisque + les miniatures ne seront plus générées." +flagAsBot: "Ce compte est un robot 🤖" +flagAsBotDescription: "Si ce compte est géré de manière automatisée, choisissez cette + option. Si elle est activée, elle agira comme un marqueur pour les autres développeurs + afin d’éviter des chaînes d’interaction sans fin avec d’autres robots et d’ajuster + les systèmes internes de Iceshrimp pour traiter ce compte comme un robot." +flagAsCat: "Ce compte est un chat 🐱" +flagAsCatDescription: "Vous aurez des oreilles de chat et parlerez comme un chat !" +flagShowTimelineReplies: "Afficher les réponses dans le fil" +autoAcceptFollowed: "Accepter automatiquement les demandes d’abonnement venant d’utilisateur·rice·s + que vous suivez" +addAccount: "Ajouter un compte" +loginFailed: "Échec de la connexion" +showOnRemote: "Voir sur l’instance d’origine" +general: "Général" +wallpaper: "Fond d’écran" +setWallpaper: "Définir le fond d’écran" +removeWallpaper: "Supprimer le fond d’écran" +searchWith: "Recherche : {q}" +youHaveNoLists: "Vous n’avez aucune liste" +followConfirm: "Êtes-vous sûr·e de vouloir suivre {name} ?" +proxyAccount: "Compte proxy" +proxyAccountDescription: "Un compte proxy se comporte, dans certaines conditions, + comme un·e abonné·e distant·e pour les utilisateur·rice·s d’autres instances. Par + exemple, quand un·e utilisateur·rice local ajoute un·e utilisateur·rice distant·e + à une liste, ses publications ne seront pas visibles sur l’instance si personne + ne suit cet·te utilisateur·rice. Le compte proxy va donc suivre cet·te utilisateur·rice + pour que ses publications soient acheminées." +host: "Serveur distant" +selectUser: "Sélectionner un·e utilisateur·rice" +recipient: "Destinataire" +annotation: "Commentaires" +federation: "Fédération" +instances: "Instance" +registeredAt: "Premier contact le" +latestRequestSentAt: "Dernière requête envoyée" +latestRequestReceivedAt: "Dernière requête reçue" +latestStatus: "Dernier statut" +storageUsage: "Stockage utilisé" +charts: "Graphiques" +perHour: "par heure" +perDay: "par jour" +stopActivityDelivery: "Arrêter l’envoi de l’activité" +blockThisInstance: "Bloquer cette instance" +operations: "Opérations" +software: "Logiciel" +version: "Version" +metadata: "Métadonnées" +monitor: "Contrôle" +jobQueue: "File d’attente" +cpuAndMemory: "Processeur et mémoire" +network: "Réseau" +disk: "Disque" +instanceInfo: "Informations sur l’instance" +statistics: "Statistiques" +clearQueue: "Vider la file d’attente" +clearQueueConfirmTitle: "Êtes-vous sûr·e de vouloir vider la file d’attente ?" +clearQueueConfirmText: "Les publications non distribuées ne seront pas délivrées. + Normalement, vous n’avez pas besoin d’effectuer cette opération." +clearCachedFiles: "Vider le cache" +clearCachedFilesConfirm: "Êtes-vous sûr·e de vouloir vider tout le cache de fichiers + distants ?" +blockedInstances: "Instances bloquées" +blockedInstancesDescription: "Listez les instances que vous désirez bloquer, un par + ligne. Ces instances ne seront plus en capacité d’interagir avec la votre." +muteAndBlock: "Masqué·e·s / Bloqué·e·s" +mutedUsers: "Utilisateur·rice·s en sourdine" +blockedUsers: "Utilisateur·rice·s bloqué·e·s" +noUsers: "Il n’y a pas d’utilisateur·rice·s" +editProfile: "Modifier votre profil" +noteDeleteConfirm: "Êtes-vous sûr·e de vouloir supprimer cette publication ?" +pinLimitExceeded: "Vous ne pouvez pas épingler plus de publications" +intro: "L’installation de Iceshrimp est terminée ! Veuillez créer un compte administrateur·rice." +done: "Terminé" +processing: "Traitement en cours…" +preview: "Aperçu" +default: "Par défaut" +noCustomEmojis: "Il n’y a pas d’émoji" +noJobs: "Il n’y a aucune tâche planifiée" +federating: "En cours de fédération" +blocked: "Bloqué·e" +suspended: "Suspendu·e" +all: "Tous" +subscribing: "Abonné" +publishing: "Publié" +notResponding: "Ne répond pas" +instanceFollowing: "Abonnements de l’instance" +instanceFollowers: "Abonné·e·s de l’instance" +instanceUsers: "Utilisateur·rice·s de cette l’instance" +changePassword: "Modifier votre mot de passe" +security: "Sécurité" +retypedNotMatch: "Les saisies ne correspondent pas." +currentPassword: "Mot de passe actuel" +newPassword: "Nouveau mot de passe" +newPasswordRetype: "Répéter le nouveau mot de passe" +attachFile: "Joindre un fichier" +more: "Plus" +featured: "Tendances" +usernameOrUserId: "Nom d’utilisateur·rice ou ID utilisateur" +noSuchUser: "Utilisateur·rice non trouvé·e" +lookup: "Recherche" +announcements: "Annonces" +imageUrl: "URL de l’image" +remove: "Supprimer" +removed: "Supprimé" +removeAreYouSure: "Êtes-vous sûr·e de vouloir supprimer «{x}» ?" +deleteAreYouSure: "Êtes-vous sûr·e de vouloir supprimer «{x}» ?" +resetAreYouSure: "Êtes-vous sûr de vouloir réinitialiser ?" +saved: "Enregistré" +messaging: "Discuter" +upload: "Téléverser" +keepOriginalUploading: "Garder l’image d’origine" +fromDrive: "Depuis le Drive" +fromUrl: "Depuis une URL" +uploadFromUrl: "Téléverser via une URL" +uploadFromUrlDescription: "URL du fichier que vous souhaitez téléverser" +uploadFromUrlRequested: "Téléversement demandé" +uploadFromUrlMayTakeTime: "Le téléversement de votre fichier peut prendre un certain + temps." +explore: "Découvrir" +messageRead: "Lu" +noMoreHistory: "Il n’y a plus d’historique" +startMessaging: "Commencer à discuter" +nUsersRead: "Lu par {n} personnes" +agreeTo: "J’accepte {0}" +tos: "Informations & Charte de l’instance" +start: "Commencer" +home: "Principal" +remoteUserCaution: "Les informations de ce compte risqueraient d’être incomplètes + du fait que l’utilisateur·rice provient d’une instance distante." +activity: "Activité" +images: "Images" +birthday: "Date de naissance" +yearsOld: "{age} ans" +registeredDate: "Inscrit le" +location: "Localisation" +theme: "Thème" +themeForLightMode: "Thème à utiliser en Mode Clair" +themeForDarkMode: "Thème à utiliser en Mode Sombre" +light: "Clair" +dark: "Sombre" +lightThemes: "Thèmes clairs" +darkThemes: "Thèmes sombres" +syncDeviceDarkMode: "Utiliser le mode sombre de votre appareil" +drive: "Drive" +fileName: "Nom du fichier" +selectFile: "Choisir le fichier" +selectFiles: "Choisir les fichiers" +selectFolder: "Sélectionnez un dossier" +selectFolders: "Sélectionnez des dossiers" +renameFile: "Renommer le fichier" +folderName: "Nom du dossier" +createFolder: "Créer un dossier" +renameFolder: "Renommer le dossier" +deleteFolder: "Supprimer le dossier" +addFile: "Ajouter un fichier" +emptyDrive: "Le Drive est vide" +emptyFolder: "Le dossier est vide" +unableToDelete: "Suppression impossible" +inputNewFileName: "Entrez un nouveau nom de fichier" +inputNewDescription: "Veuillez entrer une nouvelle description" +inputNewFolderName: "Entrez un nouveau nom de dossier" +circularReferenceFolder: "Le dossier de destination est un sous-dossier du dossier + que vous souhaitez déplacer." +hasChildFilesOrFolders: "Impossible de supprimer ce dossier car il n’est pas vide." +copyUrl: "Copier l’URL" +rename: "Renommer" +avatar: "Avatar" +banner: "Bannière" +nsfw: "Contenu sensible (NSFW)" +whenServerDisconnected: "Lorsque la connexion au serveur est perdue" +disconnectedFromServer: "Déconnecté·e du serveur" +reload: "Rafraîchir" +doNothing: "Ignorer" +reloadConfirm: "Voulez-vous recharger le fil ?" +watch: "Surveiller" +unwatch: "Ne plus surveiller" +accept: "Autoriser" +reject: "Refuser" +normal: "Normal" +instanceName: "Nom de l’instance" +instanceDescription: "Description de l’instance" +maintainerName: "L’administrateur·rice" +maintainerEmail: "Email de l’administrateur·rice" +tosUrl: "URL des conditions d’utilisation" +thisYear: "Cette année" +thisMonth: "Ce mois-ci" +today: "Aujourd’hui" +dayX: "{day}" +monthX: "{month}" +yearX: "{year}" +pages: "Pages" +integration: "Intégrations" +connectService: "Connexion" +disconnectService: "Déconnexion" +enableLocalTimeline: "Activer le fil local" +enableGlobalTimeline: "Activer le fil global" +disablingTimelinesInfo: "Même si vous désactivez ces fils, les administrateur·rice·s + et les modérateur·rice·s pourront toujours y accéder." +registration: "S’inscrire" +enableRegistration: "Autoriser les nouvelles inscriptions" +invite: "Inviter" +driveCapacityPerLocalAccount: "Volume du Drive par utilisateur local" +driveCapacityPerRemoteAccount: "Volume du Drive par utilisateur distant" +inMb: "en mégaoctets" +iconUrl: "URL de l’icône" +bannerUrl: "URL de l’image de la bannière" +backgroundImageUrl: "URL de l’image d’arrière-plan" +basicInfo: "Informations basiques" +pinnedUsers: "Utilisateur·rice épinglé·e" +pinnedUsersDescription: "Listez les utilisateur·rice·s que vous souhaitez voir épinglé·e·s + sur la page \"Découvrir\", un·e par ligne." +pinnedPages: "Pages épinglées" +pinnedPagesDescription: "Inscrivez le chemin des Pages que vous souhaitez épingler + en haut de la page de l’instance. Séparez les d’un retour à la ligne." +pinnedClipId: "Identifiant du clip épinglé" +pinnedNotes: "Publications épinglées" +hcaptcha: "hCaptcha" +enableHcaptcha: "Activer hCaptcha" +hcaptchaSiteKey: "Clé du site" +hcaptchaSecretKey: "Clé secrète" +recaptcha: "reCAPTCHA" +enableRecaptcha: "Activer reCAPTCHA" +recaptchaSiteKey: "Clé du site" +recaptchaSecretKey: "Clé secrète" +avoidMultiCaptchaConfirm: "L’utilisation de plusieurs Captchas peut provoquer des + interférences. Souhaitez-vous désactiver l’autre Captcha ? Vous pouvez laisser plusieurs + Captcha activés en appuyant sur Annuler." +antennas: "Antennes" +manageAntennas: "Gestion des antennes" +name: "Nom" +antennaSource: "Source de l’antenne" +antennaKeywords: "Mots clés à recevoir" +antennaExcludeKeywords: "Mots clés à exclure" +antennaKeywordsDescription: "Séparer avec des espaces pour la condition AND. Séparer + avec un saut de ligne pour une condition OR." +notifyAntenna: "Je souhaite recevoir les notifications des nouvelles publications" +withFileAntenna: "Publications ayant des pièces-jointes uniquement" +enableServiceworker: "Activer ServiceWorker" +antennaUsersDescription: "Saisissez un seul nom d’utilisateur·rice par ligne" +caseSensitive: "Sensible à la casse" +withReplies: "Inclure les réponses" +connectedTo: "Vous êtes connectés aux services suivants" +notesAndReplies: "Publications et Réponses" +withFiles: "Avec fichiers joints" +silence: "Mettre en sourdine" +silenceConfirm: "Êtes-vous sûr·e de vouloir mettre l’utilisateur·rice en sourdine + ?" +unsilence: "Annuler la sourdine" +unsilenceConfirm: "Êtes-vous sûr·e de vouloir annuler la mise en sourdine de cet·te + utilisateur·rice ?" +popularUsers: "Utilisateur·rice·s populaires" +recentlyUpdatedUsers: "Utilisateur·rice·s actif·ve·s récemment" +recentlyRegisteredUsers: "Utilisateur·rice·s récemment inscrit·e·s" +recentlyDiscoveredUsers: "Utilisateur·rice·s récemment découvert·e·s" +exploreUsersCount: "Il y a {count} utilisateur·rice·s" +exploreFediverse: "Explorer le Fediverse" +popularTags: "Mots-clés populaires" +userList: "Listes" +about: "Informations" +aboutIceshrimp: "À propos de Iceshrimp" +administrator: "Administrateur·rice" +token: "Jeton" +twoStepAuthentication: "Authentification à deux facteurs" +moderator: "Modérateur·rice·s" +nUsersMentioned: "{n} utilisateur·rice·s mentionné·e·s" +securityKey: "Clé de sécurité" +securityKeyName: "Nom de la clé" +registerSecurityKey: "Enregistrer une clé de sécurité" +lastUsed: "Dernier utilisé" +unregister: "Se désinscrire" +passwordLessLogin: "Se connecter sans mot de passe" +resetPassword: "Réinitialiser le mot de passe" +newPasswordIs: "Votre nouveau mot de passe est \"{password}\"" +reduceUiAnimation: "Réduire les animations dans l’interface" +share: "Partager" +notFound: "Non trouvé" +notFoundDescription: "Aucune page ne correspond à l’URL spécifiée." +uploadFolder: "Emplacement de téléversement par défaut" +cacheClear: "Vider le cache" +markAsReadAllNotifications: "Marquer toutes les notifications comme lues" +markAsReadAllUnreadNotes: "Marquer toutes les publications comme lues" +markAsReadAllTalkMessages: "Marquer toutes les discussions comme lues" +help: "Aide" +inputMessageHere: "Écrivez votre message ici" +close: "Fermer" +group: "Groupe" +groups: "Groupes" +createGroup: "Créer un groupe" +ownedGroups: "Mes groupes" +joinedGroups: "Groupes rejoints" +invites: "Invitations" +groupName: "Nom du groupe" +members: "Membres" +transfer: "Transférer" +messagingWithUser: "Discuter avec un·e autre utilisateur·rice" +messagingWithGroup: "Discuter avec un groupe" +title: "Titre" +text: "Texte" +enable: "Activer" +next: "Suivant" +retype: "Confirmation" +noteOf: "Publications de {user}" +inviteToGroup: "Inviter dans un groupe" +quoteAttached: "Avec citation" +quoteQuestion: "Souhaitez-vous ajouter une citation ?" +noMessagesYet: "Pas encore de discussion" +newMessageExists: "Vous avez un nouveau message" +onlyOneFileCanBeAttached: "Vous ne pouvez joindre qu’un seul fichier au message" +signinRequired: "Veuillez vous connecter" +invitations: "Invitations" +invitationCode: "Code d’invitation" +checking: "Vérification en cours…" +available: "Disponible" +unavailable: "Non disponible" +usernameInvalidFormat: "Le nom d’utilisateur peut contenir uniquement des lettres + (minuscules et/ou majuscules), des chiffres et des _ (tirets du bas)." +tooShort: "Trop court" +tooLong: "Trop long" +weakPassword: "Mot de passe faible" +normalPassword: "Mot de passe acceptable" +strongPassword: "Mot de passe fort" +passwordMatched: "Les mots de passe correspondent" +passwordNotMatched: "Les mots de passe ne correspondent pas" +signinWith: "Se connecter avec {x}" +signinFailed: "Échec d’authentification. Veuillez vérifier que votre nom d’utilisateur + et mot de passe sont corrects." +tapSecurityKey: "Appuyez sur votre clé de sécurité" +or: "OU" +language: "Langue" +uiLanguage: "Langue d’affichage de l’interface" +groupInvited: "Invité au groupe" +aboutX: "À propos de {x}" +useOsNativeEmojis: "Utiliser les émojis natifs du système" +youHaveNoGroups: "Vous n’avez aucun groupe" +joinOrCreateGroup: "Vous pouvez être invité·e à rejoindre des groupes existants ou + créer votre propre nouveau groupe." +noHistory: "Pas d’historique" +signinHistory: "Historique de connexion" +disableAnimatedMfm: "Désactiver MFM ayant des animations" +doing: "En cours…" +category: "Catégorie" +tags: "Étiquettes" +docSource: "Source de ce document" +createAccount: "Créer un compte" +existingAccount: "Compte existant" +regenerate: "Générer à nouveau" +fontSize: "Taille de la police" +noFollowRequests: "Vous n’avez aucune demande d’abonnement en attente" +openImageInNewTab: "Ouvrir les images dans un nouvel onglet" +dashboard: "Tableau de bord" +local: "Local" +remote: "Distant" +total: "Total" +weekOverWeekChanges: "Hebdomadaire" +dayOverDayChanges: "Journalier" +appearance: "Apparence" +clientSettings: "Paramètres du client" +accountSettings: "Paramètres du compte" +promotion: "Promu" +promote: "Promouvoir" +numberOfDays: "Nombre de jours" +hideThisNote: "Masquer cette publication" +showFeaturedNotesInTimeline: "Afficher les publications des Tendances dans le fil + d’actualité" +objectStorage: "Stockage d’objets" +useObjectStorage: "Utiliser le stockage d’objets" +objectStorageBaseUrl: "URL racine" +objectStorageBaseUrlDesc: "Préfixe d’URL utilisé pour construire l’URL vers le référencement + d’objet (média). Spécifiez son URL si vous utilisez un CDN ou un proxy, sinon spécifiez + l’adresse accessible au public selon le guide de service que vous allez utiliser.\n\ + \ Ex : «https://.s3.amazonaws.com» pour AWS S3 et «https://storage.googleapis.com/» + pour GCS." +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "Veuillez spécifier le nom du compartiment utilisé sur le + service configuré." +objectStoragePrefix: "Prefix" +objectStoragePrefixDesc: "Les fichiers seront stockés sous le répertoire de ce préfixe." +objectStorageEndpoint: "Endpoint" +objectStorageEndpointDesc: "Laissez ce champ vide si vous utilisez AWS S3, sinon spécifiez + le point de terminaison comme «» ou «:» selon le guide de service + que vous allez utiliser." +objectStorageRegion: "Région" +objectStorageRegionDesc: "Spécifiez une région comme «xx-east-1». Si votre service + ne fait pas de distinction entre les régions, laissez-le vide ou remplissez «us-east-1»." +objectStorageUseSSL: "Utiliser SSL" +objectStorageUseSSLDesc: "Désactivez cette option si vous n’utilisez pas HTTPS pour + la connexion API" +objectStorageUseProxy: "Se connecter via proxy" +objectStorageUseProxyDesc: "Désactivez cette option si vous n’utilisez pas de proxy + pour la connexion API" +objectStorageSetPublicRead: "Régler sur « public » lors de l’envoi" +serverLogs: "Journal du serveur" +deleteAll: "Supprimer tout" +showFixedPostForm: "Afficher le formulaire de publication en haut du fil d’actualité" +newNoteRecived: "Voir les nouvelles publications" +sounds: "Sons" +listen: "Écouter" +none: "Rien" +showInPage: "Afficher dans la page" +popout: "Fenêtre contextuelle" +volume: "Volume" +masterVolume: "Volume principal" +details: "Détails" +chooseEmoji: "Choisissez un émoji" +unableToProcess: "L’opération n’a pas pu être complétée" +recentUsed: "Utilisé récemment" +install: "Installation" +uninstall: "Désinstaller" +installedApps: "Applications installées" +nothing: "Il n’y a rien à voir ici" +installedDate: "Date d’installation" +lastUsedDate: "Dernière utilisation" +state: "État" +sort: "Trier" +ascendingOrder: "Ascendant" +descendingOrder: "Descendant" +scratchpad: "ScratchPad" +scratchpadDescription: "ScratchPad fournit un environnement expérimental pour AiScript. + Vous pouvez vérifier la rédaction de votre code, sa bonne exécution et le résultat + de son interaction avec Iceshrimp." +output: "Sortie" +script: "Script" +disablePagesScript: "Désactiver AiScript sur les Pages" +updateRemoteUser: "Mettre à jour les informations de l’utilisateur·rice distant·e" +deleteAllFiles: "Supprimer tous les fichiers" +deleteAllFilesConfirm: "Êtes-vous sûr·e de vouloir supprimer tous les fichiers ?" +removeAllFollowing: "Retenir tous les abonnements" +removeAllFollowingDescription: "Se désabonner de tous les comptes de {host}. Veuillez + lancer cette action uniquement si l’instance n’existe plus." +userSuspended: "Cet·te utilisateur·rice a été suspendu·e." +userSilenced: "Cette utilisateur·rice est en sourdine." +yourAccountSuspendedTitle: "Ce compte est suspendu" +yourAccountSuspendedDescription: "Ce compte est suspendu car vous avez enfreint les + conditions d’utilisation de l’instance, ou pour un motif similaire. Si vous souhaitez + connaître en détail les raisons de cette suspension, renseignez-vous auprès de l’administrateur·rice + de votre instance. Merci de ne pas créer de nouveau compte." +menu: "Menu" +divider: "Séparateur" +addItem: "Ajouter un élément" +relays: "Relais" +addRelay: "Ajouter un relais" +inboxUrl: "URL de la boîte de récéption" +addedRelays: "Relais ajoutés" +serviceworkerInfo: "Devrait être activé pour les notifications push." +deletedNote: "Publication supprimée" +invisibleNote: "Publication invisible" +enableInfiniteScroll: "Activer le défilement infini" +visibility: "Visibilité" +poll: "Sondage" +useCw: "Masquer le contenu" +enablePlayer: "Ouvrir dans le lecteur vidéo" +disablePlayer: "Fermer le lecteur vidéo" +expandTweet: "Étendre le tweet" +themeEditor: "Éditeur de thèmes" +description: "Description" +describeFile: "Ajouter une description d’image" +enterFileDescription: "Saisissez une description" +author: "Auteur·rice" +leaveConfirm: "Vous avez des modifications non-sauvegardées. Voulez-vous les ignorer + ?" +manage: "Gestion" +plugins: "Extensions" +deck: "Deck" +undeck: "Quitter le deck" +useBlurEffectForModal: "Utiliser un effet de flou pour les modals" +useFullReactionPicker: "Utiliser l’intégralité du panneau de réactions" +width: "Largeur" +height: "Hauteur" +large: "Grand" +medium: "Moyen" +small: "Petit" +generateAccessToken: "Générer un jeton d’accès" +permission: "Autorisations" +enableAll: "Tout activer" +disableAll: "Tout désactiver" +tokenRequested: "Autoriser l’accès au compte" +pluginTokenRequestedDescription: "Ce plugin pourra utiliser les autorisations définies + ici." +notificationType: "Type de notifications" +edit: "Editer" +emailServer: "Serveur mail" +enableEmail: "Activer la distribution de courriel" +emailConfigInfo: "Utilisé pour confirmer votre adresse de courriel et la réinitialisation + de votre mot de passe en cas d’oubli" +email: "E-mail" +emailAddress: "Adresses e-mail" +smtpConfig: "Paramètres du serveur SMTP" +smtpHost: "Serveur distant" +smtpPort: "Port" +smtpUser: "Nom d’utilisateur·rice" +smtpPass: "Mot de passe" +emptyToDisableSmtpAuth: "Laisser le nom d’utilisateur et le mot de passe vides pour + désactiver la vérification SMTP" +smtpSecure: "Utiliser SSL/TLS implicitement dans les connexions SMTP" +smtpSecureInfo: "Désactiver cette option lorsque STARTTLS est utilisé" +testEmail: "Tester la distribution de courriel" +wordMute: "Filtre de mots" +regexpError: "Erreur d’expression régulière" +instanceMute: "Instance en sourdine" +userSaysSomething: "{name} a dit quelque chose" +makeActive: "Activer" +display: "Affichage" +copy: "Copier" +metrics: "Métriques" +overview: "Aperçu" +logs: "Journaux" +delayed: "en retard" +database: "Base de données" +channel: "Chaînes" +create: "Créer" +notificationSetting: "Paramètres des notifications" +notificationSettingDesc: "Sélectionnez le type de notification à afficher." +useGlobalSetting: "Utiliser paramètre général" +useGlobalSettingDesc: "S’il est activé, les paramètres de notification de votre compte + seront utilisés. S’il est désactivé, des configurations individuelles peuvent être + effectuées." +other: "Autre" +regenerateLoginToken: "Régénérer le jeton de connexion" +regenerateLoginTokenDescription: "Générer un nouveau jeton d’authentification. Cette + opération ne devrait pas être nécessaire ; lors de la génération d’un nouveau jeton, + tous les appareils seront déconnectés." +setMultipleBySeparatingWithSpace: "Vous pouvez en définir plusieurs, en les séparant + par des espaces." +fileIdOrUrl: "ID du fichier ou URL" +behavior: "Comportement" +sample: "Exemple" +abuseReports: "Signalements" +reportAbuse: "Signaler" +reportAbuseOf: "Signaler {name}" +fillAbuseReportDescription: "Veuillez expliquer les raisons du signalement. S’il s’agit + d’une publication en particulier, veuillez inclure le lien." +abuseReported: "Le rapport est envoyé. Merci." +reporter: "Signalé par" +reporteeOrigin: "Origine du signalement" +reporterOrigin: "Signalé par" +forwardReport: "Transférer le signalement à l’instance distante" +send: "Envoyer" +abuseMarkAsResolved: "Marquer le signalement comme résolu" +openInNewTab: "Ouvrir dans un nouvel onglet" +openInSideView: "Ouvrir en vue latérale" +defaultNavigationBehaviour: "Navigation par défaut" +editTheseSettingsMayBreakAccount: "La modification de ces paramètres peut endommager + votre compte." +instanceTicker: "Nom de l’instance d’origine des publications" +waitingFor: "En attente de {x}" +random: "Aléatoire" +system: "Système" +switchUi: "Mise en page" +desktop: "Bureau" +clip: "Clip" +createNew: "Créer nouveau" +optional: "Facultatif" +createNewClip: "Créer un nouveau clip" +public: "Public" +i18nInfo: "Iceshrimp est traduit dans différentes langues par des bénévoles. Vous + pouvez contribuer à {link}." +manageAccessTokens: "Gérer les jetons d’accès" +accountInfo: "Informations du compte" +notesCount: "Nombre de publications" +repliesCount: "Nombre de réponses envoyées" +renotesCount: "Nombre de boosts que vous avez envoyé" +repliedCount: "Nombre de réponses reçues" +renotedCount: "Nombre de vos publications boostées" +followingCount: "Nombre de comptes suivis" +followersCount: "Nombre d’abonnés" +sentReactionsCount: "Nombre de réactions envoyées" +receivedReactionsCount: "Nombre de réactions reçues" +pollVotesCount: "Nombre de votes envoyés" +pollVotedCount: "Nombre de votes reçus" +yes: "Oui" +no: "Non" +driveFilesCount: "Nombre de fichiers dans le Drive" +driveUsage: "Utilisation du Drive" +noCrawle: "Refuser l’indexation par les robots" +noCrawleDescription: "Demandez aux moteurs de recherche de ne pas indexer votre page + de profil, vos publications, vos pages, etc." +lockedAccountInfo: "À moins que vous ne définissiez la visibilité de votre publication + sur \"Abonné-e-s\", vos publications sont visibles par tous, même si vous exigez + que les demandes d’abonnement soient approuvées manuellement." +alwaysMarkSensitive: "Marquer les médias comme contenu sensible (NSFW) par défaut" +loadRawImages: "Affichage complet des images jointes au lieu des vignettes" +disableShowingAnimatedImages: "Désactiver l’animation des images" +verificationEmailSent: "Un e-mail de vérification a été envoyé. Veuillez accéder au + lien pour compléter la vérification." +notSet: "Non défini" +emailVerified: "Votre adresse e-mail a été vérifiée" +noteFavoritesCount: "Nombre de publications dans les favoris" +pageLikesCount: "Nombre de pages aimées" +pageLikedCount: "Nombre de vos pages aimées" +contact: "Contact" +useSystemFont: "Utiliser la police par défaut du système" +clips: "Clips" +experimentalFeatures: "Fonctionnalités expérimentales" +developer: "Développeur" +makeExplorable: "Rendre le compte visible sur la page \"Découvrir\"" +makeExplorableDescription: "Si vous désactivez cette option, votre compte n’apparaîtra + pas sur la page \"Découvrir\"." +showGapBetweenNotesInTimeline: "Afficher un écart entre les publications du fil d’actualité" +duplicate: "Duliquer" +left: "Gauche" +center: "Centrer" +wide: "Large" +narrow: "Condensé" +reloadToApplySetting: "Vos paramètres seront appliqués lorsque vous rechargerez la + page. Souhaitez-vous recharger ?" +needReloadToApply: "Ce paramètre s’appliquera après un rechargement." +showTitlebar: "Afficher la barre de titre" +clearCache: "Vider le cache" +onlineUsersCount: "{n} utilisateur(s) en ligne" +nUsers: "{n} utilisateur·rice·s" +nNotes: "{n} Publications" +sendErrorReports: "Envoyer les rapports d’erreur" +sendErrorReportsDescription: "Si vous activez l’envoi des rapports d’erreur, vous + contribuerez à améliorer la qualité de Iceshrimp grâce au partage d’informations + détaillées sur les erreurs lorsqu’un problème survient.\nCela inclut des informations + telles que la version de votre système d’exploitation, le type de navigateur que + vous utilisez, votre historique d’activité, etc." +myTheme: "Mes thèmes" +backgroundColor: "Arrière-plan" +accentColor: "Accentuation" +textColor: "Texte" +saveAs: "Enregistrer sous…" +advanced: "Avancé" +value: "Valeur" +createdAt: "Date de création" +updatedAt: "Mis à jour le" +saveConfirm: "Voulez-vous sauvegarder les modifications ?" +deleteConfirm: "Confirmez-vous la suppression ?" +invalidValue: "Cette valeur est invalide." +registry: "Registre" +closeAccount: "Fermer le compte" +currentVersion: "Version actuelle" +latestVersion: "Dernière version " +youAreRunningUpToDateClient: "Votre client est à jour." +newVersionOfClientAvailable: "Une nouvelle version de votre client est disponible." +usageAmount: "Utilisation" +capacity: "Capacité " +inUse: "utilisé" +editCode: "Modifier le code" +apply: "Appliquer" +receiveAnnouncementFromInstance: "Recevoir les messages d’information de l’instance" +emailNotification: "Notifications par mail" +publish: "Public" +inChannelSearch: "Chercher dans la chaîne" +useReactionPickerForContextMenu: "Clic-droit pour ouvrir le panneau de réactions" +typingUsers: "{users} est en train d’écrire" +jumpToSpecifiedDate: "Se rendre à la date" +showingPastTimeline: "Un fil âgé est affiché" +clear: "Effacer" +markAllAsRead: "Tout marquer comme lu" +goBack: "Retour" +unlikeConfirm: "Êtes-vous sûr·e de ne plus vouloir aimer cette publication ?" +fullView: "Plein écran" +quitFullView: "Quitter le plein écran" +addDescription: "Ajouter une description" +userPagePinTip: "Vous pouvez afficher des publications ici en sélectionnant l’option + « Épingler au profil » dans le menu de chaque publication." +notSpecifiedMentionWarning: "Vous avez mentionné des utilisateur·rice·s qui ne font + pas partie de la liste des destinataires" +info: "Informations" +userInfo: "Informations sur l’utilisateur" +unknown: "Inconnu" +onlineStatus: "Statut" +hideOnlineStatus: "Se rendre invisible" +hideOnlineStatusDescription: "Rendre votre statut invisible peut diminuer les performances + de certaines fonctionnalités, telles que la recherche." +online: "En ligne" +active: "Actif·ve" +offline: "Hors ligne" +notRecommended: "Déconseillé" +botProtection: "Protection contre les bots" +instanceBlocking: "Gestion de la Fédération" +selectAccount: "Sélectionner un compte" +switchAccount: "Changer de compte" +enabled: "Activé" +disabled: "Désactivé" +quickAction: "Actions rapides" +user: "Utilisateur·rice·s" +administration: "Gestion" +accounts: "Comptes" +switch: "Remplacer" +noMaintainerInformationWarning: "Informations administrateur·rice non configurées." +noBotProtectionWarning: "La protection contre les bots n’est pas configurée." +configure: "Configurer" +postToGallery: "Publier dans la galerie" +gallery: "Galerie" +recentPosts: "Publications récentes" +popularPosts: "Publications populaires" +shareWithNote: "Partager dans une publication" +ads: "Bannières communautaires" +expiration: "Échéance" +memo: "Pense-bête" +priority: "Priorité" +high: "Haute" +middle: "Moyen" +low: "Basse" +emailNotConfiguredWarning: "Vous n’avez pas configuré d’adresse e-mail." +ratio: "Ratio" +previewNoteText: "Voir l’aperçu" +customCss: "CSS personnalisé" +customCssWarn: "Utilisez cette fonctionnalité uniquement si vous savez exactement + ce que vous faites. Une configuration inadaptée peut empêcher le client de s’exécuter + normalement." +global: "Global" +squareAvatars: "Avatars carrés" +sent: "Envoyer" +received: "Reçu" +searchResult: "Résultats de la recherche" +hashtags: "Hashtags" +troubleshooting: "Résolution de problèmes" +useBlurEffect: "Utiliser des effets de flou dans l’interface" +learnMore: "Plus d’informations" +iceshrimpUpdated: "Iceshrimp a été mis à jour !" +whatIsNew: "Voir les derniers changements" +translate: "Traduire" +translatedFrom: "Traduit depuis {x}" +accountDeletionInProgress: "La suppression de votre compte est en cours" +usernameInfo: "C’est un nom qui identifie votre compte sur l’instance de manière unique. + Vous pouvez utiliser des lettres de l’alphabet (minuscules et majuscules), des chiffres + (de 0 à 9), ou bien le tiret « _ ». Vous ne pourrez pas modifier votre nom d’utilisateur·rice + par la suite." +aiChanMode: "Mode Ai" +keepCw: "Garder le CW" +pubSub: "Comptes Pub/Sub" +lastCommunication: "Dernière communication" +resolved: "Résolu" +unresolved: "En attente" +breakFollow: "Ne plus suivre" +itsOn: "Activé" +itsOff: "Désactivé" +emailRequiredForSignup: "Une adresse e-mail est nécessaire pour créer un compte" +unread: "Non lu" +filter: "Filtre" +controlPanel: "Panneau de contrôle" +manageAccounts: "Gérer les comptes" +makeReactionsPublic: "Rendre les réactions publiques" +makeReactionsPublicDescription: "Ceci rendra public la liste de toutes vos réactions + à des publications." +classic: "Centré" +muteThread: "Masquer cette discussion" +unmuteThread: "Ne plus masquer le fil" +ffVisibility: "Visibilité des abonnés/abonnements" +ffVisibilityDescription: "Permet de configurer qui peut voir les personnes que tu + suis et les personnes qui te suivent." +continueThread: "Continuer le fil" +deleteAccountConfirm: "Ce compte sera définitivement supprimé. Êtes vous certain ?" +incorrectPassword: "Le mot de passe est incorrect." +voteConfirm: "Confirmez-vous votre vote pour « {choice} » ?" +hide: "Masquer" +leaveGroup: "Quitter le groupe" +leaveGroupConfirm: "Êtes vous sûr de vouloir quitter \"{name}\" ?" +useDrawerReactionPickerForMobile: "Afficher le sélecteur de réactions en tant que + panneau sur mobile" +clickToFinishEmailVerification: "Veuillez cliquer sur [{ok}] afin de compléter la + vérification par courriel." +overridedDeviceKind: "Type d’appareil" +smartphone: "Smartphone" +tablet: "Tablette" +auto: "Automatique" +themeColor: "Couleur de l’onglet et du badge fédéré de l’instance" +size: "Taille" +numberOfColumn: "Nombre de colonnes" +searchByGoogle: "Rechercher" +instanceDefaultLightTheme: "Thème clair par défaut sur toute l’instance" +instanceDefaultDarkTheme: "Thème sombre par défaut sur toute l’instance" +instanceDefaultThemeDescription: "Saisissez le code du thème au format JSON." +mutePeriod: "Durée de mise en sourdine" +indefinitely: "Illimité" +tenMinutes: "10 minutes" +oneHour: "1 heure" +oneDay: "1 jour" +oneWeek: "1 semaine" +rateLimitExceeded: "Limite de taux dépassée" +cropImage: "Recadrer l’image" +cropImageAsk: "Voulez-vous recadrer cette image ?" +file: "Fichier" +reverse: "Inverser" +colored: "Coloré" +label: "Étiquette" +localOnly: "Local seulement" +account: "Comptes" +_emailUnavailable: + used: "Non disponible" + format: "Le format de cette adresse de courriel est invalide" + disposable: "Les adresses e-mail jetables ne peuvent pas être utilisées" + mx: "Ce serveur de courriels est invalide" + smtp: "Ce serveur de courriels ne répond pas" +_ffVisibility: + public: "Public" + followers: "Visible uniquement pour les abonné·e·s" + private: "Privé" +_signup: + almostThere: "Bientôt fini" + emailAddressInfo: "Insérez votre adresse e-mail." + emailSent: "Un courriel de confirmation vient d’être envoyé à l’adresse que vous + avez renseignée ({email}). Cliquez sur le lien contenu dans le message pour terminer + la création de votre compte." +_accountDelete: + accountDelete: "Supprimer le compte" + mayTakeTime: "La suppression de compte nécessitant beaucoup de ressources, l’exécution + du processus peut prendre du temps, en fonction de la quantité de contenus que + vous avez créés et du nombre de fichiers que vous avez téléversés." + sendEmail: "Une fois la suppression de votre compte effectuée, un courriel sera + envoyé à l’adresse que vous aviez enregistrée." + requestAccountDelete: "Demander la suppression de votre compte" + started: "La procédure de suppression a commencé." + inProgress: "Suppression en cours" +_ad: + back: "Retour" + reduceFrequencyOfThisAd: "Voir cette publicité moins souvent" +_forgotPassword: + enterEmail: "Entrez ici l’adresse e-mail que vous avez enregistrée pour votre compte. + Un lien vous permettant de réinitialiser votre mot de passe sera envoyé à cette + adresse." + ifNoEmail: "Si vous n’avez pas enregistré d’adresse e-mail, merci de contacter l’administrateur·rice + de votre instance." + contactAdmin: "Cette instance ne permettant pas l’utilisation d’adresses e-mail, + prenez contact avec l’administrateur·rice pour procéder à la réinitialisation + de votre mot de passe." +_gallery: + my: "Mes publications" + liked: "Publications que j’ai aimé" + like: "J’aime" + unlike: "Je n’aime pas" +_email: + _follow: + title: "Vous suit" + _receiveFollowRequest: + title: "Vous avez reçu une demande de suivi" +_plugin: + install: "Installation de plugin" + installWarn: "N’installez que des extensions provenant de sources de confiance." + manage: "Gestion des plugins" +_registry: + scope: "Portée" + key: "Clé" + keys: "Clés" + domain: "Domaine" + createKey: "Créer une clé" +_aboutIceshrimp: + about: "Iceshrimp est un média social décentralisé et fédéré utilisant ActivityPub.\n\ + \ C’est un fork de Calckey/Firefish (lui-même fork de Misskey) qui se concentre + sur la stabilité, les performances et la facilité d’utilisation plutôt que sur + de nouvelles fonctionnalités." + contributors: "Principaux contributeurs" + allContributors: "Tous les contributeurs" + source: "Développement" + translation: "Traductions" + chatroom: "Salon de discussion" + documentation: "Documentation" + roadmap: "Roadmap" + changelog: "Changelog" + donate: "Soutenir Iceshrimp" + donateTitle: Iceshrimp vous plaît ? + pleaseDonateToIceshrimp: Merci de considérer de faire un don pour soutenir le + développement de Iceshrimp. + pleaseDonateToHost: Également, veuillez envisager de faire un don à votre + instance d’accueil, {host}, pour contribuer à couvrir ses frais de + fonctionnement. + donateHost: Faire un don à {host} + morePatrons: "Nous apprécions vraiment le soutien de nombreuses autres personnes + non mentionnées ici. Merci à toutes et à tous ! 🥰" + sponsors: Sponsors Iceshrimp + patrons: "Contributeurs" + patronsList: Listé chronologiquement, pas par taille de donation. Faite un don + avec le lien ci-dessus pour avoir votre nom affiché ici ! +_nsfw: + respect: "Cacher les médias marqués comme contenu sensible (NSFW)" + ignore: "Afficher les médias sensibles (NSFW)" + force: "Cacher tous les médias" +_mfm: + cheatSheet: "Antisèche MFM" + intro: "MFM est un langage basé sur le Markdown. Il est utilisable dans Iceshrimp, + Misskey, Akkoma et d’autre… Vous pouvez consulter les syntaxes utilisables avec + MFM." + dummy: "La Fédiverse s’agrandit avec Iceshrimp" + mention: "Mentionner" + mentionDescription: "Vous pouvez afficher un utilisateur spécifique en indiquant + le symbole d’arobase (@) suivie d’un nom d’utilisateur." + hashtag: "Hashtags" + hashtagDescription: "Vous pouvez afficher les hashtags en utilisant un croisillon + et du texte." + url: "URL" + urlDescription: "L’adresse web peut être affichée." + link: "Lien" + linkDescription: "Une partie précise d’une phrase peut être liée à l’adresse web." + bold: "Gras" + boldDescription: "Mise en évidence du texte en le mettant en gras." + small: "Diminuer l’emphase" + smallDescription: "Le contenu peut être affiché en petit et fin." + center: "Centrer" + centerDescription: "Centre le contenu sur la page." + inlineCode: "Code (inline)" + inlineCodeDescription: "Affiche la coloration syntaxique des lignes de code." + blockCode: "Bloc de code" + blockCodeDescription: "Afficher la coloration syntaxique de multiples lignes de + code dans un bloc." + inlineMath: "Formule mathématique (inline)" + inlineMathDescription: "Afficher les formules mathématiques (KaTeX)" + blockMath: "Formule mathématique (bloc)" + blockMathDescription: "Afficher les formules mathématiques (KaTeX) dans un bloc" + quote: "Citer" + quoteDescription: "Affiche le contenu sous forme de citation." + emoji: "Émojis personnalisés" + emojiDescription: "Encadrer le nom de l’émoji personnalisé de deux points pour l’afficher. + :nomEmoji:" + search: "Rechercher" + searchDescription: "Affiche une boîte de recherche avec du texte pré-saisi." + flip: "Inverser" + flipDescription: "Rotation verticale ou horizontale du contenu." + jelly: "Animation (Gelée)" + jellyDescription: "Donne une animation d’étirement comme de la gelée." + tada: "Animation (Tada)" + tadaDescription: "Donne une animation qui donne une impression de \"Tada !\"." + jump: "Animation (Saut)" + jumpDescription: "Donne une animation qui saute." + bounce: "Animation (Rebond)" + bounceDescription: "Donne une animation de rebondissement." + shake: "Animation (Secousse)" + shakeDescription: "Donne une animation tremblante." + twitch: "Animation (Tremblement)" + twitchDescription: "Donne une animation de tremblement intense." + spin: "Animation (Rotation)" + spinDescription: "Donne une animation de rotation." + x2: "Grand" + x2Description: "Afficher le contenu en grand." + x3: "Très grand" + x3Description: "Afficher le contenu en très grand." + x4: "Plus grand" + x4Description: "Afficher le contenu encore plus grand que grand." + blur: "Flou" + blurDescription: "Floutage du contenu. Il sera visible en le survolant avec le curseur." + font: "Police de caractères" + fontDescription: "Choisir la police du contenu." + rainbow: "Arc-en-ciel" + rainbowDescription: "Permet d’afficher le contenu en couleurs arc-en-ciel." + sparkle: "Paillettes" + sparkleDescription: "Ajoute un effet scintillant au contenu." + rotate: "Pivoter" + fade: "Apparaître/Disparaître" + fadeDescription: "Fait apparaître et disparaître le contenu." + plainDescription: Désactiver les effets de tous les MFM contenus dans cet + effet MFM. + rotateDescription: Pivoter le contenu d’un angle spécifique. + position: Position + advanced: MFM avancées + warn: Les MFM peuvent contenir des animations à mouvement rapide ou + clignotantes + crop: Rogner + positionDescription: Déplacer le contenu selon une valeur spécifiée. + play: Animer les MFM + stop: Arrêter les MFM + alwaysPlay: Toujours lire automatiquement toutes les MFM animées + advancedDescription: Si désactivé, n’autorise que la mise en forme de base, + sauf si une MFM animée est en cours de lecture + cropDescription: Rogner le contenu. + scale: Mettre à l’échelle + scaleDescription: Mettre le contenu à l’échelle selon une valeur spécifiée. + foreground: Couleur de premier plan + foregroundDescription: Changer la couleur de premier plan d’un texte. + backgroundDescription: Changer la couleur d’arrière-plan d’un texte. + background: Couleur d’arrière-plan + plain: Simple + border: Bordure + rubyDescription: Affiche une petite annotation au-dessus du texte, + généralement utilisée pour afficher les prononciations des caractères d’Asie + de l’Est. + borderDescription: Ajoute une bordure autour du contenu. + ruby: Rubis + unixtime: Heure Unix (Posix) + unixtimeDescription: Convertit un nombre de secondes depuis le 1er janvier + 1970 en une date lisible. + followmouse: Suivre la souris + followmouseDescription: Faire en sorte que le contenu suive le curseur de la + souris. + followmouseToggle: Afficher/Masquer l’aperçu +_instanceTicker: + none: "Cacher" + remote: "Montrer pour les utilisateur·ice·s distant·e·s" + always: "Toujours afficher" +_serverDisconnectedBehavior: + reload: "Rechargement automatique" + dialog: "Ouvrir une boîte de dialogue pour l’avertissement" + quiet: "Afficher un avertissement discret" + nothing: Ne rien faire +_channel: + create: "Créer une chaîne" + edit: "Éditer la chaîne" + setBanner: "Sélectionner la bannière" + removeBanner: "Supprimer la bannière" + featured: "Tendances" + owned: "Mes chaînes" + following: "Abonné·e" + usersCount: "{n} Participant·e·s" + notesCount: "{n} publications" + nameAndDescription: Nom et description + nameOnly: Nom uniquement +_menuDisplay: + sideFull: "Latéral" + sideIcon: "Latéral (icônes)" + top: "Haut de page" + hide: "Masquer" +_wordMute: + muteWords: "Mots à filtrer" + muteWordsDescription: "Séparer avec des espaces pour la condition AND. Séparer avec + un saut de ligne pour une condition OR." + muteWordsDescription2: "Pour utiliser des expressions régulières (regex), mettez + les mots-clés entre barres obliques." + softDescription: "Masquez de votre fil d’actualité les publications qui répondent + aux conditions définies." + hardDescription: "Empêche les publications, qui remplissent les conditions définies, + d’être ajoutées au fil d’actualité. Cette action est irréversible : si vous modifiez + ces conditions plus tard, les publications précédemment filtrées ne seront pas + récupérées." + soft: "Doux" + hard: "Strict" + mutedNotes: "Publications masquées" +_instanceMute: + instanceMuteDescription2: "Séparer avec des sauts de lignes" + title: "Masque les publications provenant des instances listés." + heading: "Instances à mettre en sourdine/masquer" + instanceMuteDescription: Ceci va masquer toute publication ou boost de + instances listés, incluant celles des personnes répondant à des personnes + des instances masqués. +_theme: + explore: "Explorer les thèmes" + install: "Installer un thème" + manage: "Gestion des thèmes" + code: "Code du thème" + description: "Description" + installed: "{name} a été installé" + installedThemes: "Thèmes installés" + builtinThemes: "Thèmes intégrés" + alreadyInstalled: "Ce thème est déjà installé" + invalid: "Le format du thème n’est pas valide" + make: "Créer un thème" + base: "Base" + addConstant: "Ajouter une constante" + constant: "Constante" + defaultValue: "Valeur par défaut" + color: "Couleur" + refProp: "Appeler une propriété" + refConst: "Appeler une constante" + key: "Clé" + func: "Fonction" + funcKind: "Type de fonction" + argument: "Argument" + basedProp: "Nom de la propriété référencée" + alpha: "Transparence" + darken: "Assombrir" + lighten: "Clair" + inputConstantName: "Insérez un nom de constante" + importInfo: "Vous pouvez importer un thème vers l’éditeur de thèmes en saisissant + son code ici" + deleteConstantConfirm: "Êtes-vous sûr·e de vouloir supprimer la constante {const} + ?" + keys: + accent: "Accentuation" + bg: "Arrière-plan" + fg: "Texte" + focus: "Mise au point" + indicator: "Indicateur" + panel: "Panneau" + shadow: "Ombre" + header: "Entête" + navBg: "Fond de la barre latérale" + navFg: "Texte de la barre latérale" + navHoverFg: "Texte de la barre latérale (survolé)" + navActive: "Texte de la barre latérale (actif)" + navIndicator: "Indicateur de barre latérale" + link: "Lien" + hashtag: "Hashtags" + mention: "Mentionner" + mentionMe: "Mentions (Moi)" + renote: "Booster" + modalBg: "Modal d’arrière-plan" + divider: "Séparateur" + scrollbarHandle: "Poignée de la barre de navigation" + scrollbarHandleHover: "Poignée de la barre de navigation (survolée)" + dateLabelFg: "Texte de l’étiquette de la date" + infoBg: "Arrière-plan pour les informations" + infoFg: "Texte d’information" + infoWarnBg: "Arrière-plan des avertissements" + infoWarnFg: "Texte d’avertissement" + cwBg: "Arrière-plan du CW" + cwFg: "Texte du bouton CW" + cwHoverBg: "Arrière-plan du bouton CW (survolé)" + toastBg: "Arrière-plan de la bulle de notification" + toastFg: "Texte de la bulle de notification" + buttonBg: "Arrière-plan du bouton" + buttonHoverBg: "Arrière-plan du bouton (survolé)" + inputBorder: "Cadre de la zone de texte" + listItemHoverBg: "Arrière-plan d’item de liste (survolé)" + driveFolderBg: "Arrière-plan du dossier de disque" + wallpaperOverlay: "Superposition de fond d’écran" + badge: "Badge" + messageBg: "Arrière plan de la discussion" + accentDarken: "Plus sombre" + accentLighten: "Plus clair" + fgHighlighted: "Texte mis en évidence" +_sfx: + note: "Nouvelle publication" + noteMy: "Ma publication" + notification: "Notifications" + chat: "Discuter" + chatBg: "Discussion (arrière-plan)" + antenna: "Réception de l’antenne" + channel: "Notifications de chaîne" +_ago: + future: "Futur" + justNow: "à l’instant" + secondsAgo: "Il y a {n}s" + minutesAgo: "Il y a {n}min {n2}s" + hoursAgo: "Il y a {n} heures {n2}min" + daysAgo: "Il y a {n} jour {n2} heures" + weeksAgo: "Il y a {n} semaine {n2} jours" + monthsAgo: "Il y a {n} moi {n2} semaines" + yearsAgo: "Il y a {n} an {n2} mois" +_time: + second: "s" + minute: "min" + hour: "h" + day: "j" +_tutorial: + title: "Comment utiliser Iceshrimp" + step1_1: "Bienvenue !" + step1_2: "On va vous installer. Vous serez opérationnel en un rien de temps !" + step2_1: "Tout d’abord, remplissez votre profil." + step2_2: "En fournissant quelques informations sur qui vous êtes, il sera plus facile + pour les autres de savoir s’ils veulent voir vos publcations ou vous suivre." + step3_1: "Maintenant il est temps de suivre des gens !" + step3_2: "Vos fil d’actualité Principal et Social sont basés sur les personnes que + vous suivez, alors essayez de suivre quelques comptes pour commencer.\nCliquez + sur le cercle plus en haut à droite d’un profil pour le suivre." + step4_1: "On y va." + step4_2: "Pour votre première publication, certaines personnes aiment faire une + {introduction} ou un simple «Bonjour tout le monde !»" + step5_1: "Des fils, des fils d’actualité partout !" + step5_2: "Votre instance a {timelines} fils différents activés." + step5_3: "Le fil {icon} Principal est l’endroit où vous pouvez voir les publications + de vos abonnements." + step5_4: "La fil {icon} Local est l’endroit où vous pouvez voir les publications + de tout le monde sur ce instance." + step5_5: "Le fil {icon} Social est une combinaison des fils Principal et Local." + step5_6: "Le fil {icon} Recommandé est l’endroit où vous pouvez voir les publications + des instances recommandés par vos administrateur·rice·s." + step5_7: "Le fil {icon} Global est l’endroit où vous pouvez voir les publications + de tout les autres instances connectés." + step6_1: "Alors quel est cet endroit ?" + step6_2: "Eh bien, vous ne venez pas de rejoindre Iceshrimp. Vous avez rejoint un + portail vers le Fediverse, un réseau interconnecté de milliers de serveurs, appelés + \"instances\"." + step6_3: "Chaque instance fonctionne différemment, et tous les instances n’utilisent + pas Iceshrimp. Cependant, celui-ci le fait ! C’est un peu délicat, mais vous aurez + le coup de main en un rien de temps." + step6_4: "Maintenant, allez-y, explorez et amusez-vous !" +_2fa: + alreadyRegistered: "Configuration déjà achevée." + registerTOTP: "Ajouter un nouvel appareil" + registerSecurityKey: "Enregistrer une clef" + step1: "Tout d’abord, installez une application d’authentification, telle que {a} + ou {b}, sur votre appareil." + step2: "Ensuite, scannez le code QR affiché sur l’écran." + step2Url: "Vous pouvez également saisir cette URL si vous utilisez un programme + de bureau :" + step3: "Entrez le jeton affiché sur votre application pour compléter la configuration." + step4: "À partir de maintenant, ce même jeton vous sera demandé à chacune de vos + connexions." + securityKeyInfo: "Vous pouvez configurer l’authentification WebAuthN pour sécuriser + davantage le processus de connexion grâce à une clé de sécurité matérielle qui + prend en charge FIDO2, ou bien en configurant l’authentification par empreinte + digitale ou par code PIN sur votre appareil." + token: Jeton 2FA + step3Title: Entrez un code d’authentification + chromePasskeyNotSupported: Les clés de passe Chrome ne sont actuellement pas + prises en charge. + step2Click: En cliquant sur ce QR code, vous pourrez enregistrer + l’authentification à deux facteurs (2FA) sur votre clé de sécurité ou votre + application d’authentification sur téléphone. + whyTOTPOnlyRenew: L’application d’authentification ne peut pas être supprimée + tant qu’une clé de sécurité est enregistrée. + securityKeyName: Entrez un nom de clé + removeKeyConfirm: Voulez-vous vraiment supprimer la clé {name} ? + renewTOTP: Reconfigurer l’application d’authentification + renewTOTPConfirm: Cela entraînera l’arrêt de fonctionnement des codes de + vérification provenant de votre application précédente + renewTOTPOk: Reconfigurer + securityKeyNotSupported: Votre navigateur ne prend pas en charge les clés de + sécurité. + removeKey: Supprimer la clé de sécurité + renewTOTPCancel: Annuler + registerTOTPBeforeKey: Veuillez configurer une application d’authentification + pour enregistrer une clé de sécurité ou un mot de passe. + tapSecurityKey: Veuillez suivre les instructions de votre navigateur pour + enregistrer la clé de sécurité ou le mot de passe +_permissions: + "read:account": "Afficher les informations du compte" + "write:account": "Mettre à jour les informations de votre compte" + "read:blocks": "Voir les comptes bloqués" + "write:blocks": "Gérer les comptes bloqués" + "read:drive": "Parcourir le Drive" + "write:drive": "Écrire sur le Drive" + "read:favorites": "Afficher les favoris" + "write:favorites": "Gérer les favoris" + "read:following": "Voir les informations de vos abonnements" + "write:following": "Abonnements/Se désabonner" + "read:messaging": "Voir vos discussions" + "write:messaging": "Gérer les discussions" + "read:mutes": "Voir les comptes masqués" + "write:mutes": "Gérer les comptes masqués" + "write:notes": "Créer / supprimer des publications" + "read:notifications": "Afficher les notifications" + "write:notifications": "Gérer vos notifications" + "read:reactions": "Lire les réactions" + "write:reactions": "Gérer vos réactions" + "write:votes": "Voter" + "read:pages": "Voir vos pages" + "write:pages": "Gérer les pages" + "read:page-likes": "Voir les mentions « J’aime » des pages" + "write:page-likes": "Gérer les mentions « J’aime » sur les pages" + "read:user-groups": "Voir les groupes d’utilisateur·rice·s" + "write:user-groups": "Éditer les groupes des utilisateur·rice·s" + "read:channels": "Lire vos chaînes" + "write:channels": "Gérer vos chaînes" + "read:gallery": "Voir la galerie" + "write:gallery": "Éditer la galerie" + "read:gallery-likes": "Voir vos favoris de la galerie" + "write:gallery-likes": "Gérer vos favoris de la galerie" +_auth: + shareAccess: "Autoriser \"{name}\" à accéder à votre compte ?" + shareAccessAsk: "Voulez-vous vraiment autoriser cette application à accéder à votre + compte ?" + permissionAsk: "Cette application nécessite les autorisations suivantes :" + pleaseGoBack: "Veuillez retourner à l’application" + callback: "Retour vers l’application" + denied: "Accès refusé" + copyAsk: "Veuillez coller le code d’autorisation suivant dans l’application :" + allPermissions: Accès complet au compte + signedInAs: Connecté·e en tant que + authRequired: Autorisation requise +_antennaSources: + all: "Toutes les publications" + homeTimeline: "Publications provenant des utilisateur·rice·s auxquel·les je suis + abonné" + users: "Publications venant de la part d’utilisateur·rice·s précis" + userList: "Publications venant d’une liste spécifique" + userGroup: "Publications venant d’utilisateur·rice·s du groupe spécifié" + instances: Publications de tous les utilisateurs d’une instance +_weekday: + sunday: "Dimanche" + monday: "Lundi" + tuesday: "Mardi" + wednesday: "Mercredi" + thursday: "Jeudi" + friday: "Vendredi" + saturday: "Samedi" +_widgets: + memo: "Post-it" + notifications: "Notifications" + timeline: "Fil d’actualité" + calendar: "Calendrier" + trends: "Tendances" + clock: "Horloge" + rss: "Lecteur de flux RSS" + activity: "Activité" + photos: "Photos" + digitalClock: "Horloge numérique" + federation: "Fédération" + postForm: "Champ de publication" + slideshow: "Diaporama" + button: "Bouton" + onlineUsers: "Utilisateurs en ligne" + jobQueue: "File d’attente" + serverMetric: "Statistiques du serveur" + aiscript: "Console AiScript" + aichan: "Ai" + userList: Liste d’utilisateurs + _userList: + chooseList: Sélectionner une liste + unixClock: Horloge UNIX + meiliIndexCount: Publications indexées + serverInfo: Info serveur + meiliStatus: État de l’instance + meiliSize: Taille de l’index + rssTicker: Bandeau RSS +_cw: + hide: "Masquer" + show: "Afficher le contenu" + chars: "{count} caractères" + files: "{count} fichiers" +_poll: + noOnlyOneChoice: "Au moins 2 réponses nécéssaires" + choiceN: "Choix {n}" + noMore: "Vous ne pouvez pas en ajouter davantage" + canMultipleVote: "Autoriser le multi-choix" + expiration: "Fin du sondage" + infinite: "Illimité" + at: "Expire le…" + after: "Expire après…" + deadlineDate: "Date de fin" + deadlineTime: "Heure de fin" + duration: "Durée" + votesCount: "{n} votes" + totalVotes: "{n} votes au total" + vote: "Voter" + showResult: "Voir résultats" + voted: "Déjà voté" + closed: "Terminé" + remainingDays: "{d} jours, {h} heures restantes" + remainingHours: "{h} heures et {m} minutes restantes" + remainingMinutes: "{m} minutes et {s} secondes restantes" + remainingSeconds: "{s} secondes restantes" +_visibility: + public: "Public" + publicDescription: "Publier sur tous les fils publics" + home: "Non-listé" + homeDescription: "Publier sur le fil principal uniquement" + followers: "Abonné·e·s" + followersDescription: "Publier à vos abonné·e·s et mentions uniquement" + specified: "Direct" + specifiedDescription: "Publier uniquement aux utilisateur·rice·s mentionné·e·s" + localOnly: "Local seulement" + localOnlyDescription: "Caché pour les utilisateur·rice·s distant" +_postForm: + replyPlaceholder: "Répondre à cette publication…" + quotePlaceholder: "Citez cette publication…" + channelPlaceholder: "Publier sur une chaîne…" + _placeholders: + a: "Quoi de neuf ?" + b: "Il s’est passé quelque chose ?" + c: "Qu’avez-vous en tête ?" + d: "Désirez-vous publier quelques mots ?" + e: "Commencez à écrire…" + f: "En attente de vos écrits…" +_profile: + name: "Nom" + username: "Nom d’utilisateur·rice" + description: "À propos de moi" + youCanIncludeHashtags: "Vous pouvez également inclure des hashtags." + metadata: "Informations supplémentaires" + metadataEdit: "Éditer les informations supplémentaires" + metadataDescription: "Vous pouvez afficher jusqu’à quatre informations supplémentaires + dans votre profil. Vous pouvez ajouter une balise {a} ou une balise {l} avec {rel} + pour vérifier le lien sur votre profil !" + metadataLabel: "Étiquette" + metadataContent: "Contenu" + changeAvatar: "Changer l’image de profil" + changeBanner: "Changer de bannière" + locationDescription: Si vous entrez votre ville en premier, votre heure locale + sera affichée aux autres utilisateur·rice·s. + pronouns: Pronoms +_exportOrImport: + allNotes: "Toutes les publications" + followingList: "Abonnements" + muteList: "Comptes masqués" + blockingList: "Comptes bloqués" + userLists: "Listes" + excludeMutingUsers: "Exclure les utilisateur·rice·s mis en sourdine" + excludeInactiveUsers: "Exclure les utilisateur·rice·s inactifs" +_charts: + federation: "Fédération" + apRequest: "Requêtes" + usersIncDec: "Variation du nombre d’utilisateur·rice·s" + usersTotal: "Nombre des utilisateur·rice·s au total" + activeUsers: "Nombre d’utilisateurices actif·ve·s" + notesIncDec: "Variation du nombre de publications" + localNotesIncDec: "Variation du nombre de publications locales" + remoteNotesIncDec: "Variation du nombre de publications distantes" + notesTotal: "Nombre total des publications" + filesIncDec: "Variation du nombre de fichiers" + filesTotal: "Nombre total de fichiers" + storageUsageIncDec: "Variation de l’utilisation du stockage" + storageUsageTotal: "Utilisation totale du stockage" +_instanceCharts: + requests: "Requêtes" + users: "Variation du nombre d’utilisateur·rice·s" + usersTotal: "Total cumulé du nombre d’utilisateur·rice·s" + notes: "Variation du nombre de publications" + notesTotal: "Nombre total cumulé des publications" + ff: "Variation des abonnements / abonné·e·s " + ffTotal: "Total cumulé du nombre d’abonné·e·s / abonnements" + cacheSize: "Variation de la taille du cache" + cacheSizeTotal: "Total cumulé de la taille du cache" + files: "Variation du nombre de fichiers" + filesTotal: "Total cumulé du nombre de fichiers" +_timelines: + home: "Principal" + local: "Local" + social: "Social" + global: "Global" + recommended: Recommandé +_pages: + newPage: "Créer une page" + editPage: "Modifier une page" + readPage: "Affichage de la source en cours" + created: "La page a été créée !" + updated: "La page a été mise à jour !" + deleted: "La page a été supprimée" + pageSetting: "Paramètres de la Page" + nameAlreadyExists: "L’URL de page spécifiée existe déjà" + invalidNameTitle: "L’URL de page spécifiée n’est pas valide" + invalidNameText: "Assurez-vous qu’il n’est pas vide" + editThisPage: "Éditer cette page" + viewSource: "Afficher la source" + viewPage: "Afficher la page" + like: "Favori" + unlike: "Je n’aime pas" + my: "Mes pages" + liked: "Pages favorites" + featured: "Populaire" + inspector: "Inspecteur" + contents: "Contenu" + content: "Bloc de page" + variables: "Variables" + title: "Titre" + url: "URL de la page" + summary: "Résumé de page" + alignCenter: "Centrée" + hideTitleWhenPinned: "Masquer le titre de la page lorsque celle-ci est épinglée + au profil" + font: "Police de caractères" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + eyeCatchingImageSet: "Définir une image attractive" + eyeCatchingImageRemove: "Supprimer l’image attractive" + chooseBlock: "Ajouter un bloc" + selectType: "Choisir un type" + enterVariableName: "Veuillez entrer un nom pour votre variable" + variableNameIsAlreadyUsed: "Ce nom de variable est déjà utilisé" + contentBlocks: "Contenu" + inputBlocks: "Blocs d’entrée" + specialBlocks: "Spécial" + blocks: + text: "Texte" + textarea: "Zone de texte" + section: "Section" + image: "Images" + button: "Bouton" + if: "Si" + _if: + variable: "Variable" + post: "Champ de publication" + _post: + text: "Contenu" + attachCanvasImage: "Publier une image sur la Toile" + canvasId: "Toile ID" + textInput: "Entrée textuelle" + _textInput: + name: "Nom de la variable" + text: "Titre" + default: "Valeur par défaut" + textareaInput: "Entrée textuelle multi-ligne" + _textareaInput: + name: "Nom de la variable" + text: "Titre" + default: "Valeur par défaut" + numberInput: "Entrée numérique" + _numberInput: + name: "Nom de la variable" + text: "Titre" + default: "Valeur par défaut" + canvas: "Toile" + _canvas: + id: "Toile ID" + width: "Largeur" + height: "Hauteur" + note: "Publication intégrée" + _note: + id: "Identifiant de la publication" + idDescription: "Vous pouvez aussi coller l’URL de la publication ici." + detailed: "Afficher les détails" + switch: "Interrupteur" + _switch: + name: "Nom de la variable" + text: "Titre" + default: "Valeur par défaut" + counter: "Compteur" + _counter: + name: "Nom de la variable" + text: "Titre" + inc: "Augmenter de" + _button: + text: "Titre" + colored: "Coloré" + action: "Opération à effectuer lorsque le bouton est pressé" + _action: + dialog: "Afficher une fenêtre de dialogue" + _dialog: + content: "Contenu" + resetRandom: "Réinitialiser un nombre aléatoire" + pushEvent: "Envoyer un évènement" + _pushEvent: + event: "Nom de l’évènement" + message: "Message à afficher lorsqu’il est activé" + variable: "Variable à envoyer" + no-variable: "Rien" + callAiScript: "Appeler AiScript" + _callAiScript: + functionName: "Nom de la fonction" + radioButton: "Choix" + _radioButton: + name: "Nom de la variable" + title: "Titre" + values: "Liste des choix (un par ligne)" + default: "Valeur par défaut" + script: + categories: + flow: "Contrôle de flux" + logical: "Opération logique" + operation: "Calculer" + comparison: "Comparer" + random: "Aléatoire" + value: "Valeur" + fn: "Fonction" + text: "Manipulation de texte" + convert: "Convertir" + list: "Listes" + blocks: + text: "Texte" + multiLineText: "Texte (multi-ligne)" + textList: "Liste de texte" + _textList: + info: "Veuillez séparer chaque entrée avec un saut de ligne" + strLen: "Longueur du texte" + _strLen: + arg1: "Texte" + strPick: "Extraire un caractère" + _strPick: + arg1: "Texte" + arg2: "Position du joueur" + strReplace: "Remplacement de texte" + _strReplace: + arg1: "Texte" + arg2: "Avant le remplacement" + arg3: "Après le remplacement" + strReverse: "Inverser le texte" + _strReverse: + arg1: "Texte" + join: "Concaténer du texte" + _join: + arg1: "Listes" + arg2: "Séparateur" + add: "Ajouter" + _add: + arg1: "A" + arg2: "B" + subtract: "Soustraire" + _subtract: + arg1: "A" + arg2: "B" + multiply: "Multiplier par" + _multiply: + arg1: "A" + arg2: "B" + divide: "Diviser par" + _divide: + arg1: "A" + arg2: "B" + mod: "Reste" + _mod: + arg1: "A" + arg2: "B" + round: "Arrondir les décimales" + _round: + arg1: "Numérique" + eq: "A et B sont égaux" + _eq: + arg1: "A" + arg2: "B" + notEq: "A et B sont différents" + _notEq: + arg1: "A" + arg2: "B" + and: "A et B" + _and: + arg1: "A" + arg2: "B" + or: "A ou B" + _or: + arg1: "A" + arg2: "B" + lt: "A est inférieur à B" + _lt: + arg1: "A" + arg2: "B" + gt: "A est supérieur à B" + _gt: + arg1: "A" + arg2: "B" + ltEq: "A est inférieur ou égal à B" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: "A est supérieur ou égal à B" + _gtEq: + arg1: "A" + arg2: "B" + if: "Branche" + _if: + arg1: "Si" + arg2: "Si" + arg3: "Sinon" + not: "Nier" + _not: + arg1: "Nier" + random: "Aléatoire" + _random: + arg1: "Probabilité" + rannum: "Nombre aléatoire" + _rannum: + arg1: "Minimum" + arg2: "Maximum" + randomPick: "Sélectionner au hasard dans la liste" + _randomPick: + arg1: "Listes" + dailyRandom: "Aléatoire (Quotidien pour chaque utilisateur)" + _dailyRandom: + arg1: "Probabilité" + dailyRannum: "Numéros aléatoires (Quotidien pour chaque utilisateur)" + _dailyRannum: + arg1: "Minimum" + arg2: "Maximum" + dailyRandomPick: "Sélectionné au hasard dans la liste (Quotidien pour chaque + utilisateur)" + _dailyRandomPick: + arg1: "Listes" + seedRandom: "Aléatoire (graine)" + _seedRandom: + arg1: "Graine" + arg2: "Probabilité" + seedRannum: "Nombre aléatoire (Graine)" + _seedRannum: + arg1: "Graine" + arg2: "Minimum" + arg3: "Maximum" + seedRandomPick: "Sélectionné au hasard dans la liste (graine)" + _seedRandomPick: + arg1: "Graine" + arg2: "Listes" + DRPWPM: "Sélectionné au hasard dans une liste de probabilités (Quotidien pour + chaque utilisateur)" + _DRPWPM: + arg1: "Liste de texte" + pick: "Sélectionner dans la liste" + _pick: + arg1: "Listes" + arg2: "Position" + listLen: "Longueur de la liste" + _listLen: + arg1: "Listes" + number: "Numérique" + stringToNumber: "Convertir du texte en numérique" + _stringToNumber: + arg1: "Texte" + numberToString: "Convertir du numérique en texte" + _numberToString: + arg1: "Numérique" + splitStrByLine: "Séparer le texte par des sauts de lignes" + _splitStrByLine: + arg1: "Texte" + ref: "Variable" + aiScriptVar: "Variable d’AiScript" + fn: "Fonction" + _fn: + slots: "Slots" + slots-info: "Veuillez insérer un seul slot par ligne" + arg1: "Sortie" + for: "Répéter" + _for: + arg1: "Compter" + arg2: "Action" + typeError: "Le slot {slot} accepte \"{expect}\" mais a \"{actual}\" !" + thereIsEmptySlot: "Slot {slot} est vide !" + types: + string: "Texte" + number: "Numérique" + boolean: "Marqueur" + array: "Listes" + stringArray: "Liste de texte" + emptySlot: "Slot vide" + enviromentVariables: "Variables d’environnement" + pageVariables: "Élément de page" + argVariables: "Entrée slot" +_relayStatus: + requesting: "En attente" + accepted: "Accepté" + rejected: "Refusée" +_notification: + fileUploaded: "Le fichier a été téléversé" + youGotMention: "{name} vous a mentionné" + youGotReply: "Réponse de {name}" + youGotQuote: "Cité·e par {name}" + youRenoted: "{name} vous a boosté" + youGotPoll: "{name} a participé à votre sondage" + youGotMessagingMessageFromUser: "{name} vous envoyé un message" + youGotMessagingMessageFromGroup: "Un message a été envoyé au groupe {name}" + youWereFollowed: "Vous suit" + youReceivedFollowRequest: "Vous avez reçu une demande d’abonnement" + yourFollowRequestAccepted: "Votre demande d’abonnement a été accepté" + youWereInvitedToGroup: "Invité·e au groupe" + pollEnded: "Les résultats du sondage sont disponibles" + emptyPushNotificationMessage: "Les notifications push ont été mises à jour" + _types: + all: "Toutes" + follow: "Nouvel·le abonné·e" + mention: "Mentions" + reply: "Réponses" + renote: "Boosts" + quote: "Citations" + reaction: "Réactions" + pollVote: "Votes dans des sondages" + receiveFollowRequest: "Demande d’abonnement reçue" + followRequestAccepted: "Demande d’abonnement acceptée" + groupInvited: "Invitation à un groupe" + app: "Notifications provenant des apps" + pollEnded: Fin du sondage + bite: Morsures + _actions: + followBack: "Suivre" + reply: "Répondre" + renote: "Boosts" + reacted: a réagi à votre publication + renoted: a boosté votre publication + voted: a voté pour votre sondage +_deck: + alwaysShowMainColumn: "Toujours afficher la colonne principale" + columnAlign: "Aligner les colonnes" + addColumn: "Ajouter une colonne" + swapLeft: "Déplacer à gauche" + swapRight: "Déplacer à droite" + swapUp: "Déplacer vers le haut" + swapDown: "Déplacer vers le bas" + stackLeft: "Empiler à gauche" + popRight: "Extraire à droite" + profile: "Espace de travail" + _columns: + main: "Principale" + widgets: "Widgets" + notifications: "Notifications" + tl: "Fil d’actualité" + antenna: "Antenne" + list: "Listes" + mentions: "Mentions" + direct: "Direct" + channel: Chaîne + introduction: Créer l’interface parfaite pour vous en arrangeant les colonnes + librement ! + introduction2: Cliquer sur le + sur la droite de l’écran pour ajouter de + nouvelles colonnes à tout moment. + renameProfile: Renommer l’espace de travail + configureColumn: Paramètres de colonne + deleteProfile: Supprimer l’espace de travail + widgetsIntroduction: Sélectionner "Modifier les widgets" dans le menu de la + colonne et ajouter un widget. + nameAlreadyExists: Ce nom d’espace de travail existe déjà. + newProfile: Nouvel espace de travail +keepOriginalUploadingDescription: Enregistrer l’image originale telle quelle. Si + désactivé, une version à afficher sur le web sera générée au chargement. +manageGroups: Gérer les groupes +moderation: Modération +disableDrawer: Ne pas utiliser de menus déroulants +preferencesBackups: Sauvegarde des préférences +confirmToUnclipAlreadyClippedNote: Cette publication fait déjà partie du clip + "{name}". Voudriez-vous plutôt le supprimer du clip ? +instanceSecurity: Sécurité de l’instance +recommended: Recommandé +recentNDays: Les derniers {n} jours +recentNHours: Les dernières {n} heures +check: Vérifier +thereIsUnresolvedAbuseReportWarning: Il y a des signalements non résolus. +numberOfPageCacheDescription: Augmenter ce nombre augmentera le confort des + utilisateur⋅rice⋅s mais augmentera la charge de travail du serveur, plus de + mémoire sera utilisée. +logoutConfirm: Confirmer la déconnexion ? +lastActiveDate: Dernière utilisation le +cannotUploadBecauseNoFreeSpace: Mise en ligne échouée faute de place sur le + Drive. +remoteOnly: Distant seulement +showUpdates: Afficher une fenêtre en sur-impression quand Iceshrimp se met à + jour +recommendedInstances: Instances recommandées +caption: Description automatique +migration: Migration +showAdminUpdates: Indiquer qu’une nouvelle version de Iceshrimp est disponible + (admin seulement) +replayTutorial: Relancer le tutoriel +moveTo: Migrer le compte courant vers un nouveau compte +moveFromDescription: Ceci va configurer un alias pour votre ancien compte afin + que vous puissiez migrer de cet ancien compte à l’actuel. Faites ceci AVANT de + migrer de votre ancien compte. Merci d’entrer la mention de l’ancien compte + sous ce format @personne@instance.tld +_sensitiveMediaDetection: + sensitivityDescription: Réduire la sensibilité conduira à moins de mauvaises + détections (faux positifs) alors que l’augmenter mènera à moins de détection + manquées (faux négatifs). + analyzeVideosDescription: Analyser les vidéos en plus des images. Cela + augmentera légèrement la charge du serveur. + setSensitiveFlagAutomatically: Marquer comme sensible (NSFW) + sensitivity: Sensibilité de la détection + analyzeVideos: Activer l’analyse des vidéos + setSensitiveFlagAutomaticallyDescription: Les résultats de la détection + interne seront conservés même si cette option est désactivée. + description: Réduit potentiellement l’effort de la modération de l’instance en + reconnaissant automatiquement les médias sensibles (NSFW) via de + l’intelligence artificielle. Cela va augmenter légèrement la charge du + serveur. +_messaging: + dms: Privé + groups: Groupes +cannotUploadBecauseExceedsFileSizeLimit: Le fichier n’a pas pu être chargé car + il dépasse la taille maximum autorisée. +moveAccountDescription: Ce processus est irréversible. Soyez sûr⋅e que vous avez + préparé un alias pour ce compte sur votre nouveau compte avant de migrer. + Merci d’entrer la mention du compte formaté comme ceci @personne@instance.tld +moveAccount: Déplacer le compte ! +seperateRenoteQuote: Séparer les boutons de boosts et de citation +failedToFetchAccountInformation: Impossible de récupérer les informations de + compte +noEmailServerWarning: Serveur mail non configuré. +deleteAccount: Supprimer le compte +document: Documentation +numberOfPageCache: Nombre de pages mise en cache +fast: Rapide +failedToUpload: Mise en ligne échouée +enableAutoSensitiveDescription: Permet la détection automatique des médias + sensibles (NSFW) via une intelligence artificielle, lorsque c’est possible. + Même si cette option est désactivée, elle peut être activée au niveau de + l’instance. +activeEmailValidationDescription: Active une vérification plus poussée des + adresses e-mail, ce qui inclut de vérifier la présence d’e-mail jetables et + s’il est possible de communiquer avec ces adresses. Si désactivé, seul le + format de l’e-mail est vérifié. +adminCustomCssWarn: Ce paramètre ne devrait être utilisé que si vous savez ce + qu’il fait. Entrer des valeurs impropres pourraient empêcher les clients de + TOUT LE MONDE de fonctionner. Assurez-vous que votre CSS fonctionne + correctement en l’essayant dans vos paramètres utilisateur. +swipeOnDesktop: Permettre le style de balayage de fenêtre de mobile sur PC +moveFromLabel: "Compte depuis lequel vous migrez :" +migrationConfirm: "Êtes-vous absolument certain⋅e que vous voulez migrer votre compte + vers {account} ? Une fois fait, vous ne pourrez pas revenir en arrière, et vous + ne pourrez plus utiliser le compte actuel normalement à nouveau.\nAussi, assurez-vous + d’avoir configuré le compte actuel comme le compte depuis lequel vous migrez." +_preferencesBackups: + updatedAt: "Mis à jour le : {date} {time}" + cannotLoad: Le chargement a échoué + invalidFile: Format de fichier invalide + saveConfirm: Enregistrer la sauvegarde sous le nom {name} ? + deleteConfirm: Supprimer la sauvegarde {name} ? + nameAlreadyExists: Une sauvegarde nommée "{name}" existe déjà. Merci d’entrer + un autre nom. + applyConfirm: Voulez-vous vraiment appliquer la sauvegarde "{name} à cet + appareil ? Les réglages existants de cet appareil seront écrasés. + noBackups: Aucune sauvegarde n’existe. Vous pouvez sauvegarder les paramètres + de votre client sur cette instance en utilisant "Créer une nouvelle + sauvegarde". + createdAt: "Crée le : {date} {time}" + renameConfirm: Renommer la sauvegarde "{old}" en "{new}" ? + list: Sauvegardes créées + saveNew: Faire une nouvelle sauvegarde + loadFile: Charger depuis le fichier + apply: Appliquer à l’appareil + save: Enregistrer les changements + inputName: Merci d’entrer un nom pour cette sauvegarde + cannotSave: La sauvegarde a échoué + delete: Supprimer la sauvegarde +privateMode: Mode privé +privateModeInfo: Si activé, seules les instances autorisées peuvent fédérer avec + votre instance. Toutes les publications seront masquées de la visibilité + publique. +allowedInstances: Instances autorisées +driveCapOverrideLabel: Changer la capacité du drive pour cet utilisateur +driveCapOverrideCaption: Réinitialiser la capacité à la valeur par défaut en + entrant 0 ou moins. +pleaseSelect: Sélectionner une option +customMOTD: Message du jour personnalisé (Message d’écran de démarrage) +refreshInterval: "Intervalle de mise à jour" +type: Type +speed: Vitesse +slow: Lent +move: Déplacer +showAds: Afficher les bannières communautaire (publicités) +enterSendsMessage: Appuyer sur Entrée pendant la rédaction pour envoyer le + message (sinon Ctrl+Entrée) +allowedInstancesDescription: Noms des instances autorisées pour la fédération, + chacun séparé par une nouvelle ligne (s’applique uniquement en mode privé). +enableAutoSensitive: Marquage automatique du contenu sensible (NSFW) +regexpErrorDescription: "Il y a eu une erreur dans l’expression régulière à la ligne + {line} de votre {tab} des mots masqués :" +forwardReportIsAnonymous: À la place de votre compte, un compte système anonyme + sera affiché comme rapporteur à l’instance distante. +noThankYou: Non merci +addInstance: Ajouter une instance +renoteMute: Masquer les boosts +flagSpeakAsCat: Parler comme un chat +flagSpeakAsCatDescription: Vos messages seront «nyanifiés» en mode chat +hiddenTags: Hashtags cachés +hiddenTagsDescription: "Lister les hashtags (sans le #) que vous souhaitez cacher + de Tendances et Découvrir. Les hashtags cachés sont toujours découvrables par d’autres + moyens." +antennaInstancesDescription: Lister un hôte d’instance par ligne +userSaysSomethingReason: "{name} a dit {reason}" +breakFollowConfirm: Êtes vous sur de vouloir retirer l’abonné ? +recommendedInstancesDescription: Instances recommandées séparées par une + nouvelle ligne pour apparaître dans le fil recommandé. +sendPushNotificationReadMessage: Supprimer les notifications push une fois que + les notifications ou messages concernés ont été lus +sendPushNotificationReadMessageCaption: Une notification contenant le texte + "{emptyPushNotificationMessage}" sera affichée pendant un court instant. Cela + peut augmenter la consommation de batterie de votre appareil. +splash: Écran d’Accueil +pushNotificationNotSupported: Votre navigateur ou instance ne supporte pas les + notifications push +customMOTDDescription: Messages personnalisé pour le message du jour (sur + l’écran d’accueil), séparés par des retours à la ligne, affichés au hasard à + chaque (re)chargement de page. +customSplashIcons: Icônes de l’écran d’accueil personnalisées (urls) +customSplashIconsDescription: URLs pour les icônes personnalisées de l’écran + d’accueil, séparés par des retours à la ligne, qui seront affichées + aléatoirement à chaque (re)chargement de page. Assurez-vous que les images + sont sur des URL statiques, de préférence toutes de taille 192x192. +updateAvailable: Une mise à jour est peut-être disponible ! +accountMoved: "L’utilisateur·rice a migré vers un nouveau compte :" +enableEmojiReactions: Activer les réactions par émojis +showEmojisInReactionNotifications: Montrer les émojis dans les notifications de + réactions +renoteUnmute: Ne plus masquer les boosts +selectInstance: Choisir une instance +noInstances: Il n’y a aucune instance +showLocalPosts: "Montrer les notes locales dans :" +homeTimeline: Timeline d’Accueil +socialTimeline: Timeline Sociale +requireAdminForView: Vous avez besoin d’un compte d’administration pour voir + cela. +isSystemAccount: Ce compte est créé et géré automatiquement par le système. + Veuillez ne pas modérer, éditer, supprimer ou altérer d’une autre manière ce + compte, ou cela risque de perturber votre instance. +typeToConfirm: Entrer {x} pour confirmer +statusbar: Barre d’état +sensitiveMediaDetection: Détection des médias sensibles (NSFW) +cannotUploadBecauseInappropriate: Ce fichier n’a pas pu être mis en ligne, car + il a été détecté comme potentiellement sensible (NSFW). +beta: Beta +navbar: Barre de navigation +shuffle: Mélanger +pushNotification: Notifications push +subscribePushNotification: Activer les notifications push +unsubscribePushNotification: Désactiver les notifications push +pushNotificationAlreadySubscribed: Notifications push déjà activées +logoImageUrl: URL de l’image du logo +moveToLabel: "Compte vers lequel vous migrez :" +moveFrom: Migrer vers ce compte depuis un ancien compte +defaultReaction: Émoji de réaction par défaut pour les publications entrantes et + sortantes +license: Licence +indexPosts: Indexer les publications +indexNotice: Indexation en cours. Cela prendra certainement du temps, veuillez + ne pas redémarrer votre serveur pour au moins une heure. +customKaTeXMacro: Macros KaTeX personnalisées +enableCustomKaTeXMacro: Activer les macros KaTeX personnalisées +noteId: ID des publications +customKaTeXMacroDescription: "Définissez des macros pour écrire des expressions mathématiques + simplement ! La notation se conforme aux définitions de commandes LaTeX et s’écrit + \\newcommand{\\·name}{content} ou \\newcommand{\\name}[number of arguments]{content}. + Par exemple, \\newcommand{\\add}[2]{#1 + #2} étendra \\add{3}{foo} en 3 + foo. Les + accolades entourant le nom de la macro peuvent être changés pour des parenthèses + ou des crochets. Cela affectera les types de parenthèses utilisées pour les arguments. + Une (et une seule) macro peut être définie par ligne, et vous ne pouvez pas couper + la ligne au milieu d’une définition. Les lignes invalides sont simplement ignorées. + Seulement de simples fonctions de substitution de chaines sont supportées ; la syntaxe + avancée, telle que la ramification conditionnelle, ne peut pas être utilisée ici." +enableRecommendedTimeline: Activer le fil recommandé +silenceThisInstance: Masquer cette instance +silencedInstances: Instances masquées +silenced: Masquée +deleted: Effacé +editNote: Modifier publication +edited: "Modifié à {date} {time}" +flagShowTimelineRepliesDescription: Si activé, affiche dans le fil les réponses + des utilisatieur·rice·s aux publications des autres. +findOtherInstance: Trouver une autre instance +userSaysSomethingReasonQuote: "{name} a cité une publication contenant {reason}" +signupsDisabled: Les inscriptions sur cette instance sont actuellement + désactivés, mais vous pouvez toujours vous inscrire sur une autre instance ! + Si vous avez un code d’invitation pour cette instance, entrez-le ci-dessous + s’il vous plait. +apps: Applications +userSaysSomethingReasonReply: "{noms} a répondu à une publication contenant {raison}" +defaultValueIs: "défaut : {valeur}" +searchPlaceholder: Rechercher dans le Fédiverse +removeReaction: Retirer votre réaction +selectChannel: Sélectionner une chaîne +expandOnNoteClick: Ouvrir la publications en cliquant +preventAiLearning: Empêcher le récupération de données par des IA +listsDesc: Les listes vous laissent créer des fils personnalisés avec des + utilisateur·rice·s spécifié·e·s. Elles sont accessibles depuis la page des + fils. +indexFromDescription: Laisser vide pour indexer toutes les publications +_feeds: + jsonFeed: flux JSON + atom: Atom + copyFeed: Copier le flux + rss: RSS +alt: ALT +swipeOnMobile: Permettre le balayage entre les pages +expandOnNoteClickDesc: Si désactivé, vous pourrez toujours ouvrir les + publications dans le menu du clic droit et en cliquant sur l’horodatage. +indexFrom: Indexer à partir de l’ID des publications +older: ancien +newer: récent +accessibility: Accessibilité +silencedInstancesDescription: Listez les noms de domaine d’instances que vous + voulez masquer. Les comptes des instances listées seront traités comme + "Masqués", ne pourront faire que des demandes d’abonnement, et ne pourront pas + mentionner les comptes locaux si non-suivis. Cela n’affectera en rien les + instances bloqués. +antennasDesc: "Les Antennes affichent de nouvelles publications selon les critères + que vous indiqués.\nElles peuvent être consultées depuis la page des fils." +image: Image +video: Vidéo +audio: Audio +jumpToPrevious: Passer au précédent +cw: Avertissement de contenu +xl: XL +reflectMayTakeTime: Il pourra s’écouler un certain temps avant que les + changements ne soient reflétés. +userSaysSomethingReasonRenote: "{name} a boosté une publication contenant {reason}" +sendModMail: Envoyer un avis à la modération +clipsDesc: Les clips sont comme des favoris catégorisés pouvant être partagés. + Vous pouvez créer des clips à partir du menu de chaque publication. +unclip: Dé-clipper +secureMode: Mode sécurisé (Authorized Fetch) +secureModeInfo: Quand sollicité depuis d’autres instances, ne pas répondre sans + preuve. +isModerator: Modérateur +enableServerMachineStats: Activer les statistiques matérielles du serveur +enableIdenticonGeneration: Activer la génération d’Identicon +reactionPickerSkinTone: Couleur de peau des emojis préférée +verifiedLink: Lien vérifié +isBot: Ce compte est un bot +isLocked: Ce compte nécessite une approbation pour être suivi +origin: Origine +showPopup: Notifier les utilisateurs avec un popup +showWithSparkles: Afficher avec des paillettes +youHaveUnreadAnnouncements: Vous avez des annonces non-lues +donationLink: Lien vers la page de donation +neverShow: Ne plus afficher +remindMeLater: Peut-être plus tard +removeQuote: Retirer la citation +removeRecipient: Retirer le destinataire +removeMember: Retirer un membre +preventAiLearningDescription: Demandez aux modèles d’IA de tiers de ne pas + étudier le contenu que vous téléchargez, tel que les publications et les + images. +noGraze: Veuillez désactiver l’extension de navigateur "Graze for Mastodon", car + elle interfère avec Iceshrimp. +silencedWarning: Cette page s’affiche car ces utilisateurs proviennent + d’instances que votre administrateur·rice a réduits au silence/masqué, il se + peut donc qu’ils soient de potentiels spams. +isAdmin: Administrateur·rice +isPatron: Mécène Iceshrimp +_filters: + fromUser: De l’utilisateur + withFile: Avec pièce jointe + notesBefore: Publié avant le + notesAfter: Publié après le + followersOnly: Abonnés uniquement + followingOnly: Abonnements uniquement + fromDomain: De l’instance + _dialog: + exclusivity: 'Notez que le filtre before : est exclusif, tandis que le filtre + after : est inclusif.' + info1: Les options entre [] sont optionelles. Le | indique les alternatives. + infoEnd1: Pour des raisons de commodité et de prévention des fautes de + frappe, certains filtres ont des alias, qui sont énumérés ci-dessous. + title: Syntaxe du filtre de recherche + learnMore: Utilisation des filtres + inFilters: Filtrer par clip et/ou favori + miscFilters: Filtrer par statut d’abonnement et/ou type de publication + userDomain: Filtrer par auteur·ice, mention, réponse ou nom d’instance + postDate: Filtrer par date + wordFilters: Filtrer par le contenu du texte + word: mot + phrase: phrase littérale contenant des caractères (arbitraires) + attachmentType: Filtrer par type(s) de pièce(s) jointe(s) + matchOptions: Modifier la sensibilité à la casse et/ou activer la + correspondance exacte avec une suite de caractères + info: Nomenclature + info2: Un tiret entre crochets [-] indique la possibilité + d’inverser/négativer un filtre avec le caractère tiret - + infoEnd: Filtrer par aliases + replyTo: En réponse à + mentioning: Faisant mention à + inFavorites: Dans les favoris + inBookmarks: Dans les clips + repliesOnly: Réponses seulement + excludeReplies: Exclure les réponses + excludeRenotes: Exclure les boosts + caseSensitive: Sensible à la casse + matchWords: Correspondance exacte +_dialog: + charactersBelow: "Pas assez de caractères ! Actuel : {current}/Minimum : {min}" + charactersExceeded: "Nombre maximal de caractères dépassé ! Actuel : {current}/Limite + : {max}" +channelFederationWarn: Les chaînes ne se fédèrent pas encore vers d’autres + instances +_skinTones: + yellow: Jaune + dark: Peau Foncée + light: Claire + mediumLight: Moyennement Claire + medium: Légèrement Mate + mediumDark: Peau Mate +objectStorageS3ForcePathStyle: Utiliser des URL d’endpoints basées sur le chemin +objectStorageS3ForcePathStyleDesc: Activez cette option pour construire les URL + d’endpoints au format «s3.amazonaws.com//» au lieu de + «.s3.amazonaws.com». +delete2fa: Désativer A2F +deletePasskeys: Supprimer les clés d’accès +delete2faConfirm: Cela supprimera de manière irréversible la double + authentification sur ce compte. Souhaitez-vous continuer ? +inputNotMatch: L’entrée ne correspond pas +deletePasskeysConfirm: Cela supprimera de manière irréversible toutes les clés + d’accès et les clés de sécurité sur ce compte. Souhaitez-vous continuer ? +addRe: Ajouter "re:" au début d’un avertissement de contenu (CW) en réponse à + une publication avec un avertissement de contenu +expandAllCws: Afficher le contenu de toutes les réponses +_cwStyle: + modern: Moderne + alternative: Alternatif (Firefish) + classic: Classique (Misskey/Foundkey) +cannotChangeScopeWhenEditing: Vous ne pouvez pas modifier la visibilité de ce + message pendant son édition +openInMainColumn: Ouvrir dans la colonne principale +cwStyle: Apparence des «Avertissement de contenu» +collapseAllCws: Cacher le contenu de toutes les réponses +searchNotLoggedIn_1: Vous devez être authentifié pour pouvoir utiliser la + recherche en texte intégral. +searchNotLoggedIn_2: Toutefois, vous pouvez effectuer des recherches à l’aide de + hashtags et rechercher des utilisateurs. +antennaTimelineHint: Les antennes affichent les messages correspondants dans + l’ordre dans lequel ils ont été reçus, ce qui n’est pas nécessairement + chronologique. +alwaysExpandCws: Toujours dérouler les messages avec des avertissements sur le + contenu +hideFromHome: Cacher de la timeline «Principal» +_wellness: + newPostsButton: Activer le bouton d’alerte «Voir les nouvelles publications» + newPostsGlowOpacity: Opacité de la lueur qui indique la présence de nouveaux + messages + immediacy: Immédiateté + name: Bien-être + description: Ces paramètres vous permettent d’ajuster les options susceptibles + de créer une dépendance ou d’induire de l’anxiété, trop souvent présentes + sur les réseaux sociaux. Choisissez les paramètres qui vous conviennent le + mieux. +searchEmptyQuery: Veuillez saisir votre recherche. +_biteControls: + name: Qui peut te mordre + anyone: N’importe qui + followers: Abonné·e·s + nobody: Personne +bite: Mordre +biteBack: Riposter +bitYou: t’a mordu +bitYouBack: t’a mordu en retour +bitYourNote: a mordu ta note diff --git a/locales/gl.yml b/locales/gl.yml new file mode 100644 index 0000000..9c8d4f3 --- /dev/null +++ b/locales/gl.yml @@ -0,0 +1,17 @@ +_lang_: Inglés +introIceshrimp: Benvida! Iceshrimp é unha plataforma de medios sociais de código aberto, + descentralizada e gratuíta para sempre!🚀 +monthAndDay: '{day}/{month}' +notifications: Notificacións +password: Contrasinal +forgotPassword: Esquecín o contrasinal +gotIt: Vale! +cancel: Cancelar +noThankYou: Non, grazas +headlineIceshrimp: Plataforma de medios sociais de código aberto e descentralizada, + gratuíta para sempre!🚀 +search: Buscar +searchPlaceholder: Buscar en Iceshrimp +username: Identificador +fetchingAsApObject: Descargando desde o Fediverso +ok: OK diff --git a/locales/id-ID.yml b/locales/id-ID.yml new file mode 100644 index 0000000..1d07a32 --- /dev/null +++ b/locales/id-ID.yml @@ -0,0 +1,1791 @@ +_lang_: "Bahasa Indonesia" +headlineIceshrimp: "Jaringan terhubung melalui catatan" +introIceshrimp: "Selamat datang! Iceshrimp adalah perangkat mikroblog tercatu bersifat\ + \ sumber terbuka.\nMulailah menuliskan catatan, bagikan peristiwa terkini, serta\ + \ ceritakan segala tentangmu.\U0001F4E1\nTunjukkan juga reaksimu pada catatan pengguna\ + \ lain.\U0001F44D\nMari jelajahi dunia baru\U0001F680" +monthAndDay: "{day} {month}" +search: "Penelusuran" +notifications: "Pemberitahuan" +username: "Nama Pengguna" +password: "Kata sandi" +forgotPassword: "Lupa Kata Sandi" +fetchingAsApObject: "Mengambil data dari Fediverse" +ok: "OK" +gotIt: "Saya mengerti" +cancel: "Batalkan" +enterUsername: "Masukkan nama pengguna" +renotedBy: "direnote oleh {user}" +noNotes: "Tidak ada catatan" +noNotifications: "Tidak ada pemberitahuan" +instance: "Instansi" +settings: "Pengaturan" +basicSettings: "Pengaturan umum" +otherSettings: "Pengaturan lainnya" +openInWindow: "Buka di jendela" +profile: "Profil" +timeline: "Linimasa" +noAccountDescription: "Pengguna ini belum menulis bio" +login: "Masuk" +loggingIn: "Sedang masuk" +logout: "Keluar" +signup: "Daftar" +uploading: "Sedang mengunggah" +save: "Simpan" +users: "Pengguna" +addUser: "Tambah pengguna" +favorite: "Favorit" +favorites: "Favorit" +unfavorite: "Hapus favorit" +favorited: "Ditambahkan ke favorit" +alreadyFavorited: "Telah ditambahkan ke favorit" +cantFavorite: "Tidak dapat menambahkan ke favorit" +pin: "Sematkan ke profil" +unpin: "Lepas sematan dari profil" +copyContent: "Salin konten" +copyLink: "Salin tautan" +delete: "Hapus" +deleteAndEdit: "Hapus dan sunting" +deleteAndEditConfirm: "Apakah kamu yakin ingin menghapus note ini dan menyuntingnya?\ + \ Kamu akan kehilangan semua reaksi, renote dan balasan di note ini." +addToList: "Tambahkan ke daftar" +sendMessage: "Kirim pesan" +copyUsername: "Salin nama pengguna" +searchUser: "Cari pengguna" +reply: "Balas" +loadMore: "Selebihnya" +showMore: "Selebihnya" +showLess: "Tutup" +youGotNewFollower: "Mengikuti kamu" +receiveFollowRequest: "Ingin mengikuti kamu" +followRequestAccepted: "Permintaan mengikuti telah disetujui" +mention: "Sebut" +mentions: "Sebutan" +directNotes: "Catatan langsung" +importAndExport: "Impor & Ekspor" +import: "Impor" +export: "Ekspor" +files: "Berkas" +download: "Unduh" +driveFileDeleteConfirm: "Hapus {name}? Catatan dengan berkas terkait juga akan terhapus." +unfollowConfirm: "Berhenti mengikuti {name}?" +exportRequested: "Kamu telah meminta ekspor. Ini akan memakan waktu sesaat. Setelah\ + \ ekspor selesai, berkas yang dihasilkan akan ditambahkan ke Drive" +importRequested: "Kamu telah meminta impor. Ini akan memakan waktu sesaat." +lists: "Daftar" +noLists: "Kamu tidak memiliki daftar apapun" +note: "Catat" +notes: "Catatan" +following: "Ikuti" +followers: "Pengikut" +followsYou: "Mengikuti kamu" +createList: "Buat daftar" +manageLists: "Sunting daftar" +error: "Galat" +somethingHappened: "Terjadi kesalahan" +retry: "Coba lagi" +pageLoadError: "Gagal memuat halaman." +pageLoadErrorDescription: "Umumnya disebabkan jaringan atau tembolok perambah. Cobalah\ + \ bersihkan tembolok peramban lalu tunggu sesaat sebelum mencoba kembali." +serverIsDead: "Tidak ada respon dari peladen. Mohon tunggu dan coba beberapa saat\ + \ lagi." +youShouldUpgradeClient: "Untuk melihat halaman ini, mohon muat ulang untuk memutakhirkan\ + \ klienmu." +enterListName: "Masukkan nama daftar" +privacy: "Privasi" +makeFollowManuallyApprove: "Permintaan mengikuti membutuhkan persetujuan" +defaultNoteVisibility: "Privasi bawaan catatan" +follow: "Ikuti" +followRequest: "Permintaan mengikuti" +followRequests: "Permintaan mengikuti" +unfollow: "Berhenti mengikuti" +followRequestPending: "Permintaan mengikuti yang menunggu" +enterEmoji: "Masukkan emoji" +renote: "Renote" +unrenote: "Hapus renote" +renoted: "Telah direnote" +cantRenote: "Postingan ini tidak dapat direnote" +cantReRenote: "Renote tidak dapat direnote" +quote: "Kutip" +pinnedNote: "Catatan yang disematkan" +pinned: "Sematkan ke profil" +you: "Kamu" +clickToShow: "Klik untuk melihat" +sensitive: "Konten sensitif" +add: "Tambahkan" +reaction: "Reaksi" +reactionSetting: "Reaksi untuk dimunculkan di bilah reaksi" +reactionSettingDescription2: "Geser untuk memindah urutkan, klik untuk menghapus,\ + \ tekan \"+\" untuk menambahkan" +rememberNoteVisibility: "Ingat pengaturan visibilitas catatan" +attachCancel: "Hapus lampiran" +markAsSensitive: "Tandai sebagai konten sensitif" +unmarkAsSensitive: "Hapus tanda konten sensitif" +enterFileName: "Masukkan nama berkas" +mute: "Bisukan" +unmute: "Hapus bisukan" +block: "Blokir" +unblock: "Buka blokir" +suspend: "Bekukan" +unsuspend: "Buka pembekuan" +blockConfirm: "Apakah kamu yakin ingin memblokir akun ini?" +unblockConfirm: "Apakah kamu yakin ingin membuka blokir akun ini?" +suspendConfirm: "Apakah kamu yakin ingin membekukan akun ini?" +unsuspendConfirm: "Apakah kamu yakin ingin membuka pembekuan akun ini?" +selectList: "Pilih daftar" +selectAntenna: "Pilih Antena" +selectWidget: "Pilih gawit" +editWidgets: "Sunting gawit" +editWidgetsExit: "Selesai" +customEmojis: "Emoji kustom" +emoji: "Emoji" +emojis: "Emoji" +emojiName: "Nama emoji" +emojiUrl: "URL Emoji" +addEmoji: "Tambahkan emoji" +settingGuide: "Pengaturan rekomendasi" +cacheRemoteFiles: "Tembolokkan berkas remote" +cacheRemoteFilesDescription: "Ketika pengaturan ini dinonaktifkan, berkas luar akan\ + \ dimuat langsung dari instansi luar. Menonaktifkan ini akan mengurangi penggunaan\ + \ penyimpanan, namun dapat menyebabkan meningkatkan lalu lintas bandwidth, karena\ + \ thumbnail tidak dihasilkan." +flagAsBot: "Atur akun ini sebagai Bot" +flagAsBotDescription: "Jika akun ini dikendalikan oleh program, tetapkanlah opsi ini.\ + \ Jika diaktifkan, ini akan berfungsi sebagai tanda bagi pengembang lain untuk mencegah\ + \ interaksi berantai dengan bot lain dan menyesuaikan sistem internal Iceshrimp untuk\ + \ memperlakukan akun ini sebagai bot." +flagAsCat: "Atur akun ini sebagai kucing" +flagAsCatDescription: "Nyalakan tanda ini untuk menandai akun ini sebagai kucing." +flagShowTimelineReplies: "Tampilkan balasan di linimasa" +flagShowTimelineRepliesDescription: "Menampilkan balasan pengguna dari note pengguna\ + \ lain di linimasa apabila dinyalakan." +autoAcceptFollowed: "Setujui otomatis permintaan mengikuti dari pengguna yang kamu\ + \ ikuti" +addAccount: "Tambahkan akun" +loginFailed: "Gagal untuk masuk" +showOnRemote: "Lihat profil asli" +general: "Umum" +wallpaper: "Wallpaper" +setWallpaper: "Atur wallpaper" +removeWallpaper: "Hapus wallpaper" +searchWith: "Cari: {q}" +youHaveNoLists: "Kamu tidak memiliki daftar apapun" +followConfirm: "Apakah kamu yakin ingin mengikuti {name}?" +proxyAccount: "Akun proksi" +proxyAccountDescription: "Akun proksi merupakan sebuah akun yang bertindak sebagai\ + \ pengikut luar untuk pengguna dalam kondisi tertentu. Sebagai contoh, ketika pengguna\ + \ menambahkan seorang pengguna luar ke dalam daftar, aktivitas dari pengguna luar\ + \ tidak akan disampaikan ke instansi apabila tidak ada pengguna lokal yang mengikuti\ + \ pengguna tersebut, dengan begitu akun proksilah yang akan mengikutinya." +host: "Host" +selectUser: "Pilih pengguna" +recipient: "Penerima" +annotation: "Keterangan konten" +federation: "Federasi" +instances: "Instansi" +registeredAt: "Terdaftar" +latestRequestSentAt: "Permintaan terakhir dikirim pada" +latestRequestReceivedAt: "Permintaan terakhir diterima pada" +latestStatus: "Status terakhir" +storageUsage: "Penggunaan penyimpanan" +charts: "Grafik" +perHour: "per Jam" +perDay: "per Hari" +stopActivityDelivery: "Berhenti mengirim aktivitas" +blockThisInstance: "Blokir instansi ini" +operations: "Tindakan" +software: "Perangkat lunak" +version: "Versi" +metadata: "Metadata" +monitor: "Pantau" +jobQueue: "Antrian kerja" +cpuAndMemory: "CPU dan Memori" +network: "Jaringan" +disk: "Diska" +instanceInfo: "Informasi Instansi" +statistics: "Statistik" +clearQueue: "Bersihkan antrian" +clearQueueConfirmTitle: "Apakah kamu yakin ingin membersihkan antrian?" +clearQueueConfirmText: "Seluruh sisa catatan yang tidak tersampaikan di dalam antrian\ + \ tidak akan difederasi. Biasanya operasi ini TIDAK dibutuhkan." +clearCachedFiles: "Hapus tembolok" +clearCachedFilesConfirm: "Apakah kamu yakin ingin menghapus seluruh tembolok berkas\ + \ remote?" +blockedInstances: "Instansi terblokir" +blockedInstancesDescription: "Daftar nama host dari instansi yang diperlukan untuk\ + \ diblokir. Instansi yang didaftarkan tidak akan dapat berkomunikasi dengan instansi\ + \ ini." +muteAndBlock: "Bisukan / Blokir" +mutedUsers: "Pengguna yang dibisukan" +blockedUsers: "Pengguna yang diblokir" +noUsers: "Tidak ada pengguna" +editProfile: "Sunting profil" +noteDeleteConfirm: "Apakah kamu yakin ingin menghapus catatan ini?" +pinLimitExceeded: "Kamu tidak dapat menyematkan catatan lagi" +intro: "Instalasi Iceshrimp telah selesai! Mohon untuk membuat pengguna admin." +done: "Selesai" +processing: "Memproses" +preview: "Pratinjau" +default: "Bawaan" +noCustomEmojis: "Tidak ada emoji kustom" +noJobs: "Tidak ada kerja" +federating: "memfederasi" +blocked: "Diblokir" +suspended: "Diberhentikan" +all: "Semua" +subscribing: "Berlangganan" +publishing: "Sedang menyiarkan langsung" +notResponding: "Tidak ada respon" +instanceFollowing: "Mengikuti instance" +instanceFollowers: "Pengikut instance" +instanceUsers: "Pengguna pada instance ini" +changePassword: "Ubah kata sandi" +security: "Keamanan" +retypedNotMatch: "Input tidak sama" +currentPassword: "Kata sandi saat ini" +newPassword: "Kata sandi baru" +newPasswordRetype: "Ulangi kata sandi baru" +attachFile: "Lampirkan berkas" +more: "Lagi !" +featured: "Sorotan" +usernameOrUserId: "Nama pengguna atau User ID" +noSuchUser: "Pengguna tidak ditemukan" +lookup: "Mencari" +announcements: "Pengumuman" +imageUrl: "URL Gambar" +remove: "Hapus" +removed: "Telah dihapus" +removeAreYouSure: "Apakah kamu yakin ingin menghapus \"{x}\"?" +deleteAreYouSure: "Apakah kamu yakin ingin menghapus \"{x}\"?" +resetAreYouSure: "Yakin mau atur ulang?" +saved: "Telah disimpan" +messaging: "Pesan" +upload: "Unggah" +keepOriginalUploading: "Simpan gambar asli" +keepOriginalUploadingDescription: "Simpan gambar yang diunggah sebagaimana gambar\ + \ aslinya. Bila dimatikan, versi tampilan web akan dihasilkan pada saat diunggah." +fromDrive: "Dari Drive" +fromUrl: "Dari URL" +uploadFromUrl: "Unggah dari URL" +uploadFromUrlDescription: "URL berkas yang ingin kamu unggah" +uploadFromUrlRequested: "Pengunggahan telah diminta" +uploadFromUrlMayTakeTime: "Membutuhkan beberapa waktu hingga pengunggahan selesai" +explore: "Jelajahi" +messageRead: "Telah dibaca" +noMoreHistory: "Tidak ada sejarah lagi" +startMessaging: "Mulai mengirim pesan" +nUsersRead: "Dibaca oleh {n}" +agreeTo: "Saya setuju kepada {0}" +tos: "Syarat dan ketentuan" +start: "Mulai" +home: "Beranda" +remoteUserCaution: "Informasi ini mungkin tidak mutakhir, karena pengguna ini berasal\ + \ dari instansi luar." +activity: "Aktivitas" +images: "Gambar" +birthday: "Tanggal lahir" +yearsOld: "{age} tahun" +registeredDate: "Bergabung pada" +location: "Lokasi" +theme: "Tema" +themeForLightMode: "Tema untuk Mode Terang" +themeForDarkMode: "Tema untuk Mode Gelap" +light: "Terang" +dark: "Gelap" +lightThemes: "Tema Terang" +darkThemes: "Tema gelap" +syncDeviceDarkMode: "Sinkronkan mode gelap dengan pengaturan perangkat" +drive: "Drive" +fileName: "Nama berkas" +selectFile: "Pilih berkas" +selectFiles: "Pilih berkas" +selectFolder: "Pilih folder" +selectFolders: "Pilih folder" +renameFile: "Ubah nama berkas" +folderName: "Nama folder" +createFolder: "Buat folder" +renameFolder: "Ubah nama folder" +deleteFolder: "Hapus folder" +addFile: "Tambahkan berkas" +emptyDrive: "Drive kosong" +emptyFolder: "Folder kosong" +unableToDelete: "Tidak dapat menghapus" +inputNewFileName: "Masukkan nama berkas yang baru" +inputNewDescription: "Masukkan keterangan disini" +inputNewFolderName: "Masukkan nama folder yang baru" +circularReferenceFolder: "Folder tujuan adalah subfolder dari folder yang ingin kamu\ + \ pindahkan." +hasChildFilesOrFolders: "Karena folder ini tidak kosong, maka tidak dapat dihapus." +copyUrl: "Salin tautan" +rename: "Ubah nama" +avatar: "Avatar" +banner: "Banner" +nsfw: "Konten sensitif" +whenServerDisconnected: "Ketika kehilangan koneksi dengan peladen" +disconnectedFromServer: "Terputus koneksi dari peladen" +reload: "Muat ulang" +doNothing: "Abaikan" +reloadConfirm: "Apakah kamu ingin memuat ulang linimasa?" +watch: "Tonton" +unwatch: "Batal tonton" +accept: "Terima" +reject: "Tolak" +normal: "Normal" +instanceName: "Nama instance" +instanceDescription: "Tentang instance" +maintainerName: "Pengelola" +maintainerEmail: "Surel pengelola" +tosUrl: "URL Syarat dan Ketentuan" +thisYear: "Tahun ini" +thisMonth: "Bulan ini" +today: "Hari ini" +dayX: "{day}" +monthX: "{month}" +yearX: "{year}" +pages: "Halaman" +integration: "Integrasi" +connectService: "Sambungkan" +disconnectService: "Putuskan" +enableLocalTimeline: "Nyalakan linimasa lokal" +enableGlobalTimeline: "Nyalakan linimasa global" +disablingTimelinesInfo: "Admin dan Moderator akan selalu memiliki akses ke semua linimasa\ + \ meskipun linimasa tersebut tidak diaktifkan." +registration: "Pendaftaran" +enableRegistration: "Nyalakan pendaftaran pengguna baru" +invite: "Undang" +driveCapacityPerLocalAccount: "Kapasitas drive per pengguna lokal" +driveCapacityPerRemoteAccount: "Kapasitas drive per pengguna remote" +inMb: "dalam Megabytes" +iconUrl: "URL Gambar ikon" +bannerUrl: "URL Banner" +backgroundImageUrl: "URL Gambar latar" +basicInfo: "Informasi Umum" +pinnedUsers: "Pengguna yang disematkan" +pinnedUsersDescription: "Tuliskan satu nama pengguna dalam satu baris. Pengguna yang\ + \ dituliskan disini akan disematkan dalam bilah \"Jelajahi\"." +pinnedPages: "Halaman yang disematkan" +pinnedPagesDescription: "Masukkan tautan dari halaman yang kamu ingin sematkan ke\ + \ halaman utama dari instansi ini, dipisah dengan membuat baris baru." +pinnedClipId: "ID dari klip yang disematkan" +pinnedNotes: "Catatan yang disematkan" +hcaptcha: "hCaptcha" +enableHcaptcha: "Nyalakan hCaptcha" +hcaptchaSiteKey: "Site Key" +hcaptchaSecretKey: "Secret Key" +recaptcha: "reCAPTCHA" +enableRecaptcha: "Nyalakan reCAPTCHA" +recaptchaSiteKey: "Site key" +recaptchaSecretKey: "Secret Key" +avoidMultiCaptchaConfirm: "Menggunakan banyak Captcha dapat menyebabkan gangguan.\ + \ Apakah kamu ingin untuk menonaktifkan Captcha yang lain? Kamu dapat membiarkan\ + \ fitur ini tetap aktif dengan menekan tombol batal." +antennas: "Antena" +manageAntennas: "Pengelola Antena" +name: "Nama" +antennaSource: "Sumber Antenna" +antennaKeywords: "Kata kunci yang diterima" +antennaExcludeKeywords: "Kata kunci yang dikecualikan" +antennaKeywordsDescription: "Pisahkan dengan spasi untuk kondisi AND. Pisahkan dengan\ + \ baris baru untuk kondisi OR." +notifyAntenna: "Beritahu untuk catatan baru" +withFileAntenna: "Hanya tampilkan catatan dengan berkas yang dilampirkan" +enableServiceworker: "Aktifkan ServiceWorker" +antennaUsersDescription: "Tuliskan satu nama pengguna per baris" +caseSensitive: "Peka huruf besar dan huruf kecil" +withReplies: "Termasuk balasan" +connectedTo: "Akun yang mengikuti telah terhubung" +notesAndReplies: "Catatan dan balasan" +withFiles: "Media" +silence: "Bungkam" +silenceConfirm: "Apakah kamu yakin ingin membungkam pengguna ini?" +unsilence: "Hapus bungkam" +unsilenceConfirm: "Apakah kamu ingin untuk batal membungkam pengguna ini?" +popularUsers: "Pengguna populer" +recentlyUpdatedUsers: "Pengguna dengan aktivitas terkini" +recentlyRegisteredUsers: "Pengguna baru saja bergabung" +recentlyDiscoveredUsers: "Pengguna baru saja dilihat" +exploreUsersCount: "Terdapat {count} pengguna" +exploreFediverse: "Jelajahi Fediverse" +popularTags: "Tag populer" +userList: "Daftar" +about: "Informasi" +aboutIceshrimp: "Tentang Iceshrimp" +administrator: "Admin" +token: "Token" +twoStepAuthentication: "Otentikasi dua faktor" +moderator: "Moderator" +nUsersMentioned: "{n} pengguna disebut" +securityKey: "Kunci keamanan" +securityKeyName: "Nama kunci" +registerSecurityKey: "Daftarkan kunci keamanan" +lastUsed: "Terakhir digunakan" +unregister: "Batalkan pendaftaran" +passwordLessLogin: "Setel login tanpa kata sandi" +resetPassword: "Atur ulang kata sandi" +newPasswordIs: "Kata sandi baru adalah \"{password}\"" +reduceUiAnimation: "Kurangi animasi antarmuka" +share: "Bagikan" +notFound: "Tidak dapat ditemukan" +notFoundDescription: "Tidak ada halaman sesuai dengan URL yang ditentukan." +uploadFolder: "Lokasi unggah folder bawaan" +cacheClear: "Bersihkan tembolok" +markAsReadAllNotifications: "Tandai semua pemberitahuan telah dibaca" +markAsReadAllUnreadNotes: "Tandai semua catatan telah dibaca" +markAsReadAllTalkMessages: "Tandai semua pesan telah dibaca" +help: "Bantuan" +inputMessageHere: "Ketik pesan disini" +close: "Tutup" +group: "Grup" +groups: "Grup" +createGroup: "Buat grup" +ownedGroups: "Grup yang dimiliki" +joinedGroups: "Grup yang diikuti" +invites: "Undang" +groupName: "Nama grup" +members: "Anggota" +transfer: "Transfer" +messagingWithUser: "Obrolan dengan pengguna lain" +messagingWithGroup: "Obrolan di dalam grup" +title: "Judul" +text: "Teks" +enable: "Aktifkan" +next: "Selanjutnya" +retype: "Masukkan ulang" +noteOf: "Catatan milik {user}" +inviteToGroup: "Undang ke grup" +quoteAttached: "Dikutip" +quoteQuestion: "Apakah kamu ingin menambahkan kutipan?" +noMessagesYet: "Tidak ada pesan" +newMessageExists: "Kamu mendapatkan pesan baru" +onlyOneFileCanBeAttached: "Kamu hanya dapat melampirkan satu berkas ke dalam pesan" +signinRequired: "Silahkan login" +invitations: "Undang" +invitationCode: "Kode undangan" +checking: "Memeriksa" +available: "Tersedia" +unavailable: "Tidak tersedia" +usernameInvalidFormat: "Hanya dapat menerima karakter a-z, A-Z dan angka 0-9." +tooShort: "Terlalu pendek" +tooLong: "Terlalu panjang" +weakPassword: "Kata sandi lemah" +normalPassword: "Kata sandi baik" +strongPassword: "Kata sandi kuat" +passwordMatched: "Kata sandi sama" +passwordNotMatched: "Kata sandi tidak sama" +signinWith: "Masuk dengan {x}" +signinFailed: "Tidak dapat masuk. Nama pengguna atau kata sandi yang kamu masukkan\ + \ salah." +tapSecurityKey: "Ketuk kunci keamanan kamu" +or: "atau" +language: "Bahasa" +uiLanguage: "Bahasa antarmuka pengguna" +groupInvited: "Telah diundang ke grup" +aboutX: "Tentang {x}" +useOsNativeEmojis: "Gunakan Emoji bawaan sistem operasi" +disableDrawer: "Jangan gunakan menu bergaya laci" +youHaveNoGroups: "Kamu tidak memiliki grup" +joinOrCreateGroup: "Bergabunglah dengan grup atau kamu dapat membuat grupmu sendiri." +noHistory: "Tidak ada riwayat" +signinHistory: "Riwayat masuk" +disableAnimatedMfm: "Nonaktifkan MFM dengan animasi" +doing: "Sedang berkerja..." +category: "Kategori" +tags: "Tandai" +docSource: "Sumber dari dokumen ini" +createAccount: "Buat akun" +existingAccount: "Akun yang ada" +regenerate: "Buat ulang" +fontSize: "Ukuran huruf" +noFollowRequests: "Kamu tidak memiliki permintaan mengikuti yang menunggu" +openImageInNewTab: "Buka gambar di tab baru" +dashboard: "Dasbor" +local: "Lokal" +remote: "Remote" +total: "Jumlah" +weekOverWeekChanges: "Mingguan" +dayOverDayChanges: "Harian" +appearance: "Tampilan" +clientSettings: "Pengaturan Klien" +accountSettings: "Pengaturan Akun" +promotion: "Promosi" +promote: "Promosikan" +numberOfDays: "Jumlah hari" +hideThisNote: "Sembunyikan catatan ini" +showFeaturedNotesInTimeline: "Tampilkan catatan yang diunggulkan di linimasa" +objectStorage: "Object Storage" +useObjectStorage: "Gunakan object storage" +objectStorageBaseUrl: "Base URL" +objectStorageBaseUrlDesc: "Prefix URL digunakan untuk mengkonstruksi URL ke object\ + \ (media) referencing. Tentukan URL jika kamu menggunakan CDN atau Proxy, jika tidak\ + \ tentukan alamat yang dapat diakses secara publik sesuai dengan panduan dari layanan\ + \ yang akan kamu gunakan, contohnya. 'https://.s3.amazonaws.com' untuk AWS\ + \ S3, dan 'https://storage.googleapis.com/' untuk GCS." +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "Mohon tentukan nama bucket yang digunakan pada layanan yang\ + \ telah dikonfigurasi." +objectStoragePrefix: "Prefix" +objectStoragePrefixDesc: "Berkas tidak akan disimpan dalam direktori dari prefix ini." +objectStorageEndpoint: "Endpoint" +objectStorageEndpointDesc: "Kosongkan bagian ini jika kamu menggunakan AWS S3, jika\ + \ tidak tentukan endpoint sebagai '' atau ':' sesuai dengan panduan\ + \ dari layanan yang akan kamu gunakan." +objectStorageRegion: "Region" +objectStorageRegionDesc: "Tentukan region seperti 'xx-east-1'. Jika layanan kamu tidak\ + \ memiliki perbedaan mengenai region, kosongkan saja atau isi dengan 'us-east-1'." +objectStorageUseSSL: "Gunakan SSL" +objectStorageUseSSLDesc: "Matikan ini jika kamu tidak akan menggunakan HTTPS untuk\ + \ koneksi API" +objectStorageUseProxy: "Hubungkan melalui Proxy" +objectStorageUseProxyDesc: "Matikan ini jika kamu tidak akan menggunakan Proxy untuk\ + \ koneksi ObjectStorage" +objectStorageSetPublicRead: "Setel \"public-read\" disaat mengunggah" +serverLogs: "Log Peladen" +deleteAll: "Hapus semua" +showFixedPostForm: "Tampilkan form posting di atas linimasa." +newNoteRecived: "Kamu mendapat catatan baru" +sounds: "Bunyi" +listen: "Dengarkan" +none: "Tidak ada" +showInPage: "Tampilkan di halaman" +popout: "Pop-out" +volume: "Volume" +masterVolume: "Master volume" +details: "Selengkapnya" +chooseEmoji: "Pilih emoji" +unableToProcess: "Operasi tersebut tidak dapat diselesaikan." +recentUsed: "Baru saja digunakan" +install: "Pasang" +uninstall: "Copot pemasangan" +installedApps: "Aplikasi yang diijinkan" +nothing: "Tidak ada sama sekali disini" +installedDate: "Diijinkan" +lastUsedDate: "Terakhir digunakan" +state: "Kondisi" +sort: "Urutkan" +ascendingOrder: "Urutkan naik" +descendingOrder: "Urutkan menurun" +scratchpad: "Scratchpad" +scratchpadDescription: "Scratchpad menyediakan lingkungan eksperimen untuk AiScript.\ + \ Kamu bisa menulis, mengeksuksi, serta mengecek hasil yang berinteraksi dengan\ + \ Iceshrimp." +output: "Keluaran" +script: "Script" +disablePagesScript: "Nonaktifkan script pada halaman" +updateRemoteUser: "Perbaharui informasi pengguna luar" +deleteAllFiles: "Hapus semua berkas" +deleteAllFilesConfirm: "Apakah kamu yakin ingin menghapus semua berkas?" +removeAllFollowing: "Tahan semua mengikuti" +removeAllFollowingDescription: "Batal mengikuti semua akun dari {host}. Mohon jalankan\ + \ ini ketika instansi sudah tidak ada lagi." +userSuspended: "Pengguna ini telah dibekukan." +userSilenced: "Pengguna ini telah dibungkam." +yourAccountSuspendedTitle: "Akun ini dibekukan" +yourAccountSuspendedDescription: "Akun ini dibekukan karena melanggar ketentuan penggunaan\ + \ layanan peladen atau semacamnya. Hubungi admin apabila ingin tahu alasan lebih\ + \ lanjut. Mohon untuk tidak membuat akun baru." +menu: "Menu" +divider: "Pembagi" +addItem: "Tambahkan item" +relays: "Relay" +addRelay: "Tambahkan relay" +inboxUrl: "URL Kotak masuk" +addedRelays: "Relay yang ditambahkan" +serviceworkerInfo: "Harus diaktifkan untuk pemberitahuan push." +deletedNote: "Catatan yang dihapus" +invisibleNote: "Catatan yang disembunyikan" +enableInfiniteScroll: "Aktifkan gulir tak terbatas" +visibility: "Visibilitas" +poll: "Angket" +useCw: "Sembunyikan konten" +enablePlayer: "Buka pemutar video" +disablePlayer: "Tutup pemutar video" +expandTweet: "Perluas utas" +themeEditor: "Penyunting tema" +description: "Deskripsi" +describeFile: "Tambahkan keterangan" +enterFileDescription: "Masukkan keterangan" +author: "Pembuat" +leaveConfirm: "Ada perubahan yang belum disimpan. Apakah kamu ingin membuangnya?" +manage: "Manajemen" +plugins: "Plugin" +deck: "Dek" +undeck: "Keluar dari dek" +useBlurEffectForModal: "Gunakan efek buram untuk modal" +useFullReactionPicker: "Gunakan pemilih reaksi ukuran penuh" +width: "Lebar" +height: "Tinggi" +large: "Besar" +medium: "Sedang" +small: "Kecil" +generateAccessToken: "Buat access token" +permission: "Izin" +enableAll: "Aktifkan semua" +disableAll: "Nonaktifkan semua" +tokenRequested: "Berikan ijin akses ke akun" +pluginTokenRequestedDescription: "Plugin ini dapat menggunakan setelan ijin disini." +notificationType: "Jenis pemberitahuan" +edit: "Sunting" +emailServer: "Peladen surel" +enableEmail: "Nyalakan distribusi surel" +emailConfigInfo: "Digunakan untuk mengonfirmasi surel kamu disaat mendaftar dan lupa\ + \ kata sandi" +email: "Surel" +emailAddress: "Alamat surel" +smtpConfig: "Konfigurasi peladen SMTP" +smtpHost: "Host" +smtpPort: "Port" +smtpUser: "Nama Pengguna" +smtpPass: "Kata sandi" +emptyToDisableSmtpAuth: "Kosongkan nama pengguna dan kata sandi untuk menonaktifkan\ + \ verifikasi SMTP" +smtpSecure: "Gunakan SSL/TLS implisit untuk koneksi SMTP" +smtpSecureInfo: "Matikan ini ketika menggunakan STARTTLS" +testEmail: "Tes pengiriman surel" +wordMute: "Bisukan kata" +regexpError: "Kesalahan ekspresi reguler" +regexpErrorDescription: "Galat terjadi pada baris {line} ekspresi reguler dari {tab}\ + \ kata yang dibisukan:" +instanceMute: "Bisuka instansi" +userSaysSomething: "{name} mengatakan sesuatu" +makeActive: "Aktifkan" +display: "Tampilkan" +copy: "Salin" +metrics: "Metrik" +overview: "Ikhtisar" +logs: "Log" +delayed: "Terlambat" +database: "Basis data" +channel: "Kanal" +create: "Buat" +notificationSetting: "Pengaturan Pemberitahuan" +notificationSettingDesc: "Pilih tipe pemberitahuan untuk ditampilkan" +useGlobalSetting: "Gunakan setelan global" +useGlobalSettingDesc: "Jika dinyalakan, setelan pemberitahuan akun kamu akan digunakan.\ + \ Jika dimatikan, konfigurasi secara individu dapat dibuat." +other: "Lainnya" +regenerateLoginToken: "Perbarui token login" +regenerateLoginTokenDescription: "Perbarui token yang digunakan secara internal saat\ + \ login. Normalnya aksi ini tidak diperlukan. Jika diperbarui, semua perangkat akan\ + \ dilogout." +setMultipleBySeparatingWithSpace: "Kamu dapat menyetel banyak dengan memisahkannya\ + \ menggunakan spasi." +fileIdOrUrl: "File-ID atau URL" +behavior: "Perilaku" +sample: "Contoh" +abuseReports: "Laporkan" +reportAbuse: "Laporkan" +reportAbuseOf: "Laporkan {name}" +fillAbuseReportDescription: "Mohon isi rincian laporan. Jika laporan ini mengenai\ + \ catatan yang spesifik, mohon lampirkan serta URL catatan tersebut." +abuseReported: "Laporan kamu telah dikirimkan. Terima kasih." +reporter: "Pelapor" +reporteeOrigin: "Yang dilaporkan" +reporterOrigin: "Pelapor" +forwardReport: "Teruskan laporan ke instansi luar" +forwardReportIsAnonymous: "Untuk melindungi privasi akun kamu, akun anonim dari sistem\ + \ akan digunakan sebagai pelapor pada instansi luar." +send: "Kirim" +abuseMarkAsResolved: "Tandai laporan sebagai selesai" +openInNewTab: "Buka di tab baru" +openInSideView: "Buka di tampilan samping" +defaultNavigationBehaviour: "Navigasi bawaan" +editTheseSettingsMayBreakAccount: "Menyunting pengaturan ini memiliki kemungkinan\ + \ untuk merusak akun kamu." +instanceTicker: "Informasi pengguna pada instansi" +waitingFor: "Menunggu untuk {x}" +random: "Acak" +system: "Sistem" +switchUi: "Ubah UI" +desktop: "Desktop" +clip: "Klip" +createNew: "Buat baru" +optional: "Opsional" +createNewClip: "Buat klip baru" +unclip: "Batalkan klip" +confirmToUnclipAlreadyClippedNote: "Catatan ini sudah disertakan di klip \"{name}\"\ + . Yakin ingin membatalkan catatan dari klip ini?" +public: "Publik" +i18nInfo: "Iceshrimp diterjemahkan ke dalam banyak bahasa oleh sukarelawan. Kamu dapat\ + \ ikut membantu di {link}." +manageAccessTokens: "Kelola access token" +accountInfo: "Informasi akun" +notesCount: "Jumlah catatan" +repliesCount: "Jumlah balasan terkirim" +renotesCount: "Jumlah renote terkirim" +repliedCount: "Jumlah balasan diterima" +renotedCount: "Jumlah renote diterima" +followingCount: "Jumlah akun yang diikuti" +followersCount: "Jumlah pengikut" +sentReactionsCount: "Jumlah reaksi yang terkirim" +receivedReactionsCount: "Jumlah reaksi yang diterima" +pollVotesCount: "Jumlah suara yang terkirim" +pollVotedCount: "Jumlah suara yang diterima dalam angket" +yes: "Iya" +no: "Tidak" +driveFilesCount: "Jumlah berkas drive" +driveUsage: "Penggunaan ruang penyimpanan drive" +noCrawle: "Tolak pengindeksan crawler" +noCrawleDescription: "Meminta mesin pencari untuk tidak mengindeks halaman profil\ + \ kamu, catatan, Halaman, dll." +lockedAccountInfo: "Kecuali kamu menyetel visibilitas catatan milikmu ke \"Hanya pengikut\"\ + , catatan milikmu akan dapat dilihat oleh siapa saja, bahkan jika kamu memerlukan\ + \ pengikut untuk disetujui secara manual." +alwaysMarkSensitive: "Tandai media dalam catatan sebagai media sensitif" +loadRawImages: "Tampilkan lampiran gambar secara penuh daripada thumbnail" +disableShowingAnimatedImages: "Jangan mainkan gambar bergerak" +verificationEmailSent: "Surel verifikasi telah dikirimkan. Mohon akses tautan yang\ + \ telah disertakan untuk menyelesaikan verifikasi." +notSet: "Tidak disetel" +emailVerified: "Surel telah diverifikasi" +noteFavoritesCount: "Jumlah catatan yang difavoritkan" +pageLikesCount: "Jumlah suka yang diterima Halaman" +pageLikedCount: "Jumlah Halaman yang disukai" +contact: "Kontak" +useSystemFont: "Gunakan font bawaan sistem operasi" +clips: "Klip" +experimentalFeatures: "Fitur eksperimental" +developer: "Pengembang" +makeExplorable: "Buat akun tampil di \"Jelajahi\"" +makeExplorableDescription: "Jika kamu mematikan ini, akun kamu tidak akan muncul di\ + \ bagian \"Jelajahi:" +showGapBetweenNotesInTimeline: "Tampilkan jarak diantara catatan pada linimasa" +duplicate: "Duplikat" +left: "Kiri" +center: "Tengah" +wide: "Lebar" +narrow: "Sempit" +reloadToApplySetting: "Pengaturan ini akan diterapkan saat memuat halaman kembali.\ + \ Apakah kamu ingin memuat halaman kembali sekarang?" +needReloadToApply: "Pengaturan ini hanya akan diterapkan setelah memuat ulang halaman." +showTitlebar: "Tampilkan bilah judul" +clearCache: "Hapus tembolok" +onlineUsersCount: "{n} orang sedang daring" +nUsers: "{n} Pengguna" +nNotes: "{n} Catatan" +sendErrorReports: "Kirim laporan kesalahan" +sendErrorReportsDescription: "Ketika dinyalakan, informasi kesalahan rinci akan dibagikan\ + \ dengan Iceshrimp ketika masalah terjadi, hal ini untuk membantu kualitas Iceshrimp.\ + \ Fitur ini memungkinkan memuat informasi seperti sistem operasi yang kamu gunakan\ + \ dan versinya, aplikasi peramban yang kamu gunakan, riwayat aktivitas kamu, dll." +myTheme: "Tema saya" +backgroundColor: "Latar Belakang" +accentColor: "Aksen" +textColor: "Teks" +saveAs: "Simpan sebagai…" +advanced: "Tingkat lanjut" +value: "Nilai" +createdAt: "Dibuat pada" +updatedAt: "Diperbarui pada" +saveConfirm: "Simpan perubahan?" +deleteConfirm: "Yakin hapus?" +invalidValue: "Nilai tidak valid." +registry: "Registri" +closeAccount: "Tutup akun" +currentVersion: "Versi saat ini" +latestVersion: "Versi terkini" +youAreRunningUpToDateClient: "Kamu menggunakan versi terkini dari klienmu." +newVersionOfClientAvailable: "Versi terbaru dari klien kamu telah tersedia." +usageAmount: "Penggunaan" +capacity: "Kapasitas" +inUse: "Digunakan" +editCode: "Sunting kode" +apply: "Terapkan" +receiveAnnouncementFromInstance: "Terima pemberitahuan surel dari instansi ini" +emailNotification: "Pemberitahuan surel" +publish: "Terbitkan" +inChannelSearch: "Cari di kanal" +useReactionPickerForContextMenu: "Buka pemilih reaksi dengan klik-kanan" +typingUsers: "{users} sedang mengetik" +jumpToSpecifiedDate: "Loncat ke tanggal spesifik" +showingPastTimeline: "Sedang menampilkan linimasa lama" +clear: "Bersihkan" +markAllAsRead: "Tandai semua telah dibaca" +goBack: "Kembali" +unlikeConfirm: "Yakin ingin hapus sukamu?" +fullView: "Tampilan penuh" +quitFullView: "Keluar tampilan penuh" +addDescription: "Tambahkan deskripsi" +userPagePinTip: "Kamu dapat membuat catatan untuk ditampilkan disini dengan memilih\ + \ \"Sematkan ke profil\" dari menu pada catatan individu." +notSpecifiedMentionWarning: "Catatan ini mengandung sebutan dari pengguna yang tidak\ + \ dimuat sebagai penerima" +info: "Informasi" +userInfo: "Informasi pengguna" +unknown: "Tidak diketahui" +onlineStatus: "Status daring" +hideOnlineStatus: "Sembunyikan status daring" +hideOnlineStatusDescription: "Menyembunyikan status daring kamu umengurangi kenyamanan\ + \ untuk beberapa fungsi seperti contohnya pencarian." +online: "Daring" +active: "Aktif" +offline: "Luring" +notRecommended: "Tidak disarankan" +botProtection: "Perlindungan Bot" +instanceBlocking: "Instansi yang diblokir" +selectAccount: "Pilih akun" +switchAccount: "Ganti akun" +enabled: "Aktif" +disabled: "Nonaktif" +quickAction: "Aksi cepat" +user: "Pengguna" +administration: "Manajemen" +accounts: "Akun" +switch: "Beralih" +noMaintainerInformationWarning: "Informasi pengelola belum disetel." +noBotProtectionWarning: "Proteksi bot belum disetel." +configure: "Setel" +postToGallery: "Posting ke galeri" +gallery: "Galeri" +recentPosts: "Postingan terbaru" +popularPosts: "Postingan populer" +shareWithNote: "Bagikan dengan catatan" +ads: "Iklan" +expiration: "Batas akhir" +memo: "Memo" +priority: "Prioritas" +high: "Tinggi" +middle: "Sedang" +low: "Rendah" +emailNotConfiguredWarning: "Alamat surel tidak disetel." +ratio: "Rasio" +previewNoteText: "Tampilkan pratinjau" +customCss: "Custom CSS" +customCssWarn: "Pengaturan ini seharusnya digunakan jika kamu tahu cara kerjanya.\ + \ Memasukkan nilai yang tidak tepat dapat menyebabkan klien tidak berfungsi semestinya." +global: "Global" +squareAvatars: "Tampilkan avatar sebagai persegi" +sent: "Kirim" +received: "Diterima" +searchResult: "Hasil Penelusuran" +hashtags: "Tagar" +troubleshooting: "Penyelesaian Masalah" +useBlurEffect: "Gunakan efek blur pada antarmuka" +learnMore: "Pelajari lebih lanjut" +iceshrimpUpdated: "Iceshrimp telah dimutakhirkan!" +whatIsNew: "Lihat perubahan pemutakhiran" +translate: "Terjemahkan" +translatedFrom: "Terjemahkan dari {x}" +accountDeletionInProgress: "Penghapusan akun sedang dalam proses" +usernameInfo: "Nama yang mengidentifikasikan akun kamu dari yang lain pada peladen\ + \ ini. Kamu dapat menggunakan alfabet (a~z, A~Z), digit (0~9) atau garis bawah (_).\ + \ Username tidak dapat diubah setelahnya." +aiChanMode: "Mode Ai" +keepCw: "Biarkan Peringatan Konten" +pubSub: "Akun Pub/Sub" +lastCommunication: "Komunikasi terakhir" +resolved: "Selesai" +unresolved: "Belum selesai" +breakFollow: "Batalkan mengikuti" +itsOn: "Aktif" +itsOff: "Nonaktif" +emailRequiredForSignup: "Membutuhkan alamat surel untuk mendaftar" +unread: "Belum dibaca" +filter: "Saring" +controlPanel: "Panel kendali" +manageAccounts: "Kelola Akun" +makeReactionsPublic: "Tampilkan riwayat reaksi ke publik" +makeReactionsPublicDescription: "Pengaturan ini akan membuat daftar dari semua reaksi\ + \ masa lalu kamu ditampilkan secara publik." +classic: "Klasik" +muteThread: "Bisukan thread" +unmuteThread: "Suarakan thread" +ffVisibility: "Visibilitas Mengikuti/Pengikut" +ffVisibilityDescription: "Mengatur siapa yang dapat melihat pengikutmu dan yang kamu\ + \ ikuti." +continueThread: "Lihat lanjutan thread" +deleteAccountConfirm: "Akun akan dihapus. Apakah kamu yakin?" +incorrectPassword: "Kata sandi salah." +voteConfirm: "Konfirmasi suara kamu untuk ({choice})?" +hide: "Sembunyikan" +leaveGroup: "Keluar grup" +leaveGroupConfirm: "Apakah kamu yakin untuk keluar dari \"{name}\"?" +useDrawerReactionPickerForMobile: "Tampilkan bilah reaksi sebagai laci di ponsel" +clickToFinishEmailVerification: "Mohon klik [{ok}] untuk menyelesaikan verifikasi\ + \ email." +overridedDeviceKind: "Tipe perangkat" +smartphone: "Ponsel" +tablet: "Tablet" +auto: "Otomatis" +themeColor: "Warna Tema" +size: "Ukuran" +numberOfColumn: "Jumlah per kolom" +searchByGoogle: "Penelusuran" +instanceDefaultLightTheme: "Bawaan instan tema terang" +instanceDefaultDarkTheme: "Bawaan instan tema gelap" +instanceDefaultThemeDescription: "Masukkan kode tema di format obyek." +mutePeriod: "Batas waktu bisu" +indefinitely: "Selamanya" +tenMinutes: "10 Menit" +oneHour: "1 Jam" +oneDay: "1 Hari" +oneWeek: "1 Bulan" +reflectMayTakeTime: "Mungkin perlu beberapa saat untuk dicerminkan." +failedToFetchAccountInformation: "Gagal untuk mendapatkan informasi akun" +rateLimitExceeded: "Batas sudah terlampaui" +cropImage: "potong gambar" +cropImageAsk: "Ingin memotong gambar?" +file: "Berkas" +reverse: "Balik" +colored: "Diwarnai" +label: "Label" +localOnly: "Hanya lokal" +account: "Akun" +_emailUnavailable: + used: "Alamat surel ini telah digunakan" + format: "Format tidak valid." + disposable: "Alamat surel temporer tidak dapat digunakan" + mx: "Peladen alamat surel ini tidak valid" + smtp: "Peladen alamat surel ini tidak merespon" +_ffVisibility: + public: "Terbitkan" + followers: "Tampil untuk pengikut saja" + private: "Tersembunyi" +_signup: + almostThere: "Hampir selesai" + emailAddressInfo: "Mohon masukkan alamat surel kamu." + emailSent: "Konfirmasi surel telah dikirimkan ke alamat surel kamu ({email}). Mohon\ + \ klik tautan yang tercantum di dalamnya untuk menyelesaikan pembuatan akun." +_accountDelete: + accountDelete: "Hapus akun" + mayTakeTime: "Karena penghapusan akun merupakan proses yang berat dan intensif,\ + \ kemungkinan dapat membutuhkan waktu untuk menyelesaikan tergantung daripada\ + \ berapa banyak konten yang kamu buat dan berapa banyak berkas yang telah kamu\ + \ unggah." + sendEmail: "Setelah penghapusan akun selesai, pemberitahuan akan dikirimkan ke alamat\ + \ surel yang terdaftarkan pada akun ini." + requestAccountDelete: "Minta penghapusan akun" + started: "Penghapusan telah dimulai" + inProgress: "Penghapusan sedang dalam proses" +_ad: + back: "Kembali" + reduceFrequencyOfThisAd: "Tampilkan iklan ini lebih sedikit" +_forgotPassword: + enterEmail: "Masukkan alamat surel yang kamu gunakan pada saat mendaftar. Sebuah\ + \ tautan untuk mengatur ulang kata sandi kamu akan dikirimkan ke alamat surel\ + \ tersebut." + ifNoEmail: "Apabila kamu tidak menggunakan surel pada saat pendaftaran, mohon hubungi\ + \ admin segera." + contactAdmin: "Instansi ini tidak mendukung menggunakan alamat surel, mohon kontak\ + \ admin untuk mengatur ulang password kamu." +_gallery: + my: "Postingan saya" + liked: "Postingan yang disukai" + like: "Suka" + unlike: "Hapus suka" +_email: + _follow: + title: "Mengikuti kamu" + _receiveFollowRequest: + title: "Kamu menerima permintaan mengikuti" +_plugin: + install: "Memasang plugin" + installWarn: "Mohon jangan memasang plugin yang tidak dapat dipercayai." + manage: "Manajemen plugin" +_registry: + scope: "Lingkup" + key: "Kunci" + keys: "Kunci" + domain: "Domain" + createKey: "Buat kunci" +_aboutIceshrimp: + about: "Iceshrimp adalah perangkat lunak sumber terbuka yang sedang dikembangkan oleh\ + \ syuilo sejak 2014." + contributors: "Kontributor utama" + allContributors: "Seluruh kontributor" + source: "Sumber kode" + translation: "Terjemahkan Iceshrimp" + donate: "Donasi ke Iceshrimp" + morePatrons: "Kami sangat mengapresiasi dukungan dari banyak penolong lain yang\ + \ tidak tercantum disini. Terima kasih! \U0001F970" + patrons: "Pendukung" +_nsfw: + respect: "Sembunyikan media NSFW" + ignore: "Jangan sembunyikan media NSFW" + force: "Sembunyikan semua media" +_mfm: + cheatSheet: "Contekan MFM" + intro: "MFM adalah Iceshrimp-exclusive Markup Language yang dapat digunakan di banyak\ + \ tempat. Berikut kamu bisa melihat daftar dari syntax MFM yang ada." + dummy: "Iceshrimp membentangkan dunia Fediverse" + mention: "Sebut" + mentionDescription: "Kamu dapat menentukan pengguna tertentu dengan menggunakan\ + \ simbol-At dan nama engguna mereka." + hashtag: "Tagar" + hashtagDescription: "Kamu dapat menentukan tagar dengan menggunakan angka dan teks." + url: "URL" + urlDescription: "URL dapat ditampilkan." + link: "Tautan" + linkDescription: "Bagian tertentu dari teks dapat ditampilka sebagai URL." + bold: "Tebal" + boldDescription: "Sorot tulisan dengan membuatnya tebal." + small: "Kecil" + smallDescription: "Tampilkan konten kecil dan tipis." + center: "Tengah" + centerDescription: "Tampilkan konten di tengah." + inlineCode: "Kode (Dalam baris)" + inlineCodeDescription: "Menampilkan sorotan sintaks dalam baris untuk kode(program-)." + blockCode: "Kode (Blok)" + blockCodeDescription: "Menampilkan sorotan sintaks untuk kode(program-) multi baris\ + \ dalam sebuah blok." + inlineMath: "Matematika (Dalam baris)" + inlineMathDescription: "Menampilkan formula matematika (KaTeX) dalam baris." + blockMath: "Matematika (Blok)" + blockMathDescription: "Menampilkan formula matematika (KaTeX) multibaris dalam sebuah\ + \ blok." + quote: "Kutip" + quoteDescription: "Menampilkan konten sebagai kutipan." + emoji: "Emoji kustom" + emojiDescription: "Emoji kustom dapat ditampilkan dengan mengurung nama emoji kustom\ + \ menggunakan tanda titik dua." + search: "Penelusuran" + searchDescription: "Menampilkan kotak pencarian dengan teks yang sudah dimasukkan." + flip: "Balik" + flipDescription: "Balikkan konten secara horizontal atau vertikal." + jelly: "Animasi (Jelly)" + jellyDescription: "Menerapkan animasi seperti jelly" + tada: "Animasi (Tada)" + tadaDescription: "Menerapkan animasi seperti \"Kejutan!\"." + jump: "Animasi (Loncat)" + jumpDescription: "Menerapkan animasi melompat." + bounce: "Animasi (Melambung)" + bounceDescription: "Menerapkan animasi melambung." + shake: "Animasi (Goyang)" + shakeDescription: "Menerapkan animasi bergoyang." + twitch: "Animasi (Cubit)" + twitchDescription: "Terapkan animasi cubit yang kuat." + spin: "Animasi (Putar)" + spinDescription: "Terapkan animasi putar." + x2: "Besar" + x2Description: "Tampilkan konten menjadi besar." + x3: "Lebih besar" + x3Description: "Tampilkan konten menjadi lebih besar." + x4: "Sangat besar" + x4Description: "Tampilka konten menjadi sangat besar." + blur: "Buram" + blurDescription: "Konten dapat diburamkan dengan efek ini. Konten dapat ditampilkan\ + \ dengan jelas dengan melayangkan kursor tetikus di atasnya." + font: "Font" + fontDescription: "Setel font yang ditampilkan untuk konten." + rainbow: "Pelangi" + rainbowDescription: "Membuat konten muncul dalam warna pelangi." + sparkle: "Kelap-kelip" + sparkleDescription: "Memberikan konten efek partikel kelap-kelip." + rotate: "Putar" + rotateDescription: "Putar konten sesuai sudut yang ditentukan." +_instanceTicker: + none: "Jangan tampilkan" + remote: "Tampilkan untuk pengguna luar" + always: "Selalu tampilkan" +_serverDisconnectedBehavior: + reload: "Muat ulang otomatis" + dialog: "Tampilkan dialog peringatan" + quiet: "Tampilkan peringatan tidak mengganggu" +_channel: + create: "Buat saluran" + edit: "Sunting saluran" + setBanner: "Setel banner" + removeBanner: "Hapus banner" + featured: "Tren" + owned: "Dimiliki" + following: "Mengikuti" + usersCount: "{n} Partisipan" + notesCount: "terdapat {n} catatan" +_menuDisplay: + sideFull: "Horisontal" + sideIcon: "Horisontal (Ikon)" + top: "Atas" + hide: "Sembunyikan" +_wordMute: + muteWords: "Kata yang dibisukan" + muteWordsDescription: "Pisahkan dengan spasi untuk kondisi AND. Pisahkan dengan\ + \ baris baru untuk kondisi OR." + muteWordsDescription2: "Kurung kata kunci dengan garis miring untuk menggunakan\ + \ regular expressions." + softDescription: "Sembunyikan catatan yang memenuhi aturan kondisi dari linimasa." + hardDescription: "Cegah catatan memenuhi aturan kondisi dari ditambahkan ke linimasa.\ + \ Dengan tambahan, catatan berikut tidak akan ditambahkan ke linimasa meskipun\ + \ jika kondisi tersebut diubah." + soft: "Lembut" + hard: "Keras" + mutedNotes: "Catatan yang dibisukan" +_instanceMute: + instanceMuteDescription: "Pengaturan ini akan membisukan note/renote apa saja dari\ + \ instansi yang terdaftar, termasuk pengguna yang membalas pengguna lain dalam\ + \ instansi yang dibisukan." + instanceMuteDescription2: "Pisah dengan baris baru" + title: "Sembunyikan note dari instansi terdaftar." + heading: "Daftar instansi yang akan dibisukan" +_theme: + explore: "Jelajahi tema" + install: "Pasang tema" + manage: "Manajer tema" + code: "Kode tema" + description: "Deskripsi" + installed: "{name} telah dipasang" + installedThemes: "Tema yang dipasang" + builtinThemes: "Tema bawaan" + alreadyInstalled: "Tema telah dipasang" + invalid: "Format tema tidak valid" + make: "Buat tema" + base: "Dasar" + addConstant: "Tambah konstanta" + constant: "Konstanta" + defaultValue: "Nilai bawaan" + color: "Warna" + refProp: "Referensikan properti" + refConst: "Referensikan konstanta" + key: "Kunci" + func: "Fungsi" + funcKind: "Tipe fungsi" + argument: "Argumen" + basedProp: "Mereferensikan properti" + alpha: "Opasitas" + darken: "Mengelamkan" + lighten: "Menerangkan" + inputConstantName: "Masukkan nama untuk konstanta" + importInfo: "Jika kamu memasukkan kode tema disini, kamu dapat mengimpornya ke penyunting\ + \ tema" + deleteConstantConfirm: "apakah kamu ingin menghapus konstanta {const}?" + keys: + accent: "Aksen" + bg: "Latar belakang" + fg: "Teks" + focus: "Fokus" + indicator: "Indikator" + panel: "Panel" + shadow: "Bayangan" + header: "Header" + navBg: "Latar belakang bilah samping" + navFg: "Teks bilah samping" + navHoverFg: "Teks bilah samping (Mengambang)" + navActive: "Teks bilah samping (Aktif)" + navIndicator: "Indikator bilah samping" + link: "Tautan" + hashtag: "Tagar" + mention: "Sebut" + mentionMe: "Sebutan (saya)" + renote: "Renote" + modalBg: "Latar belakang modal" + divider: "Pembagi" + scrollbarHandle: "Pegangan bilah gulir" + scrollbarHandleHover: "Pegangan bilah gulir (Mengambang)" + dateLabelFg: "Teks label tanggal" + infoBg: "Latar belakang informasi" + infoFg: "Teks informasi" + infoWarnBg: "Latar belakang peringatan" + infoWarnFg: "Teks peringatan" + cwBg: "Latar belakang tombol Sembunyikan Konten" + cwFg: "Teks tombol Sembunyikan Konten" + cwHoverBg: "Latar belakang tombol Sembunyikan Konten (Mengambang)" + toastBg: "Latar belakang pemberitahuan" + toastFg: "Teks pemberitahuan" + buttonBg: "Latar belakang tombol" + buttonHoverBg: "Latar belakang tombol (Mengambang)" + inputBorder: "Batas bidang masukan" + listItemHoverBg: "Latar belakang daftar item (Mengambang)" + driveFolderBg: "Latar belakang folder drive" + wallpaperOverlay: "Lapisan wallpaper" + badge: "Lencana" + messageBg: "Latar belakang obrolan" + accentDarken: "Aksen (Gelap)" + accentLighten: "Aksen (Terang)" + fgHighlighted: "Teks yang disorot" +_sfx: + note: "Catatan" + noteMy: "Catatan (Saya)" + notification: "Pemberitahuan" + chat: "Pesan" + chatBg: "Obrolan (Latar Belakang)" + antenna: "Penerimaan Antenna" + channel: "Pemberitahuan saluran" +_ago: + future: "Masa depan" + justNow: "Baru saja" + secondsAgo: "{n} detik lalu" + minutesAgo: "{n} menit {n2} detik lalu" + hoursAgo: "{n} jam {n2} menit lalu" + daysAgo: "{n} hari {n2} jam lalu" + weeksAgo: "{n} minggu {n2} hari lalu" + monthsAgo: "{n} bulan {n2} minggu lalu" + yearsAgo: "{n} tahu {n2} bulan lalu" +_time: + second: "detik" + minute: "menit" + hour: "jam" + day: "hari" +_tutorial: + title: "Cara menggunakan Iceshrimp" + step1_1: "Selamat datang!" + step1_2: "Halaman ini disebut \"linimasa\". Halaman ini menampilkan \"catatan\"\ + \ yang diurutkan secara kronologis dari orang-orang yang kamu \"ikuti\"." + step1_3: "Linimasa kamu kosong, karena kamu belum mencatat catatan apapun atau mengikuti\ + \ siapapun." + step2_1: "Selesaikan menyetel profilmu sebelum menulis sebuah catatan atau mengikuti\ + \ seseorang." + step2_2: "Menyediakan beberapa informasi tentang siapa kamu akan membuat orang lain\ + \ mudah untuk mengikutimu kembali." + step3_1: "Sekarang saatnya mengikuti beberapa orang!" + step3_2: "Langkah selanjutnya adalah membuat catatan. Kamu bisa lakukan ini dengan\ + \ mengklik ikon pensil pada layar kamu." + step3_3: "Isilah di dalam modal dan tekan tombol pada atas kanan untuk memcatat\ + \ catatan kamu." + step3_4: "Bingung tidak berpikiran untuk mengatakan sesuatu? Coba saja \"baru aja\ + \ ikutan bikin akun iceshrimp punyaku\"!" + step4_1: "Selesai mencatat catatan pertamamu?" + step4_2: "Horee! Sekarang catatan pertamamu sudah ditampilkan di linimasa milikmu." + step5_1: "Sekarang, mari mencoba untuk membuat linimasamu lebih hidup dengan mengikuti\ + \ orang lain." + step5_2: "{featured} akan memperlihatkan catatan yang sedang tren saat ini untuk\ + \ kamu. {explore} akan membantumu untuk mencari pengguna yang sedang tren juga\ + \ saat ini. Coba ikuti seseorang yang kamu suka!" + step5_3: "Untuk mengikuti pengguna lain, klik pada ikon mereka dan tekan tombol\ + \ follow pada profil mereka." + step5_4: "Jika pengguna lain memiliki ikon gembok di sebelah nama mereka, maka pengguna\ + \ rersebut harus menyetujui permintaan mengikuti dari kamu secara manual." + step6_1: "Sekarang kamu dapat melihat catatan pengguna lain pada linimasamu." + step6_2: "Kamu juga bisa memberikan \"reaksi\" ke catatan orang lain untuk merespon\ + \ dengan cepat." + step6_3: "Untuk memberikan \"reaksi\", tekan tanda \"+\" pada catatan pengguna lain\ + \ dan pilih emoji yang kamu suka untuk memberikan reaksimu kepada mereka." + step7_1: "Yay, Selamat! Kamu sudah menyelesaikan tutorial dasar Iceshrimp." + step7_2: "Jika kamu ingin mempelajari lebih lanjut tentang Iceshrimp, cobalah berkunjung\ + \ ke bagian {help}." + step7_3: "Semoga berhasil dan bersenang-senanglah! \U0001F680" +_2fa: + alreadyRegistered: "Kamu telah mendaftarkan perangkat otentikasi dua faktor." + registerTOTP: "Daftarkan perangkat baru" + registerSecurityKey: "Daftarkan kunci keamanan baru" + step1: "Pertama, pasang aplikasi otentikasi (seperti {a} atau {b}) di perangkat\ + \ kamu." + step2: "Lalu, pindai kode QR yang ada di layar." + step2Url: "Di aplikasi desktop, masukkan URL berikut:" + step3: "Masukkan token yang telah disediakan oleh aplikasimu untuk menyelesaikan\ + \ pemasangan." + step4: "Mulai sekarang, upaya login apapun akan meminta token login dari aplikasi\ + \ otentikasi kamu." + securityKeyInfo: "Kamu dapat memasang otentikasi WebAuthN untuk mengamankan proses\ + \ login lebih lanjut dengan tidak hanya perangkat keras kunci keamanan yang mendukung\ + \ FIDO2, namun juga sidik jari atau otentikasi PIN pada perangkatmu." +_permissions: + "read:account": "Lihat informasi akun" + "write:account": "Sunting informasi akun" + "read:blocks": "Lihat daftar orang yang diblokir" + "write:blocks": "Sunting daftar orang yang diblokir" + "read:drive": "Akses berkas dan folder drive" + "write:drive": "Sunting atau hapus berkas dan folder drive" + "read:favorites": "Lihat daftar favorit" + "write:favorites": "Sunting daftar favorit" + "read:following": "Lihat informasi mengikuti" + "write:following": "Ikuti atau Stop Ikuti akun lain" + "read:messaging": "Lihat obrolan" + "write:messaging": "Buat atau hapus obrolan" + "read:mutes": "Lihat daftar orang yang dibisukan" + "write:mutes": "Sunting daftar orang yang dibisukan" + "write:notes": "Buat atau hapus catatan" + "read:notifications": "Lihat pemberitahuan" + "write:notifications": "Sunting pemberitahuan" + "read:reactions": "Lihat reaksi" + "write:reactions": "Sunting reaksi" + "write:votes": "Beri suara" + "read:pages": "Lihat halaman" + "write:pages": "Sunting atau hapus halaman" + "read:page-likes": "Lihat suka pada halaman" + "write:page-likes": "Sunting suka pada Halaman" + "read:user-groups": "Lihat grup pengguna" + "write:user-groups": "Sunting atau hapus grup pengguna" + "read:channels": "Lihat saluran" + "write:channels": "Sunting saluran" + "read:gallery": "Lihat galeri" + "write:gallery": "Sunting galeri" + "read:gallery-likes": "Lihat daftar postingan galeri yang disukai" + "write:gallery-likes": "Sunting daftar postingan galeri yang disukai" +_auth: + shareAccess: "Apakah kamu ingin mengijinkan \"{name}\" untuk mengakses akun ini?" + shareAccessAsk: "Apakah kamu ingin mengijinkan aplikasi ini untuk mengakses akun\ + \ kamu?" + permissionAsk: "Aplikasi ini membutuhkan beberapa ijin, yaitu:" + pleaseGoBack: "Mohon kembali ke aplikasi kamu" + callback: "Mengembalikan kamu ke aplikasi" + denied: "Akses ditolak" +_antennaSources: + all: "Semua catatan" + homeTimeline: "Catatan dari pengguna yang diikuti" + users: "Catatan dari pengguna tertentu" + userList: "Catatan dari daftar tertentu" + userGroup: "Catatan dari pengguna dalam grup yang ditentukan" +_weekday: + sunday: "Minggu" + monday: "Senin" + tuesday: "Selasa" + wednesday: "Rabu" + thursday: "Kamis" + friday: "Jumat" + saturday: "Sabtu" +_widgets: + memo: "Catatan memo" + notifications: "Pemberitahuan" + timeline: "Linimasa" + calendar: "Kalender" + trends: "Tren" + clock: "Jam" + rss: "Pembaca RSS" + activity: "Aktivitas" + photos: "Foto" + digitalClock: "Jam digital" + federation: "Federasi" + postForm: "Buat catatan" + slideshow: "Slideshow" + button: "Tombol" + onlineUsers: "Pengguna online" + jobQueue: "Antrian kerja" + serverMetric: "Statistik peladen" + aiscript: "Konsol AiScript" + aichan: "Ai" +_cw: + hide: "Sembunyikan" + show: "Lihat konten" + chars: "{count} karakter" + files: "{count} berkas" +_poll: + noOnlyOneChoice: "Dibutuhkan sedikitnya dua pilihan" + choiceN: "Pilihan {n}" + noMore: "Kamu tidak dapat menambahkan pilihan lagi" + canMultipleVote: "Bolehkan memilih banyak" + expiration: "Batas akhir" + infinite: "Selamanya" + at: "Berakhir pada..." + after: "Berakhir setelah..." + deadlineDate: "Tanggal batas akhir" + deadlineTime: "jam" + duration: "Durasi" + votesCount: "{n} suara" + totalVotes: "Total {n} suara" + vote: "Beri suara" + showResult: "Lihat hasil" + voted: "Telah memilih" + closed: "Telah berakhir" + remainingDays: "Berakhir dalam {d} hari {h} jam" + remainingHours: "Berakhir dalam {h} jam {m} menit" + remainingMinutes: "Berakhir dalam {m} menit {s} detik" + remainingSeconds: "Berakhir dalam {s} detik" +_visibility: + public: "Publik" + publicDescription: "Catat ke linimasa global" + home: "Beranda" + homeDescription: "Catat ke linimasa beranda saja" + followers: "Pengikut" + followersDescription: "Catat ke pengikut saja" + specified: "Langsung" + specifiedDescription: "Catat ke pengguna yang ditentukan saja" + localOnly: "Hanya lokal" + localOnlyDescription: "Hanya dapat dilihat di instansi lokal" +_postForm: + replyPlaceholder: "Balas ke catatan ini..." + quotePlaceholder: "Kutip catatan ini..." + channelPlaceholder: "Posting ke kanal" + _placeholders: + a: "Sedang apa kamu saat ini?" + b: "Apa yang terjadi di sekitarmu?" + c: "Apa yang sedang kamu pikirkan?" + d: "Yang ingin kamu sampaikan?" + e: "Tuliskan yang kamu ingin sampaikan..." + f: "Menunggu kamu untuk menulis...." +_profile: + name: "Nama" + username: "Nama Pengguna" + description: "Bio" + youCanIncludeHashtags: "Kamu juga dapat menambahkan tagar ke dalam bio." + metadata: "Informasi tambahan" + metadataEdit: "Sunting informasi tambahan" + metadataDescription: "Kamu dapat menampilkan hingga 4 bagian informasi tambahan\ + \ ke dalam profilmu. Anda dapat menambahkan tag {a} atau tag {l} dengan {rel} untuk memverifikasi tautan di profil Anda!" + metadataLabel: "Label" + metadataContent: "Isi" + changeAvatar: "Ubah avatar" + changeBanner: "Ubah header" +_exportOrImport: + allNotes: "Semua catatan" + followingList: "Ikuti" + muteList: "Bisukan" + blockingList: "Blokir" + userLists: "Daftar" + excludeMutingUsers: "Kecualikan pengguna yang dibisukan" + excludeInactiveUsers: "Kecualikan pengguna tidak aktif" +_charts: + federation: "Federasi" + apRequest: "Permintaan" + usersIncDec: "Perbedaan dalam # pengguna" + usersTotal: "Jumlah # pengguna" + activeUsers: "Pengguna aktif" + notesIncDec: "Perbedaan # dalam catatan" + localNotesIncDec: "Perbedaan # dalam catatan lokal" + remoteNotesIncDec: "Perbedaan # dalam catatan luar" + notesTotal: "Total # catatan" + filesIncDec: "Perbedaan # dalam berkas" + filesTotal: "Jumlah # berkas" + storageUsageIncDec: "Perbedaan dalam penggunaan penyimpanan" + storageUsageTotal: "Jumlah penggunaan penyimpanan" +_instanceCharts: + requests: "Permintaan" + users: "Perbedaan dalam # pengguna" + usersTotal: "Jumlah # pengguna kumulatif" + notes: "Perbedaan # dalam catatan" + notesTotal: "Jumlah # catatan kumulatif" + ff: "Perbedaan jumlah # dalam pengikut" + ffTotal: "Jumlah # pengikut kumulatif" + cacheSize: "Perbedaan dalam ukuran tembolok" + cacheSizeTotal: "Total ukuran tembolok kumulatif" + files: "Perbedaan dalam # berkas" + filesTotal: "Jumlah # berkas kumulatif" +_timelines: + home: "Beranda" + local: "Lokal" + social: "Sosial" + global: "Global" +_pages: + newPage: "Buat halaman baru" + editPage: "Sunting halaman" + readPage: "Lihat sumber kode aktif" + created: "Halaman berhasil dibuat" + updated: "Halaman berhasil diperbaharui!" + deleted: "Halaman telah dihapus" + pageSetting: "Pengaturan Halaman" + nameAlreadyExists: "URL Halaman yang ditentukan sudah ada" + invalidNameTitle: "URL Halaman yang ditentukan tidak valid" + invalidNameText: "Cek apabila Halaman tidak kosong" + editThisPage: "Sunting Halaman ini" + viewSource: "Lihat sumber" + viewPage: "Lihat Halaman" + like: "Suka" + unlike: "Hapus suka" + my: "Halaman saya" + liked: "Halaman yang disukai" + featured: "Populer" + inspector: "Inspektor" + contents: "Konten" + content: "Blokir Halaman" + variables: "Variabel" + title: "Judul" + url: "URL Halaman" + summary: "Ringkasan Halaman" + alignCenter: "Tengah" + hideTitleWhenPinned: "Sembunyikan judul halaman saat disematkan ke profil" + font: "Font" + fontSerif: "Serif" + fontSansSerif: "Sans-serif" + eyeCatchingImageSet: "Setel gambar yang menarik" + eyeCatchingImageRemove: "Hapus gambar yang menarik" + chooseBlock: "Tambahkan blokir" + selectType: "Pilih jenis" + enterVariableName: "Mohon masukkan nama untuk variabel kamu" + variableNameIsAlreadyUsed: "Nama ini sudah digunakan oleh variabel lain" + contentBlocks: "Konten" + inputBlocks: "Masukan" + specialBlocks: "Khusus" + blocks: + text: "Teks" + textarea: "Area teks" + section: "Bagian" + image: "Gambar" + button: "Tombol" + if: "Jika" + _if: + variable: "Variabel" + post: "Buat catatan" + _post: + text: "Isi" + attachCanvasImage: "Posting dengan kanvas sebagai gambar" + canvasId: "ID Kanvas" + textInput: "Masukan teks" + _textInput: + name: "Nama variabel" + text: "Judul" + default: "Nilai bawaan" + textareaInput: "Masukan teks multibaris" + _textareaInput: + name: "Nama variabel" + text: "Judul" + default: "Nilai bawaan" + numberInput: "Masukan angka" + _numberInput: + name: "Nama variabel" + text: "Judul" + default: "Nilai bawaan" + canvas: "Kanvas" + _canvas: + id: "ID Kanvas" + width: "Lebar" + height: "Tinggi" + note: "Catatan yang ditanam" + _note: + id: "ID Catatan" + idDescription: "Kamu dapat menyetel ini dengan menempelkan tautan URL Catatan." + detailed: "Tampilan rincian" + switch: "Beralih" + _switch: + name: "Nama variabel" + text: "Judul" + default: "Nilai bawaan" + counter: "Penghitung" + _counter: + name: "Nama variabel" + text: "Judul" + inc: "Meningkat dengan" + _button: + text: "Judul" + colored: "Diwarnai" + action: "Operasi akan dimulai ketika tombol ditekan" + _action: + dialog: "Tampilkan dialog" + _dialog: + content: "Isi" + resetRandom: "Atur ulang benih acak" + pushEvent: "Kirim event" + _pushEvent: + event: "Nama event" + message: "Pesan yang tampil ketika diaktifkan" + variable: "Variable untuk kirim" + no-variable: "Tidak ada" + callAiScript: "Panggil AiScript" + _callAiScript: + functionName: "Nama fungsi" + radioButton: "Pilihan" + _radioButton: + name: "Nama variabel" + title: "Judul" + values: "Daftar pilihan (dipisahkan dengan garis baru)" + default: "Nilai bawaan" + script: + categories: + flow: "Arus kendali" + logical: "Operasi logis" + operation: "Menghitung" + comparison: "Membandingkan" + random: "Acak" + value: "Nilai" + fn: "Fungsi" + text: "Operasi teks" + convert: "Mengubah" + list: "Daftar" + blocks: + text: "Teks" + multiLineText: "Teks (multibaris)" + textList: "Daftar teks" + _textList: + info: "Pisahkan setiap entri dengan baris baru" + strLen: "Panjang teks" + _strLen: + arg1: "Teks" + strPick: "Ekstrak karakter" + _strPick: + arg1: "Teks" + arg2: "Lokasi karakter" + strReplace: "Penggantian teks" + _strReplace: + arg1: "Teks" + arg2: "Teks yang akan diganti" + arg3: "Diganti dengan" + strReverse: "Balikkan teks" + _strReverse: + arg1: "Teks" + join: "Rangkaian teks" + _join: + arg1: "Daftar" + arg2: "Pemisah" + add: "Tambah" + _add: + arg1: "A" + arg2: "B" + subtract: "Kurangi" + _subtract: + arg1: "A" + arg2: "B" + multiply: "Kali" + _multiply: + arg1: "A" + arg2: "B" + divide: "Bagi" + _divide: + arg1: "A" + arg2: "B" + mod: "Sisa" + _mod: + arg1: "A" + arg2: "B" + round: "Bulat desimal" + _round: + arg1: "Angka" + eq: "A dan B adalah sama" + _eq: + arg1: "A" + arg2: "B" + notEq: "A dan B adalah berbeda" + _notEq: + arg1: "A" + arg2: "B" + and: "A DAN B" + _and: + arg1: "A" + arg2: "B" + or: "A ATAU B" + _or: + arg1: "A" + arg2: "B" + lt: "< A ikurang dari B" + _lt: + arg1: "A" + arg2: "B" + gt: "> A lebih dari B" + _gt: + arg1: "A" + arg2: "B" + ltEq: "<= A kurang dari sama dengan B" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: ">= A lebih dari sama dengan B" + _gtEq: + arg1: "A" + arg2: "B" + if: "Cabang" + _if: + arg1: "Jika" + arg2: "Jika benar" + arg3: "Jika salah" + not: "BUKAN" + _not: + arg1: "NOT" + random: "Acak" + _random: + arg1: "Probabilitas" + rannum: "Angka acak" + _rannum: + arg1: "Nilai minimum" + arg2: "Nilai maksimum" + randomPick: "Pilih secara acak dari daftar" + _randomPick: + arg1: "Daftar" + dailyRandom: "Acak (bertahan sehari)" + _dailyRandom: + arg1: "Probabilitas" + dailyRannum: "Angka acak (bertahan sehari)" + _dailyRannum: + arg1: "Nilai minimum" + arg2: "Nilai maksimum" + dailyRandomPick: "Pilih secara acak dari daftar (bertahan sehari)" + _dailyRandomPick: + arg1: "Daftar" + seedRandom: "Acak (dengan seed)" + _seedRandom: + arg1: "Seed" + arg2: "Probabilitas" + seedRannum: "Angka acak (dengan seed)" + _seedRannum: + arg1: "Seed" + arg2: "Nilai minimum" + arg3: "Nilai maksimum" + seedRandomPick: "Pilih secara acak dari daftar (dengan seed)" + _seedRandomPick: + arg1: "Seed" + arg2: "Daftar" + DRPWPM: "Pilih secara acak dari daftar berbobot (bertahan sehari)" + _DRPWPM: + arg1: "Daftar teks" + pick: "Pilih dari daftar" + _pick: + arg1: "Daftar" + arg2: "Posisi" + listLen: "Dapatkan panjangnya dari daftar" + _listLen: + arg1: "Daftar" + number: "Angka" + stringToNumber: "Teks ke angka" + _stringToNumber: + arg1: "Teks" + numberToString: "Angka ke teks" + _numberToString: + arg1: "Angka" + splitStrByLine: "Pisahkan teks dengan baris baru" + _splitStrByLine: + arg1: "Teks" + ref: "Variabel" + aiScriptVar: "Variabel AiScript" + fn: "Fungsi" + _fn: + slots: "Slot" + slots-info: "Pisahkan setiap slot dengan baris baru" + arg1: "Keluaran" + for: "Ulangi" + _for: + arg1: "Jumlah angka untuk diulangi" + arg2: "Aksi" + typeError: "Slot {slot} menerima tipe \"{expect}\", sayangnya nilai yang disediakan\ + \ adalah \"{actual}\"!" + thereIsEmptySlot: "Slot {slot} kosong!" + types: + string: "Teks" + number: "Angka" + boolean: "Markah" + array: "Daftar" + stringArray: "Daftar teks" + emptySlot: "Slot kosong" + enviromentVariables: "Variabel Lingkungan" + pageVariables: "Elemen halaman" + argVariables: "Masukan slot" +_relayStatus: + requesting: "Menunggu" + accepted: "Disetujui" + rejected: "Ditolak" +_notification: + fileUploaded: "Berkas telah berhasil diunggah" + youGotMention: "{name} meyebut kamu" + youGotReply: "{name} membalas kamu" + youGotQuote: "{name} mengutip kamu" + youRenoted: "{name} me-renote kamu" + youGotPoll: "{name} memilih di angket kamu" + youGotMessagingMessageFromUser: "{name} mengirimi kamu pesan" + youGotMessagingMessageFromGroup: "Sebuah pesan telah dikirim ke grup {name}" + youWereFollowed: "Mengikuti kamu" + youReceivedFollowRequest: "Kamu menerima permintaan mengikuti" + yourFollowRequestAccepted: "Permintaan mengikuti kamu telah diterima" + youWereInvitedToGroup: "Telah diundang ke grup" + pollEnded: "Hasil Kuesioner telah keluar" + emptyPushNotificationMessage: "Pembaruan notifikasi dorong" + _types: + all: "Semua" + follow: "Ikuti" + mention: "Sebut" + reply: "Balasan" + renote: "Renote" + quote: "Kutip" + reaction: "Reaksi" + pollVote: "Memilih di angket" + pollEnded: "Jajak pendapat berakhir" + receiveFollowRequest: "Permintaan mengikuti diterima" + followRequestAccepted: "Permintaan mengikuti disetujui" + groupInvited: "Diundang ke grup" + app: "Pemberitahuan dari aplikasi" + _actions: + followBack: "Ikuti Kembali" + reply: "Balas" + renote: "Renote" +_deck: + alwaysShowMainColumn: "Selalu tampilkan kolom utama" + columnAlign: "Luruskan kolom" + addColumn: "Tambahkan kolom" + swapLeft: "Pindah ke kiri" + swapRight: "Pindah ke kanan" + swapUp: "Pindah ke atas" + swapDown: "Pindah ke bawah" + stackLeft: "Tumpukkan di kolom kiri" + popRight: "Keluarkan di kanan" + profile: "Profil" + _columns: + main: "Utama" + widgets: "Widget" + notifications: "Pemberitahuan" + tl: "Linimasa" + antenna: "Antena" + list: "Daftar" + mentions: "Sebutan" + direct: "Langsung" diff --git a/locales/index.d.ts b/locales/index.d.ts new file mode 100644 index 0000000..fe3edb4 --- /dev/null +++ b/locales/index.d.ts @@ -0,0 +1,3 @@ +declare const locales: { [lang: string]: any }; + +export = locales; diff --git a/locales/index.js b/locales/index.js new file mode 100644 index 0000000..66b892a --- /dev/null +++ b/locales/index.js @@ -0,0 +1,90 @@ +/** + * Languages Loader + */ + +import { readdirSync, readFileSync } from "fs"; +import { fileURLToPath } from "url"; +import { load } from "js-yaml"; +const languages = []; +const languages_custom = []; +const rootDir = fileURLToPath(new URL("..", import.meta.url)); +const customDir = process.env.ICESHRIMP_CUSTOM_DIR ?? `${rootDir}/custom`; + +const merge = (...args) => + args.reduce( + (a, c) => ({ + ...a, + ...c, + ...Object.entries(a) + .filter(([k]) => c && typeof c[k] === "object") + .reduce((a, [k, v]) => ((a[k] = merge(v, c[k])), a), {}), + }), + {}, + ); + +readdirSync(import.meta.dirname).forEach((file) => { + if (file.includes(".yml")) { + file = file.slice(0, file.indexOf(".")); + languages.push(file); + } +}); + +readdirSync(`${customDir}/locales`).forEach((file) => { + if (file.includes(".yml")) { + file = file.slice(0, file.indexOf(".")); + languages_custom.push(file); + } +}); + +const primaries = { + en: "US", + ja: "JP", + zh: "CN", +}; + +// 何故か文字列にバックスペース文字が混入することがあり、YAMLが壊れるので取り除く +const clean = (text) => + text.replace(new RegExp(String.fromCodePoint(0x08), "g"), ""); + +const locales = languages.reduce( + (a, c) => ( + (a[c] = + load(clean(readFileSync(`${import.meta.dirname}/${c}.yml`, "utf-8"))) || + {}), + a + ), + {}, +); +const locales_custom = languages_custom.reduce( + (a, c) => ( + (a[c] = + load( + clean( + readFileSync(`${customDir}/locales/${c}.yml`, "utf-8"), + ), + ) || {}), + a + ), + {}, +); +Object.assign(locales, locales_custom); + +export default Object.entries(locales).reduce( + (a, [k, v]) => ( + (a[k] = (() => { + const [lang] = k.split("-"); + switch (k) { + case "en-US": + return v; + default: + return merge( + locales["en-US"], + locales[`${lang}-${primaries[lang]}`] || {}, + v, + ); + } + })()), + a + ), + {}, +); diff --git a/locales/it-IT.yml b/locales/it-IT.yml new file mode 100644 index 0000000..ed53d63 --- /dev/null +++ b/locales/it-IT.yml @@ -0,0 +1,1562 @@ +_lang_: "Italiano" +headlineIceshrimp: "Rete collegata tramite note" +introIceshrimp: "Benvenut@! Iceshrimp è un servizio di microblogging decentralizzato, + libero e aperto. \nScrivi \"note\" per condividere ciò che sta succedendo adesso + o per dire a tutti qualcosa di te. 📡\nGrazie alla funzione \"reazioni\" puoi anche + mandare reazioni rapide alle note delle altre persone del Fediverso. 👍\nEsplora + un nuovo mondo! 🚀" +monthAndDay: "{day}/{month}" +search: "Cerca" +notifications: "Notifiche" +username: "Nome utente" +password: "Password" +forgotPassword: "Hai dimenticato la tua password?" +fetchingAsApObject: "Recuperando dal Fediverso" +ok: "OK" +gotIt: "Ho capito!" +cancel: "Annulla" +enterUsername: "Inserisci un nome utente" +renotedBy: "Rinotato da {user}" +noNotes: "Nessuna nota!" +noNotifications: "Nessuna notifica" +instance: "Istanza" +settings: "Impostazioni" +basicSettings: "Impostazioni generali" +otherSettings: "Altre impostazioni" +openInWindow: "Apri in una finestra" +profile: "Profilo" +timeline: "Timeline" +noAccountDescription: "L'utente non ha ancora scritto niente nella biografia di profilo." +login: "Accedi" +loggingIn: "Accesso in corso..." +logout: "Esci" +signup: "Iscriviti" +uploading: "Caricamento..." +save: "Salva" +users: "Utenti" +addUser: "Aggiungi utente" +favorite: "Aggiungi ai preferiti" +favorites: "Preferiti" +unfavorite: "Rimuovi nota dai preferiti" +favorited: "Aggiunta ai tuoi preferiti." +alreadyFavorited: "Già tra i tuoi preferiti." +cantFavorite: "Impossibile aggiungere la nota ai preferiti." +pin: "Fissa sul profilo" +unpin: "Non fissare sul profilo" +copyContent: "Copia il contenuto" +copyLink: "Copia il link" +delete: "Elimina" +deleteAndEdit: "Elimina e modifica" +deleteAndEditConfirm: "Vuoi davvero cancellare questa nota e scriverla di nuovo? Verrano + eliminate anche tutte le reazioni, Rinote e risposte collegate." +addToList: "Aggiungi alla lista" +sendMessage: "Invia messaggio" +copyUsername: "Copia nome utente" +searchUser: "Cerca utente" +reply: "Rispondi" +loadMore: "Mostra di più" +showMore: "Mostra di più" +showLess: "Chiudi" +youGotNewFollower: "Ha iniziato a seguirti" +receiveFollowRequest: "Hai ricevuto una richiesta di follow" +followRequestAccepted: "Richiesta di follow accettata" +mention: "Menzioni" +mentions: "Menzioni" +directNotes: "Note dirette" +importAndExport: "Importa ed esporta" +import: "Importa" +export: "Esporta" +files: "Allegati" +download: "Scarica" +driveFileDeleteConfirm: "Vuoi davvero eliminare il file「{name}? Anche gli allegati + verranno eliminati." +unfollowConfirm: "Vuoi davvero smettere di seguire {name}?" +exportRequested: "Hai richiesto un'esportazione, e potrebbe volerci tempo. Quando + sarà compiuta, il file verrà aggiunto direttamente al Drive." +importRequested: "Hai richiesto un'importazione. Può volerci tempo. " +lists: "Liste" +noLists: "Nessuna lista" +note: "Nota" +notes: "Note" +following: "Follows" +followers: "Followers" +followsYou: "Ti segue" +createList: "Aggiungi una nuova lista" +manageLists: "Gestisci liste" +error: "Errore" +somethingHappened: "Si è verificato un problema" +retry: "Riprova" +pageLoadError: "Caricamento pagina non riuscito. " +pageLoadErrorDescription: "Questo viene normalmente causato dalla rete o dalla cache + del browser. Si prega di pulire la cache, o di attendere e riprovare più tardi." +serverIsDead: "Il server non risponde. Si prega di attendere e riprovare più tardi." +youShouldUpgradeClient: "Per visualizzare la pagina è necessario aggiornare il client + alla nuova versione e ricaricare." +enterListName: "Nome della lista" +privacy: "Privacy" +makeFollowManuallyApprove: "Richiedi di approvare i follower manualmente" +defaultNoteVisibility: "Privacy predefinita delle note" +follow: "Segui" +followRequest: "Richiesta di follow" +followRequests: "Richieste di follow" +unfollow: "Smetti di seguire" +followRequestPending: "La richiesta di follow deve essere approvata" +enterEmoji: "Inserisci emoji" +renote: "Rinota" +unrenote: "Annulla rinota" +renoted: "Rinotato!" +cantRenote: "È impossibile rinotare questa nota." +cantReRenote: "È impossibile rinotare una Rinota." +quote: "Cita" +pinnedNote: "Nota fissata" +pinned: "Fissa sul profilo" +you: "Tu" +clickToShow: "Clicca per visualizzare" +sensitive: "Contenuto sensibile" +add: "Aggiungi" +reaction: "Reazione" +reactionSetting: "Reazioni visualizzate sul pannello" +reactionSettingDescription2: "Trascina per riorganizzare, clicca per cancellare, usa + il pulsante \"+\" per aggiungere." +rememberNoteVisibility: "Ricordare le impostazioni di visibilità delle note" +attachCancel: "Rimuovi allegato" +markAsSensitive: "Segna come sensibile" +unmarkAsSensitive: "Segna come non sensibile" +enterFileName: "Nome del file" +mute: "Silenzia" +unmute: "Riattiva" +block: "Blocca" +unblock: "Sblocca" +suspend: "Sospendi" +unsuspend: "Annulla la sospensione dell'account" +blockConfirm: "Vuoi davvero bloccare l'account?" +unblockConfirm: "Vuoi davvero sbloccare l'account?" +suspendConfirm: "Vuoi davvero sospendere questo account?" +unsuspendConfirm: "Vuoi annullare la sospensione dell'account?" +selectList: "Seleziona una lista" +selectAntenna: "Scegli un'antenna" +selectWidget: "Seleziona widget" +editWidgets: "Modifica i widget" +editWidgetsExit: "Modifica fine" +customEmojis: "Emoji personalizzati" +emoji: "Emoji" +emojis: "Emoji" +emojiName: "Nome dell'emoji" +emojiUrl: "URL dell'emoji" +addEmoji: "Aggiungi un emoji" +settingGuide: "Configurazione suggerita" +cacheRemoteFiles: "Memorizzazione nella cache dei file remoti" +cacheRemoteFilesDescription: "Disabilitando questa opzione, i file remoti verranno + linkati direttamente senza essere memorizzati nella cache. Sarà possibile risparmiare + spazio di archiviazione sul server, ma il traffico aumenterà in quanto non verranno + generate anteprime." +flagAsBot: "Io sono un robot" +flagAsBotDescription: "Se l'account esegue principalmente operazioni automatiche, + attiva quest'opzione. Quando attivata, opera come un segnalatore per gli altri sviluppatori + allo scopo di prevenire catene d’interazione senza fine con altri bot, e di adeguare + i sistemi interni di Iceshrimp perché trattino questo account come un bot." +flagAsCat: "Io sono un gatto" +flagAsCatDescription: "Abilita l'opzione \"Io sono un gatto\" per l'account." +autoAcceptFollowed: "Accetta automaticamente le richieste di follow da utenti che + già segui" +addAccount: "Aggiungi account" +loginFailed: "Accesso non riuscito" +showOnRemote: "Sfoglia sull'istanza remota" +general: "Generali" +wallpaper: "Sfondo" +setWallpaper: "Imposta sfondo" +removeWallpaper: "Elimina lo sfondo" +searchWith: "Cerca: {q}" +youHaveNoLists: "Non hai ancora creato nessuna lista" +followConfirm: "Sei sicur@ di voler seguire {name}?" +proxyAccount: "Account proxy" +proxyAccountDescription: "Un account proxy è un account che funziona da follower remoto + per gli utenti sotto certe condizioni. Ad esempio, quando un utente aggiunge un + utente remoto alla lista, dato che se nessun utente locale segue quell'utente le + sue attività non verranno distribuite, al suo posto lo seguirà un account proxy." +host: "Server remoto" +selectUser: "Seleziona utente" +recipient: "Destinatario" +annotation: "Descrizione" +federation: "Federazione" +instances: "Istanza" +registeredAt: "Registrato presso" +latestRequestSentAt: "Ultima richiesta inviata" +latestRequestReceivedAt: "Ultima richiesta ricevuta" +latestStatus: "Ultimo stato" +storageUsage: "Volume di dischi" +charts: "Grafici" +perHour: "All'ora" +perDay: "al giorno" +stopActivityDelivery: "Interrompi la distribuzione di attività" +blockThisInstance: "Blocca l'istanza" +operations: "Operazioni" +software: "Software" +version: "Versione" +metadata: "Metadato" +monitor: "Monitorare" +jobQueue: "Coda di lavoro" +cpuAndMemory: "CPU e Memoria" +network: "Rete" +disk: "Disco" +instanceInfo: "Informazioni sull'istanza" +statistics: "Statistiche" +clearQueue: "Svuota coda" +clearQueueConfirmTitle: "Vuoi davvero svuotare la coda?" +clearQueueConfirmText: "Le note ancora non distribuite non verranno rilasciate. Solitamente, + non è necessario eseguire questa operazione." +clearCachedFiles: "Svuota cache" +clearCachedFilesConfirm: "Vuoi davvero svuotare la cache da tutti i file remoti?" +blockedInstances: "Istanze bloccate" +blockedInstancesDescription: "Elenca le istanze che vuoi bloccare, una per riga. Esse + non potranno più interagire con la tua istanza." +muteAndBlock: "Silenziati / Bloccati" +mutedUsers: "Account silenziati" +blockedUsers: "Account bloccati" +noUsers: "Nessun utente trovato" +editProfile: "Modifica profilo" +noteDeleteConfirm: "Eliminare questo Nota?" +pinLimitExceeded: "Non puoi fissare altre note " +intro: "L'installazione di Iceshrimp è finita! Si prega di creare un account amministratore." +done: "Fine" +processing: "In elaborazione" +preview: "Anteprima" +default: "Predefinito" +noCustomEmojis: "Nessun emoji" +noJobs: "Nessun lavoro" +federating: "Federando" +blocked: "Bloccato" +suspended: "Sospes@" +all: "Tutti" +subscribing: "Iscrivendo" +publishing: "Pubblicando" +notResponding: "Nessuna risposta" +instanceFollowing: "Seguiti dall'istanza" +instanceFollowers: "Followers dell'istanza" +instanceUsers: "Utenti dell'istanza" +changePassword: "Aggiorna Password" +security: "Sicurezza" +retypedNotMatch: "Le password non corrispondono." +currentPassword: "Password attuale" +newPassword: "Nuova Password" +newPasswordRetype: "Conferma password" +attachFile: "Allega file" +more: "Altri!" +featured: "Tendenze" +usernameOrUserId: "Nome utente o ID utente" +noSuchUser: "Nessun utente trovato" +lookup: "Cercare" +announcements: "Annunci" +imageUrl: "URL dell'immagine" +remove: "Elimina" +removed: "Il tuo Tweet è stato eliminato" +removeAreYouSure: "Eliminare \"{x}\"?" +deleteAreYouSure: "Eliminare \"{x}\"?" +resetAreYouSure: "Reimposta" +saved: "Salvato" +messaging: "Messaggi" +upload: "Carica" +fromDrive: "Dal Drive" +fromUrl: "Dall'URL" +uploadFromUrl: "Incolla URL immagine" +uploadFromUrlDescription: "URL del file che vuoi caricare" +uploadFromUrlRequested: "Caricamento richiesto" +uploadFromUrlMayTakeTime: "Il caricamento del file può richiedere tempo." +explore: "Esplora" +messageRead: "Visualizzato" +noMoreHistory: "Non c'è più cronologia da visualizzare" +startMessaging: "Nuovo messaggio" +nUsersRead: "Letto da {n} persone" +agreeTo: "Sono d'accordo con {0}" +tos: "Termini di servizio" +start: "Inizia!" +home: "Home" +remoteUserCaution: "Può darsi che le informazioni siano incomplete perché questo è + un utente remoto." +activity: "Attività" +images: "Immagini" +birthday: "Compleanno" +yearsOld: "{age}Anni" +registeredDate: "Iscrizione a.." +location: "Posizione" +theme: "Tema" +themeForLightMode: "Tema da utilizzare per il modo chiaro" +themeForDarkMode: "Tema da utilizzare per il modo scuro" +light: "Chiaro" +dark: "Scuro" +lightThemes: "Tema Chiaro" +darkThemes: "Tema Scuro" +syncDeviceDarkMode: "Sincronizza il tema scuro con le impostazioni del dispositivo" +drive: "Drive" +fileName: "Nome dell'allegato" +selectFile: "Scelta allegato" +selectFiles: "Scelta allegato" +selectFolder: "Seleziona cartella" +selectFolders: "Seleziona cartella" +renameFile: "Rinomina file" +folderName: "Nome della cartella" +createFolder: "Nuova cartella" +renameFolder: "Rinominare cartella" +deleteFolder: "Elimina cartella" +addFile: "Allega" +emptyDrive: "Il Drive è vuoto" +emptyFolder: "La cartella è vuota" +unableToDelete: "Eliminazione impossibile" +inputNewFileName: "Inserisci nome del nuovo file" +inputNewDescription: "Inserisci una nuova descrizione" +inputNewFolderName: "Inserisci nome della nuova cartella" +circularReferenceFolder: "La cartella di destinazione è una sottocartella della cartella + che vuoi spostare." +hasChildFilesOrFolders: "Impossibile eliminare la cartella perché non è vuota" +copyUrl: "Copia URL" +rename: "Modifica nome" +avatar: "Foto del profilo" +banner: "Intestazione" +nsfw: "Contenuti sensibili" +whenServerDisconnected: "Quando la connessione col server è persa" +disconnectedFromServer: "Disconness@ dal server" +reload: "Ricarica" +doNothing: "Nessun'azione" +reloadConfirm: "Vuoi ricaricare?" +watch: "Osserva" +unwatch: "Smetti di Osserva" +accept: "Accetta" +reject: "Rifiuta" +normal: "Normale" +instanceName: "Nome dell'istanza" +instanceDescription: "Descrizione dell'istanza" +maintainerName: "Nome dell'Amministratore" +maintainerEmail: "Indirizzo e-mail dell'Amministratore" +tosUrl: "Termini di servizio URL" +thisYear: "Anno" +thisMonth: "Mese" +today: "Oggi" +dayX: "{day}" +monthX: "{month}" +yearX: "{year}" +pages: "Pagine" +integration: "App collegate" +connectService: "Connessione" +disconnectService: "Disconnessione " +enableLocalTimeline: "Abilita Timeline locale" +enableGlobalTimeline: "Abilita Timeline federata" +disablingTimelinesInfo: "Anche se disabiliti queste timeline, gli amministratori e + i moderatori potranno sempre accederci." +registration: "Iscriviti" +enableRegistration: "Permettere nuove registrazioni" +invite: "Invita" +driveCapacityPerLocalAccount: "Volume del Drive per utente locale" +driveCapacityPerRemoteAccount: "Volume del Drive per utente remoto" +inMb: "in Megabytes" +iconUrl: "URL di icona (favicon, ecc.)" +bannerUrl: "URL dell'immagine d'intestazione" +backgroundImageUrl: "URL dello sfondo" +basicInfo: "Informazioni fondamentali" +pinnedUsers: "Utenti in evidenza" +pinnedUsersDescription: "Elenca gli/le utenti che vuoi fissare in cima alla pagina + \"Esplora\", un@ per riga." +pinnedPages: "Pagine in evidenza" +pinnedPagesDescription: "Specifica il percorso delle pagine che vuoi fissare in cima + alla pagina dell'istanza. Una pagina per riga." +pinnedClipId: "ID della clip in evidenza" +pinnedNotes: "Nota fissata" +hcaptcha: "hCaptcha" +enableHcaptcha: "Abilita hCaptcha" +hcaptchaSiteKey: "Chiave del sito" +hcaptchaSecretKey: "Chiave segreta" +recaptcha: "reCAPTCHA" +enableRecaptcha: "Abilita reCAPTCHA" +recaptchaSiteKey: "Chiave del sito" +recaptchaSecretKey: "Chiave segreta" +avoidMultiCaptchaConfirm: "Utilizzare diversi Captcha può causare interferenze. Vuoi + disattivare l'altro Captcha? Puoi lasciare diversi Captcha attivi premendo \"Cancella\"\ + ." +antennas: "Antenne" +manageAntennas: "Gestore delle antenne" +name: "Nome" +antennaSource: "Fonte dell'antenna" +antennaKeywords: "Parole chiavi da ricevere" +antennaExcludeKeywords: "Parole chiavi da escludere" +antennaKeywordsDescription: "Separare con uno spazio indica la condizione \"E\". Separare + con un'interruzzione riga indica la condizione \"O\"." +notifyAntenna: "Invia notifiche delle nuove note" +withFileAntenna: "Solo note con file in allegato" +enableServiceworker: "Abilita ServiceWorker" +antennaUsersDescription: "Inserisci solo un nome utente per riga" +caseSensitive: "Sensibile alla distinzione tra maiuscole e minuscole" +withReplies: "Includere le risposte" +connectedTo: "Sei conness@ agli account qui sotto:" +notesAndReplies: "Note e risposte" +withFiles: "Con file in allegato" +silence: "Silenzia" +silenceConfirm: "Vuoi davvero silenziare l'utente?" +unsilence: "Riattiva" +unsilenceConfirm: "Vuoi davvero riattivare l'utente?" +popularUsers: "Utenti popolari" +recentlyUpdatedUsers: "Utenti attivi di recente" +recentlyRegisteredUsers: "Utenti registrati di recente" +recentlyDiscoveredUsers: "Utenti scoperti di recente" +exploreUsersCount: "Ci sono {count} utenti" +exploreFediverse: "Esplora il Fediverso" +popularTags: "Tag di tendenza" +userList: "Liste" +about: "Informazioni" +aboutIceshrimp: "Informazioni di Iceshrimp" +administrator: "Amministratore" +token: "Token" +twoStepAuthentication: "Autenticazione a due fattori" +moderator: "Moderatore" +nUsersMentioned: "{n} utenti menzionatə" +securityKey: "Chiave di sicurezza" +securityKeyName: "Nome della chiave" +registerSecurityKey: "Registra una chiave di sicurezza" +lastUsed: "Ultima attività" +unregister: "Annulla l'iscrizione" +passwordLessLogin: "Accedi senza password" +resetPassword: "Reimposta password" +newPasswordIs: "La tua nuova password è「{password}」" +reduceUiAnimation: "Ridurre le animazioni dell'interfaccia" +share: "Condividi" +notFound: "Non trovato" +notFoundDescription: "Nessuna pagina corrisponde all'URL indicata." +uploadFolder: "Destinazione caricamento predefinita" +cacheClear: "Svuota cache" +markAsReadAllNotifications: "Segna tutte le notifiche come lette" +markAsReadAllUnreadNotes: "Segna tutte le note come lette" +markAsReadAllTalkMessages: "Segna tutte le chat come lette" +help: "Guida" +inputMessageHere: "Scrivi messaggio qui" +close: "Chiudi" +group: "Gruppo" +groups: "Gruppi" +createGroup: "Nuovo gruppo" +ownedGroups: "I miei gruppi" +joinedGroups: "Gruppi a cui mi sono unit@" +invites: "Inviti" +groupName: "Nome del gruppo" +members: "Membri" +transfer: "Trasferisci" +messagingWithUser: "Iniziare una chat con un altr@ utente" +messagingWithGroup: "Chattare in gruppo" +title: "Titolo" +text: "Testo" +enable: "Abilita" +next: "Avanti" +retype: "Conferma" +noteOf: "Note di {user}" +inviteToGroup: "Invitare al gruppo" +quoteAttached: "Citazione allegata" +quoteQuestion: "Vuoi aggiungere una citazione?" +noMessagesYet: "Ancora nessuna chat" +newMessageExists: "Hai ricevuto un nuovo messaggio" +onlyOneFileCanBeAttached: "È possibile allegare al messaggio soltanto uno file" +signinRequired: "Devi essere registrat@ nel tuo account" +invitations: "Invita" +invitationCode: "Codice di invito" +checking: "Confermando" +available: "Consigliati" +unavailable: "Il nome utente è già in uso" +usernameInvalidFormat: "Il nome utente può contenere solo lettere, numeri e '_'" +tooShort: "Troppo breve" +tooLong: "Troppo lungo" +weakPassword: "Password debole" +normalPassword: "Password buona" +strongPassword: "Password forte" +passwordMatched: "Corretta" +passwordNotMatched: "Le password non corrispondono." +signinWith: "Accedi con {x}" +signinFailed: "Autenticazione non riuscita. Controlla la tua password e nome utente." +tapSecurityKey: "Premi la chiave di sicurezza" +or: "oppure" +language: "Lingua" +uiLanguage: "Lingua di visualizzazione dell'interfaccia" +groupInvited: "Invitat@ al gruppo" +aboutX: "Informazioni su {x}" +useOsNativeEmojis: "Usare le emoji native del sistema operativo" +disableDrawer: "Non mostrare il menù sul drawer" +youHaveNoGroups: "Nessun gruppo" +joinOrCreateGroup: "Puoi creare il tuo gruppo o essere invitat@ a gruppi che già esistono." +noHistory: "Nessuna cronologia" +signinHistory: "Cronologia di accesso all'account" +disableAnimatedMfm: "Disabilità i MFM animati" +doing: "In corso..." +category: "Categoria" +tags: "Tag" +docSource: "Sorgente della scheda" +createAccount: "Crea il tuo account" +existingAccount: "Account esistente" +regenerate: "Generare di nuovo" +fontSize: "Dimensione carattere" +noFollowRequests: "Non hai alcuna richiesta di follow" +openImageInNewTab: "Aprire immagini in una nuova scheda" +dashboard: "Pannello di controllo" +local: "Locale" +remote: "Remoto" +total: "Totale" +weekOverWeekChanges: "Settimanale" +dayOverDayChanges: "Giornaliero" +appearance: "Aspetto" +clientSettings: "Impostazioni client" +accountSettings: "Impostazioni account" +promotion: "Promossa" +promote: "Pubblicizza" +numberOfDays: "Numero di giorni" +hideThisNote: "Nasconda la nota" +showFeaturedNotesInTimeline: "Mostrare le note di tendenza nella tua timeline" +objectStorage: "Stoccaggio oggetti" +useObjectStorage: "Utilizza stoccaggio oggetti" +objectStorageBaseUrl: "Base URL" +objectStorageBaseUrlDesc: "URL di riferimento. In caso di utilizzo di proxy o CDN + l'URL è 'https://.s3.amazonaws.com' per S3, 'https://storage.googleapis.com/' + per GCS eccetera. " +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "Specificare il nome del bucket utilizzato dal provider." +objectStoragePrefix: "Prefix" +objectStoragePrefixDesc: "I file saranno conservati sotto la directory di questo prefisso." +objectStorageEndpoint: "Endpoint" +objectStorageEndpointDesc: "Lasciare vuoto se si sta utilizzando S3. In caso contrario + si prega di specificare l'endpoint come '' oppure ':' a seconda + del servizio utilizzato." +objectStorageRegion: "Region" +objectStorageRegionDesc: "Specificate una regione, quale 'xx-east-1'. Se il servizio + in utilizzo non distingue tra regioni, lasciate vuoto o inserite 'us-east-1'." +objectStorageUseSSL: "Usare SSL" +objectStorageUseSSLDesc: "Disabilita quest'opzione se non utilizzi HTTPS per le connessioni + API." +objectStorageUseProxy: "Usa proxy" +objectStorageUseProxyDesc: "Disabilita quest'opzione se non usi proxy per la connessione + API." +objectStorageSetPublicRead: "Imposta \"visibilità pubblica\" al momento di caricare" +serverLogs: "Log del server" +deleteAll: "Cancella cronologia" +showFixedPostForm: "Visualizzare la finestra di pubblicazione in cima alla timeline" +newNoteRecived: "Vedi le nuove note" +sounds: "Impostazioni suoni" +listen: "Ascolta" +none: "Niente" +showInPage: "Visualizza in pagina" +popout: "Finestra pop-out" +volume: "Volume" +masterVolume: "Volume principale" +details: "Dettagli" +chooseEmoji: "Scegli emoji" +unableToProcess: "Impossibile compiere l'operazione" +recentUsed: "Usato di recente" +install: "Installa" +uninstall: "Disinstalla" +installedApps: "Applicazioni installate" +nothing: "Niente da visualizzare" +installedDate: "Data installazione" +lastUsedDate: "Data di ultimo uso" +state: "Stato" +sort: "Ordina per" +ascendingOrder: "Ascendente" +descendingOrder: "Discendente" +scratchpad: "ScratchPad" +scratchpadDescription: "Lo Scratchpad offre un ambiente per esperimenti di AiScript. + È possibile scrivere, eseguire e confermare i risultati dell'interazione del codice + con Iceshrimp." +output: "Uscita" +script: "Script" +disablePagesScript: "Disabilita AiScript nelle pagine" +updateRemoteUser: "Aggiornare le informazioni di utente remot@" +deleteAllFiles: "Elimina tutti i file" +deleteAllFilesConfirm: "Vuoi davvero eliminare tutti i file?" +removeAllFollowing: "Cancella tutti i follows" +removeAllFollowingDescription: "Cancella tutti i follows del server {host}. Per favore, + esegui se, ad esempio, l'istanza non esiste più." +userSuspended: "L'utente è sospes@." +userSilenced: "L'utente è silenziat@." +yourAccountSuspendedTitle: "Questo account è sospeso." +yourAccountSuspendedDescription: "Questo account è stato sospeso a causa di una violazione + dei termini di servizio del server. Contattare l'amministrazione per i dettagli. + Si prega di non creare un nuovo account." +menu: "Menù" +divider: "Linea di separazione" +addItem: "Aggiungi elemento" +relays: "Ripetitori" +addRelay: "Aggiungi ripetitore" +inboxUrl: "Inbox URL" +addedRelays: "Ripetitori configurati" +serviceworkerInfo: "Deve essere abilitato per le notifiche push. " +deletedNote: "Nota eliminata" +invisibleNote: "Nota invisibile" +enableInfiniteScroll: "Abilita scorrimento infinito" +visibility: "Visibilità" +poll: "Sondaggio" +useCw: "Nascondere media" +enablePlayer: "Apri in lettore video" +disablePlayer: "Chiudi lettore video" +expandTweet: "Espandi tweet" +themeEditor: "Editor di temi" +description: "Descrizione" +describeFile: "Aggiungi una descrizione d'immagine" +enterFileDescription: "Inserisci descrizione" +author: "Autore" +leaveConfirm: "Ci sono delle modifiche ancora non salvate. Vuoi cancellarle?" +manage: "Gestione" +plugins: "Estensioni" +deck: "Deck" +undeck: "Esci dal deck" +useBlurEffectForModal: "Utilizza effetto sfocatura per i modali" +useFullReactionPicker: "Usa la totalità del pannello di reazioni" +width: "Larghezza" +height: "Altezza" +large: "Grande" +medium: "Predefinito" +small: "Piccolo" +generateAccessToken: "Genera token di accesso" +permission: "Autorizzazioni " +enableAll: "Abilita tutto" +disableAll: "Disabilita tutto" +tokenRequested: "Autorizza accesso all'account" +pluginTokenRequestedDescription: "Il plugin potrà utilizzare le autorizzazioni impostate + qui." +notificationType: "Tipo di notifiche" +edit: "Modifica" +emailServer: "Server email" +enableEmail: "Abilita consegna email" +emailConfigInfo: "Utilizzato per verificare il tuo indirizzo di posta elettronica + e per reimpostare la tua password" +email: "Email" +emailAddress: "Indirizzo di posta elettronica" +smtpConfig: "Impostazioni del server SMTP" +smtpHost: "Server remoto" +smtpPort: "Porta" +smtpUser: "Nome utente" +smtpPass: "Password" +emptyToDisableSmtpAuth: "Lasciare il nome utente e la password vuoti per disabilitare + la verifica SMTP" +smtpSecure: "Usare la porta SSL/TLS implicito per le connessioni SMTP" +smtpSecureInfo: "Disabilitare quando è attivo STARTTLS." +testEmail: "Testare la consegna di posta elettronica" +wordMute: "Filtri parole" +instanceMute: "Silenzia l'istanza" +userSaysSomething: "{name} ha detto qualcosa" +makeActive: "Attiva" +display: "Visualizza" +copy: "Copia" +metrics: "Statistiche" +overview: "Anteprima" +logs: "Log" +delayed: "Ritardo" +database: "Base di dati" +channel: "Canale" +create: "Crea" +notificationSetting: "Impostazioni notifiche" +notificationSettingDesc: "Seleziona il tipo di notifiche da visualizzare." +useGlobalSetting: "Usa impostazioni generali" +useGlobalSettingDesc: "Se abilitato, le impostazioni notifiche dell'account verranno + utilizzate. Se disabilitato, si possono definire diverse singole impostazioni." +other: "Avanzate" +regenerateLoginToken: "Genera di nuovo un token di connessione" +regenerateLoginTokenDescription: "Genera un nuovo token di autenticazione. Solitamente + questa operazione non è necessaria: quando si genera un nuovo token, tutti i dispositivi + vanno disconnessi." +setMultipleBySeparatingWithSpace: "È possibile creare multiple voci separate da spazi." +fileIdOrUrl: "ID o URL del file" +behavior: "Comportamento" +sample: "Esempio" +abuseReports: "Segnalazioni" +reportAbuse: "Segnalazioni" +reportAbuseOf: "Segnala {name}" +fillAbuseReportDescription: "Si prega di spiegare il motivo della segnalazione. Se + riguarda una nota precisa, si prega di collegare anche l'URL della nota." +abuseReported: "La segnalazione è stata inviata. Grazie." +reporter: "il corrispondente" +reporteeOrigin: "Origine del segnalato" +reporterOrigin: "Origine del segnalatore" +send: "Inviare" +abuseMarkAsResolved: "Contrassegna la segnalazione come risolta" +openInNewTab: "Apri in una nuova scheda" +openInSideView: "Apri in vista laterale" +defaultNavigationBehaviour: "Navigazione preimpostata" +editTheseSettingsMayBreakAccount: "Modificare queste impostazioni può danneggiare + l'account." +instanceTicker: "Informazioni sull'istanza da cui vengono le note" +waitingFor: "Aspettando {x}" +random: "Casuale" +system: "Sistema" +switchUi: "Cambiare interfaccia utente" +desktop: "Desktop" +clip: "Clip" +createNew: "Crea nuov@" +optional: "Opzionale" +createNewClip: "Nuova clip" +public: "Pubblica" +i18nInfo: "Iceshrimp è tradotto in diverse lingue da volontari. Anche tu puoi contribuire + su {link}." +manageAccessTokens: "Gestisci token di accesso" +accountInfo: "Informazioni account" +notesCount: "Conteggio note" +repliesCount: "Numero di risposte inviate" +renotesCount: "Numero di note che hai ricondiviso" +repliedCount: "Numero di risposte ricevute" +renotedCount: "Numero delle tue note ricondivise" +followingCount: "Numero di account seguiti" +followersCount: "Numero di account che ti seguono" +sentReactionsCount: "Numero di reazioni inviate" +receivedReactionsCount: "Numero di reazioni ricevute" +pollVotesCount: "Numero di voti inviati" +pollVotedCount: "Numero di voti ricevuti" +yes: "Sì" +no: "No" +driveFilesCount: "Numero di file nel Drive" +driveUsage: "Utilizzazione del Drive" +noCrawle: "Rifiuta l'indicizzazione dai robot." +noCrawleDescription: "Richiedi che i motori di ricerca non indicizzino la tua pagina + di profilo, le tue note, pagine, ecc." +lockedAccountInfo: "A meno che non imposti la visibilità delle tue note su \"Solo + ai follower\", le tue note sono visibili da tutti, anche se hai configurato l'account + per confermare manualmente le richieste di follow." +alwaysMarkSensitive: "Segnare i media come sensibili per impostazione predefinita" +loadRawImages: "Visualizza le intere immagini allegate invece delle miniature." +disableShowingAnimatedImages: "Disabilita le immagini animate" +verificationEmailSent: "Una mail di verifica è stata inviata. Si prega di accedere + al collegamento per compiere la verifica." +notSet: "Non impostato" +emailVerified: "Il tuo indirizzo email è stato verificato" +noteFavoritesCount: "Conteggio note tra i preferiti" +pageLikesCount: "Numero di pagine che ti piacciono" +pageLikedCount: "Numero delle tue pagine che hanno ricevuto \"Mi piace\"" +contact: "Contatti" +useSystemFont: "Usa il carattere predefinito del sistema" +clips: "Clip" +experimentalFeatures: "Funzioni sperimentali" +developer: "Sviluppatore" +makeExplorable: "Account visibile sulla pagina \"Esplora\"" +makeExplorableDescription: "Se disabiliti l'opzione, il tuo account non verrà visualizzato + sulla pagina \"Esplora\"." +showGapBetweenNotesInTimeline: "Mostrare un intervallo tra le note sulla timeline" +duplicate: "Duplica" +left: "Sinistra" +center: "Centro" +wide: "Largo" +reloadToApplySetting: "Le tue preferenze verranno impostate dopo il ricaricamento + della pagina. Vuoi ricaricare adesso?" +needReloadToApply: "È necessario riavviare per rendere effettive le modifiche." +showTitlebar: "Visualizza la barra del titolo" +clearCache: "Svuota cache" +onlineUsersCount: "{n} utenti online" +nUsers: "{n} utenti" +nNotes: "{n}Note" +sendErrorReports: "Invia segnalazioni di errori" +sendErrorReportsDescription: "Quando abilitato, se si verifica un problema, informazioni + dettagliate sugli errori verranno condivise con Iceshrimp in modo da aiutare a migliorare + la qualità del software.\nCiò include informazioni come la versione del sistema + operativo, il tipo di navigatore web che usi, la cronologia delle attività, ecc." +myTheme: "I miei temi" +backgroundColor: "Sfondo" +textColor: "Testo" +saveAs: "Salva con nome" +value: "Valore" +createdAt: "Data di creazione" +updatedAt: "Aggiornato il" +saveConfirm: "Vuoi salvare le modifiche?" +deleteConfirm: "Rimuovere?" +invalidValue: "Questo non è un valore valido." +registry: "Registro" +closeAccount: "Disattiva account" +currentVersion: "Versione attuale" +latestVersion: "Ultima versione" +youAreRunningUpToDateClient: "Stai usando la versione più recente del client." +newVersionOfClientAvailable: "Una nuova versione del tuo client è disponibile." +usageAmount: "In utilizzo" +capacity: "Capacità" +inUse: "In utilizzo" +editCode: "Modifica codice" +apply: "Applica" +receiveAnnouncementFromInstance: "Ricevi i messaggi informativi dall'istanza" +emailNotification: "Eventi per notifiche via mail" +publish: "Pubblico" +inChannelSearch: "Cerca in canale" +useReactionPickerForContextMenu: "Cliccare sul tasto destro per aprire il pannello + di reazioni" +typingUsers: "{users} sta(nno) scrivendo" +jumpToSpecifiedDate: "Vai alla data " +showingPastTimeline: "Stai visualizzando una vecchia timeline" +clear: "Cancella" +markAllAsRead: "Segna tutti come già letti" +goBack: "Indietro" +unlikeConfirm: "Non ti piace più?" +fullView: "Schermo intero" +quitFullView: "Esci dalla modalità a schermo intero" +addDescription: "Aggiungi descrizione" +userPagePinTip: "Qui puoi appuntare note, premendo \"Fissa sul profilo\" nel menù + delle singole note." +notSpecifiedMentionWarning: "Sono menzionati account che non vengono inclusi fra i + destinatari" +info: "Informazioni" +userInfo: "Informazioni utente" +unknown: "Sconosciuto" +onlineStatus: "Stato di connessione" +hideOnlineStatus: "Stato invisibile" +hideOnlineStatusDescription: "Abilitare l'opzione di stato invisibile può guastare + la praticità di singole funzioni, come la ricerca." +online: "Online" +active: "Attiv@" +offline: "Offline" +notRecommended: "Sconsigliato" +botProtection: "Protezione contro i bot" +instanceBlocking: "Istanze bloccate" +selectAccount: "Scegli account" +enabled: "Attivo" +disabled: "Inattivo" +quickAction: "Azioni rapide" +user: "Utente" +administration: "Gestione" +accounts: "Account" +switch: "Sostituisci" +noMaintainerInformationWarning: "Le informazioni amministratore non sono impostate." +noBotProtectionWarning: "Nessuna protezione impostata contro i bot." +configure: "Imposta" +postToGallery: "Pubblicare nella galleria" +gallery: "Galleria" +recentPosts: "Le più recenti" +popularPosts: "Le più visualizzate" +shareWithNote: "Condividere in nota" +ads: "Pubblicità" +expiration: "Scadenza" +memo: "Promemoria" +priority: "Priorità" +high: "Alta" +middle: "Media" +low: "Bassa" +emailNotConfiguredWarning: "Non hai impostato nessun indirizzo e-mail." +ratio: "Rapporto" +previewNoteText: "Anteprima del testo" +customCss: "CSS personalizzato" +global: "Federata" +squareAvatars: "Mostra l'immagine del profilo come quadrato" +sent: "Inviare" +received: "Ricevuto" +searchResult: "Risultati della Ricerca" +hashtags: "Hashtag" +troubleshooting: "Risoluzione problemi" +useBlurEffect: "Utilizza effetto sfocatura per l'interfaccia utente" +learnMore: "Più dettagli" +iceshrimpUpdated: "Iceshrimp è stato aggiornato!" +whatIsNew: "Visualizza le informazioni sull'aggiornamento" +translate: "Traduzione" +translatedFrom: "Tradotto da {x}" +accountDeletionInProgress: "La cancellazione dell'account è in corso" +usernameInfo: "Un nome per identificare univocamente il tuo account sul server. È + possibile utilizzare caratteri alfanumerici (a~z, A~Z, 0~9) e il trattino basso + (_). Non sarà possibile cambiare il nome utente in seguito." +aiChanMode: "Modalità Ai" +keepCw: "Mantieni il CW" +resolved: "Risolto" +unresolved: "Non risolto" +breakFollow: "Smetti di seguire" +itsOn: "Abilitato" +itsOff: "Disabilitato" +emailRequiredForSignup: "È necessario un indirizzo mail per registrare un account" +unread: "Non letto" +filter: "Filtri" +controlPanel: "Pannello di controllo" +manageAccounts: "Gestisci account" +classic: "Classico" +muteThread: "Silenzia la discussione" +unmuteThread: "Riattiva la discussione" +deleteAccountConfirm: "L'account verrà cancellato. Procedere?" +incorrectPassword: "La password è errata." +voteConfirm: "Votare per「{choice}」?" +hide: "Nascondere" +leaveGroup: "Esci dal gruppo" +leaveGroupConfirm: "Uscire da「{name}」?" +useDrawerReactionPickerForMobile: "Mostra sul drawer da dispositivo mobile" +clickToFinishEmailVerification: "Fai click su [{ok}] per completare la verifica dell'indirizzo + email." +searchByGoogle: "Cerca" +indefinitely: "Non scade" +tenMinutes: "10 minuti" +oneHour: "1 ora" +oneDay: "1 giorno" +oneWeek: "1 settimana" +file: "Allegati" +reverse: "Inverti" +colored: "Colorato" +label: "Etichetta" +localOnly: "Soltanto locale" +account: "Account" +_emailUnavailable: + used: "Email già in uso" + format: "Formato email non valido" + disposable: "Email non riutilizzabile" + mx: "Server email non corretto" + smtp: "Il server email non risponde" +_ffVisibility: + public: "Pubblico" + followers: "Mostra solo ai follower" + private: "Invisibile" +_signup: + almostThere: "Quasi completo" + emailAddressInfo: "Inserisci il tuo indirizzo email. Non verrà reso pubblico." +_accountDelete: + accountDelete: "Cancellazione account" + sendEmail: "Al termine della cancellazione dell'account, verrà inviata una mail + all'indirizzo a cui era registrato." + requestAccountDelete: "Richiesta di cancellazione account" + started: "Il processo di cancellazione è iniziato." + inProgress: "Cancellazione in corso" +_ad: + back: "Indietro" + reduceFrequencyOfThisAd: "Visualizza questa pubblicità meno spesso" +_forgotPassword: + enterEmail: "Inserisci l'indirizzo di posta elettronica che hai registrato nel tuo + profilo. Il collegamento necessario per ripristinare la password verrà inviato + a questo indirizzo." + ifNoEmail: "Se nessun indirizzo e-mail è stato registrato, si prega di contattare + l'amministratore·trice dell'istanza." + contactAdmin: "Poiché questa istanza non permette l'utilizzo di una mail, si prega + di contattare l'amministratore·trice dell'istanza per poter ripristinare la password." +_gallery: + my: "Le mie pubblicazioni" + liked: "Pubblicazioni che mi piacciono" + like: "Mi piace!" + unlike: "Non mi piace più" +_email: + _follow: + title: "Ha iniziato a seguirti" + _receiveFollowRequest: + title: "Hai ricevuto una richiesta di follow" +_plugin: + install: "Installa estensioni" + installWarn: "Si prega di installare soltanto estensioni che provengono da fonti + affidabili." + manage: "Gestisci estensioni" +_registry: + key: "Dati" + keys: "Dati" + domain: "Dominio" + createKey: "Crea chiave" +_aboutIceshrimp: + about: "Iceshrimp è un software libero e open source, sviluppato da syuilo dal 2014." + contributors: "Principali sostenitori" + allContributors: "Tutti i sostenitori" + source: "Codice sorgente" + translation: "Tradurre Iceshrimp" + donate: "Sostieni Iceshrimp" + morePatrons: "Apprezziamo sinceramente il supporto di tante altre persone. Grazie + mille! 🥰" + patrons: "Sostenitori" +_nsfw: + respect: "Nascondere i media segnati come sensibli" + ignore: "Visualizzare i media segnati come sensibili" + force: "Nascondere tutti i media" +_mfm: + cheatSheet: "Bigliettino MFM" + intro: "MFM è un linguaggio Markdown particolare che si può usare in diverse parti + di Iceshrimp. Qui puoi visualizzare a colpo d'occhio tutta la sintassi MFM utile." + dummy: "Il Fediverso si espande con Iceshrimp" + mention: "Menzioni" + mentionDescription: "Si può menzionare un utente specifico digitando il suo nome + utente subito dopo il segno @." + hashtag: "Hashtag" + url: "URL" + link: "Link" + bold: "Grassetto" + blockCode: "Codice (blocco)" + inlineMath: "Espressione matematica(Immersione)" + blockMath: "Formula matematica (blocco)" + quote: "Cita il nota" + emoji: "Emoji personalizzati" + search: "Cerca" + flip: "Inverti" + jump: "Animazione(salto)" + jumpDescription: "Da un animazione che salta su e giù." + bounce: "Animazione(rimbalzo)" + bounceDescription: "Rende il testo rimbalzante" + shake: "rimbalzante" + shakeDescription: "Rende il testo traballante" + twitch: "testo" + twitchDescription: "Fa tremare il testo" + x2: "Più grande" + x2Description: "Mostra il contenuto ingrandito." + x3: "Molto più grande" + x3Description: "Mostra il contenuto molto più ingrandito." + x4: "Estremamente più grande" + x4Description: "Mostra il contenuto estremamente più ingrandito." + blur: "Sfocatura" + blurDescription: "È possibile rendere sfocato il contenuto. Spostando il cursore + su di esso tornerà visibile chiaramente." + font: "Tipo di carattere" + fontDescription: "Puoi scegliere il tipo di carattere per il contenuto." + rainbow: "Arcobaleno" + rotate: "Ruota" + fade: "Dissolvenza" + fadeDescription: "Dissolvenza in entrata e in uscita del contenuto." +_instanceTicker: + none: "Nascondi" + remote: "Mostra solo per gli/le utenti remotə" + always: "Mostra sempre" +_serverDisconnectedBehavior: + reload: "Ricarica automaticamente" + dialog: "Apri avviso in finestra" + quiet: "Visualizza avviso in modo discreto" +_channel: + create: "Nuovo canale" + edit: "Gerisci canale" + setBanner: "Scegli intestazione" + removeBanner: "Rimuovi intestazione" + featured: "Tendenze" + owned: "I miei canali" + following: "Seguiti" + usersCount: "{n} partecipanti" + notesCount: "{n} note" +_menuDisplay: + hide: "Nascondere" +_wordMute: + muteWords: "Parole da filtrare" + muteWordsDescription: "Separare con uno spazio indica la condizione \"E\". Separare + con un'interruzzione riga indica la condizione \"O\"." + muteWordsDescription2: "Metti le parole chiavi tra slash per usare espressioni regolari + (regexp)." + softDescription: "Nascondi della timeline note che rispondono alle condizioni impostate + qui." + hardDescription: "Impedisci alla timeline di caricare le note che rispondono alle + condizioni impostate qui. Inoltre, le note scompariranno in modo irreversibile, + anche se le condizioni verranno successivamente rimosse." + soft: "Moderato" + hard: "Severo" + mutedNotes: "Note silenziate" +_theme: + explore: "Esplora temi" + install: "Installa un tema" + manage: "Gerisci temi" + code: "Codice tema" + description: "Descrizione" + installed: "{name} è installato" + installedThemes: "Temi installati" + builtinThemes: "Temi integrati" + alreadyInstalled: "Questo tema è già installato" + invalid: "Il formato tema non è valido" + make: "Crea un tema" + base: "Base" + addConstant: "Aggiungi costante" + constant: "Costante" + defaultValue: "Valore predefinito" + color: "Colore" + refConst: "Chiama costante" + key: "Chiave" + func: "Funzione" + funcKind: "Tipo di funzione" + argument: "Argomento" + alpha: "Opacità" + darken: "Scuro" + lighten: "Chiaro" + inputConstantName: "Inserisci un nome per la costante" + deleteConstantConfirm: "Vuoi davvero eliminare la costante {const}?" + keys: + bg: "Sfondo" + fg: "Testo" + focus: "Focalizzazione" + indicator: "Indicatore" + panel: "Pannello" + shadow: "Ombra" + header: "Intestazione" + navBg: "Sfondo della barra laterale" + navFg: "Testo della barra laterale" + navHoverFg: "Testo della barra laterale (al passaggio del mouse)" + navActive: "Testo della barra laterale (attivo)" + navIndicator: "Indicatore di barra laterale" + link: "Link" + hashtag: "Hashtag" + mention: "Menzioni" + mentionMe: "Menzioni (di me)" + renote: "Rinota" + divider: "Interruzione di linea" + infoBg: "Sfondo informazioni" + infoFg: "Testo di informazioni" + infoWarnBg: "Sfondo degli avvisi" + infoWarnFg: "Testo di avviso" + cwBg: "Sfondo del CW" + cwFg: "Testo del pulsante CW" + cwHoverBg: "Sfondo del pulsante CW (sorvolato)" + toastBg: "Sfondo di notifica a comparsa" + toastFg: "Testo di notifica a comparsa" + buttonBg: "Sfondo del pulsante" + buttonHoverBg: "Sfondo del pulsante (sorvolato)" + inputBorder: "Inquadra casella di testo" + listItemHoverBg: "Sfondo della voce di elenco (sorvolato)" + driveFolderBg: "Sfondo della cartella di disco" + badge: "Distintivo" + messageBg: "Sfondo della chat" +_sfx: + note: "Nota" + noteMy: "Mia nota" + notification: "Notifiche" + chat: "Messaggi" + chatBg: "Chat (sfondo)" + antenna: "Ricezione dell'antenna" + channel: "Notifiche di canale" +_ago: + future: "Futuro" + justNow: "Ora" + secondsAgo: "{n}s fa" + minutesAgo: "{n}min {n2}s fa" + hoursAgo: "{n}h {n2}min fa" + daysAgo: "{1} giorni fa" + weeksAgo: "{n} settimane fa" + monthsAgo: "{n} mesi {n2} settimane fa" + yearsAgo: "{n} ann {n2} mesi fa" +_time: + second: "s" + minute: "min" + hour: "ore" + day: "giorni" +_tutorial: + titolo: "Come usare Iceshrimp" + step1_1: "Benvenuto!" + step1_2: "Vediamo di configurarla. Sarete operativi in men che non si dica!" + step2_1: "Per prima cosa, compila il tuo profilo" + step2_2: "Fornendo alcune informazioni su chi siete, sarà più facile per gli altri + capire se vogliono vedere le vostre note o seguirvi" + step3_1: "Ora è il momento di seguire alcune persone!" + step3_2: "La vostra home e le vostre timeline social si basano su chi seguite, quindi + provate a seguire un paio di account per iniziare.\nCliccate sul cerchio più in + alto a destra di un profilo per seguirlo" + step4_1: "Fatevi conoscere" + step4_2: "Per il vostro primo post, alcuni preferiscono fare un post di {introduction} + o un semplice \"Ciao mondo!\"" + step5_1: "Linee temporali, linee temporali dappertutto!" + step5_2: "La tua istanza ha attivato {timelines} diverse timelines" + step5_3: "La timeline Home {icon} è quella in cui si possono vedere i post dei propri + follower" + step5_4: "La timeline Locale {icon} è quella in cui si possono vedere i post di + tutti gli altri utenti di questa istanza" + step5_5: "La timeline Raccomandati {icon} è quella in cui si possono vedere i post + delle istanze raccomandate dagli amministratori" + step5_6: "La timeline Social {icon} è quella in cui si possono vedere i post degli + amici dei propri follower" + step5_7: "La timeline Globale {icon} è quella in cui si possono vedere i post di + ogni altra istanza collegata" + step6_1: "Allora, cos'è questo posto?" + step6_2: "Beh, non ti sei semplicemente unito a Iceshrimp. Sei entrato in un portale + del Fediverse, una rete interconnessa di migliaia di server, chiamata \"istanze\"" + step6_3: "Ogni server funziona in modo diverso, e non tutti i server eseguono Iceshrimp. + Questo però lo fa! È un po' complicato, ma ci riuscirete in poco tempo" + step6_4: "Ora andate, esplorate e divertitevi!" +_2fa: + registerTOTP: "Aggiungi dispositivo" +_permissions: + "read:account": "Visualizzare le informazioni dell'account" + "write:account": "Modificare le informazioni dell'account" + "read:blocks": "Visualizza gli account bloccati" + "write:blocks": "Gestisci gli account bloccati" + "read:drive": "Aprire il Drive" + "write:drive": "Gestire il Drive" + "read:favorites": "Visualizza i tuoi preferiti" + "write:favorites": "Gestisci i tuoi preferiti" + "read:following": "Vedi le informazioni di follow" + "write:following": "Seguiti/ Smetti di seguire" + "read:messaging": "Visualizzare la chat" + "write:messaging": "Gestire la chat" + "read:mutes": "Vedi account silenziati" + "write:mutes": "Gerisci account silenziati" + "write:notes": "Creare / Eliminare note" + "read:notifications": "Visualizza notifiche" + "write:notifications": "Gerisci notifiche" + "read:reactions": "Vedi reazioni" + "write:reactions": "Gerisci reazioni" + "write:votes": "Votare" + "read:pages": "Visualizzare pagine" + "write:pages": "Gestire pagine" + "read:page-likes": "Visualizzare i \"Mi piace\" di pagine" + "write:page-likes": "Gestire i \"Mi piace\" di pagine" + "read:user-groups": "Vedi gruppi di utenti" + "write:user-groups": "Gestisci gruppi di utenti" + "read:channels": "Visualizza canali" + "write:channels": "Gerisci canali" +_auth: + shareAccess: "Autorizzare「{name}」ad accedere al tuo account?" + shareAccessAsk: "Vuoi davvero consentire l'accesso al tuo account a questa app'?" + permissionAsk: "Questa app richiede le seguenti autorizzazioni:" + pleaseGoBack: "Si prega di ritornare sulla app" + callback: "Ritornando sulla app" + denied: "Accesso negato" +_antennaSources: + all: "Tutte le note" + homeTimeline: "Note dagli utenti che segui" + users: "Note dagli utenti selezionati" + userList: "Note dagli utenti della lista selezionata" + userGroup: "Note dagli utenti del gruppo selezionato" +_weekday: + sunday: "Domenica" + monday: "Lunedì" + tuesday: "Martedì" + wednesday: "Mercoledì" + thursday: "Giovedì" + friday: "Venerdì" + saturday: "Sabato" +_widgets: + memo: "Memo" + notifications: "Notifiche" + timeline: "Timeline" + calendar: "Calendario" + trends: "Tendenze" + clock: "Orologio" + rss: "Aggregatore rss" + activity: "Attività" + photos: "Foto" + digitalClock: "Orologio digitale" + federation: "Federazione" + postForm: "Finestra di pubblicazione" + slideshow: "Diapositive" + button: "Pulsante" + onlineUsers: "Utenti online" + jobQueue: "Coda di lavoro" + serverMetric: "Statistiche server" + aiscript: "Console AiScript" +_cw: + hide: "Nascondere" + show: "Mostra di più" + chars: "{count} caratteri" + files: "{count} file" +_poll: + noOnlyOneChoice: "Sono necessarie almeno 2 risposte" + choiceN: "Opzione {n}" + noMore: "Hai aggiunto il numero massimo di opzioni." + canMultipleVote: "Possibilità di risposte multiple" + expiration: "Scadenza" + infinite: "Non scade" + at: "Seleziona data" + after: "Seleziona durata" + deadlineDate: "Data di scadenza" + deadlineTime: "Ora di scadenza" + duration: "Durata" + votesCount: "{n} voti" + totalVotes: "Totale di {n} voti" + vote: "Vota" + showResult: "Visualizza risultati" + voted: "Hai votato" + closed: "Terminato" + remainingDays: "Rimangono {d} giorni e {h} ore" + remainingHours: "Rimangono {h} ore e {m} minuti" + remainingMinutes: "Rimangono {m} minuti e {s} secondi" + remainingSeconds: "Rimangono {s} secondi" +_visibility: + public: "Pubblica" + publicDescription: "Visibile per tutti sul Fediverso" + home: "Home" + homeDescription: "Visibile solo sulla timeline \"Home\"" + followers: "Followers" + followersDescription: "Visibile solo per i tuoi followers" + specified: "Diretta" + specifiedDescription: "Visibile solo per gli/le utenti menzionatə" + localOnly: "Soltanto locale" + localOnlyDescription: "Nascosta per gli/le utenti remotə" +_postForm: + replyPlaceholder: "Nota la tua risposta.." + quotePlaceholder: "Cita Nota..." + channelPlaceholder: "Pubblica in canale" + _placeholders: + a: "Che succede?" + b: "È successo qualcosa?" + c: "Che cos'hai in mente?" + d: "Vuoi dire qualcosa?" + e: "Scrivi qualcosa qui" + f: "Aspettando che scriva..." +_profile: + name: "Nome" + username: "Nome utente" + description: "Bio" + youCanIncludeHashtags: "Puoi anche includere hashtag." + metadata: "Informazioni aggiuntive" + metadataEdit: "Modifica informazioni aggiuntive" + metadataDescription: "Puoi pubblicare fino a quattro informazioni aggiuntive sul + profilo. Puoi aggiungere un tag {a} o {l} con {rel} per verificare il link sul tuo profilo!" + metadataLabel: "Etichetta" + metadataContent: "Contenuto" + changeAvatar: "Modifica immagine profilo" + changeBanner: "Cambia intestazione" +_exportOrImport: + allNotes: "Tutte le note" + followingList: "Follows" + muteList: "Account silenziati" + blockingList: "Account bloccati" + userLists: "Liste" +_charts: + federation: "Federazione" + apRequest: "Richieste" + usersIncDec: "Variazione del numero di utenti" + usersTotal: "Numero totale di utenti" + activeUsers: "Numero di utenti attivi" + notesIncDec: "Variazione del numero di note" + localNotesIncDec: "Variazione del numero di note locali" + remoteNotesIncDec: "Variazione del numero di note distanti" + notesTotal: "Conteggio totale di note" + filesIncDec: "Variazione del numero dei file" + filesTotal: "Numero totale di file" + storageUsageIncDec: "Variazione dell'utilizzo dell'immagazzinamento" + storageUsageTotal: "Utilizzo totale dell'immagazzinamento" +_instanceCharts: + requests: "Richieste" + users: "Variazione del numero di utenti" + usersTotal: "Totale cumulativo di utenti" + notes: "Variazione del numero di note" + notesTotal: "Totale cumulato di note" + ff: "Variazione dei follow/ follower" + ffTotal: "Totale cumulato dei follow/ follower" + cacheSize: "Variazione dello spazio occupato dalla cache" + cacheSizeTotal: "Totale cumulato dello spazio occupato dalla cache" + files: "Variazione del numero di file" + filesTotal: "Totale cumulato del numero di file" +_timelines: + home: "Home" + local: "Locale" + social: "Sociale" + global: "Federata" +_pages: + newPage: "Crea pagina" + editPage: "Modifica pagina" + readPage: "Visualizzando fonte " + created: "Pagina creata!" + updated: "Pagina aggiornata con successo!" + deleted: "Pagina eliminata" + pageSetting: "Impostazioni pagina" + nameAlreadyExists: "Esiste già una pagina con lo stesso URL." + invalidNameTitle: "L'URL di pagina definito non è valido" + invalidNameText: "Verifica che il campo non è vuoto" + editThisPage: "Modifica questa pagina" + viewSource: "Visualizza sorgente" + viewPage: "Visualizza pagina" + like: "Mi piace" + unlike: "Togli Mi piace" + my: "Le mie pagine" + liked: "Pagine che mi piacciono" + featured: "Popolari" + contents: "Contenuto" + content: "Blocco di pagina" + variables: "Variabili" + title: "Titolo" + url: "URL della pagina" + summary: "Riassunto di pagina" + hideTitleWhenPinned: "Nascondere il titolo pagina quando è fissata in cima al profilo." + font: "Tipo di carattere" + fontSerif: "Serif" + fontSansSerif: "Sans serif" + eyeCatchingImageSet: "Imposta un'immagine attrattiva" + eyeCatchingImageRemove: "Elimina l'immagine attrattiva" + chooseBlock: "Aggiungi blocco" + selectType: "Seleziona tipo" + enterVariableName: "Digita un nome di variabile" + variableNameIsAlreadyUsed: "Esiste già una variabile con lo stesso nome" + contentBlocks: "Contenuto" + inputBlocks: "Blocchi di input" + specialBlocks: "Speciale" + blocks: + text: "Testo" + textarea: "Area di testo" + section: "Sezione" + image: "Immagini" + button: "Pulsante" + if: "Se" + _if: + variable: "Variabili" + post: "Finestra di pubblicazione" + _post: + text: "Contenuto" + textInput: "Immissione testo" + _textInput: + name: "Nome della variabile" + text: "Titolo" + default: "Valore predefinito" + textareaInput: "Immissione testo a più righe" + _textareaInput: + name: "Nome della variabile" + text: "Titolo" + default: "Valore predefinito" + numberInput: "Immissione numerica" + _numberInput: + name: "Nome della variabile" + text: "Titolo" + default: "Valore predefinito" + _canvas: + width: "Larghezza" + height: "Altezza" + note: "Nota integrata" + _note: + id: "ID nota" + idDescription: "Qui puoi anche incollare l'URL della nota che vuoi impostare." + detailed: "Visualizzazione dettagliata" + switch: "Interruttore" + _switch: + name: "Nome della variabile" + text: "Titolo" + default: "Valore predefinito" + counter: "Contatore" + _counter: + name: "Nome della variabile" + text: "Titolo" + inc: "Valore da aggiungere" + _button: + text: "Titolo" + colored: "Colorato" + action: "Operazione da eseguire quando viene premuto il pulsante" + _action: + dialog: "Visualizzare una finestra di dialogo" + _dialog: + content: "Contenuto" + resetRandom: "Ripristinare un numero aleatorio" + pushEvent: "Inviare evento" + _pushEvent: + event: "Nome evento" + message: "Messaggio da visualizzare quando abilitato" + variable: "Variabile da inviare" + no-variable: "Nessun contenuto" + callAiScript: "Chiamare AiScript" + _callAiScript: + functionName: "Nome della funzione" + radioButton: "Opzioni" + _radioButton: + name: "Nome della variabile" + title: "Titolo" + default: "Valore predefinito" + script: + categories: + comparison: "Metodo comparativo" + random: "Aleatorietà" + value: "Valore" + fn: "Funzione" + list: "Liste" + blocks: + text: "Testo" + multiLineText: "Testo (a più righe)" + textList: "Lista di testo" + _strLen: + arg1: "Testo" + _strPick: + arg1: "Testo" + _strReplace: + arg1: "Testo" + _strReverse: + arg1: "Testo" + _join: + arg1: "Liste" + _add: + arg1: "A" + arg2: "B" + _subtract: + arg1: "A" + arg2: "B" + _multiply: + arg1: "A" + arg2: "B" + _divide: + arg1: "A" + arg2: "B" + _mod: + arg1: "A" + arg2: "B" + _eq: + arg1: "A" + arg2: "B" + notEq: "A e B sono differenti" + _notEq: + arg1: "A" + arg2: "B" + and: "A e B" + _and: + arg1: "A" + arg2: "B" + or: "A o B" + _or: + arg1: "A" + arg2: "B" + _lt: + arg1: "A" + arg2: "B" + _gt: + arg1: "A" + arg2: "B" + _ltEq: + arg1: "A" + arg2: "B" + _gtEq: + arg1: "A" + arg2: "B" + _if: + arg1: "Se" + arg2: "Se" + random: "Aleatorietà" + _randomPick: + arg1: "Liste" + _dailyRandomPick: + arg1: "Liste" + _seedRandom: + arg2: "Probabilità" + _seedRandomPick: + arg2: "Liste" + _DRPWPM: + arg1: "Lista di testo" + _pick: + arg1: "Liste" + _listLen: + arg1: "Liste" + _stringToNumber: + arg1: "Testo" + _splitStrByLine: + arg1: "Testo" + ref: "Variabili" + fn: "Funzione" + types: + string: "Testo" + array: "Liste" + stringArray: "Lista di testo" +_relayStatus: + requesting: "In attesa di approvazione" + accepted: "Approvato" + rejected: "Respinto" +_notification: + fileUploaded: "File caricato correttamente" + youGotMention: "{name} ti ha menzionato" + youGotReply: "{name} ti ha risposto" + youGotQuote: "{name} ha citato il tuo Nota e ha detto" + youRenoted: "{name} ha rinotato" + youGotPoll: "{name} ha votato" + youGotMessagingMessageFromUser: "{name} ti ha mandato un messaggio" + youGotMessagingMessageFromGroup: "{name} ti ha mandato un messaggio nella chat" + youWereFollowed: "Ha iniziato a seguirti" + youReceivedFollowRequest: "Hai ricevuto una richiesta di follow" + yourFollowRequestAccepted: "La tua richiesta di follow è stata accettata" + youWereInvitedToGroup: "Invitat@ al gruppo" + _types: + all: "Tutto" + follow: "Nuovə follower" + mention: "Menzioni" + reply: "Risposte" + renote: "Rinota" + quote: "Cita" + reaction: "Reazioni" + pollVote: "Voti ricevuti" + receiveFollowRequest: "Richiesta di follow ricevuta" + followRequestAccepted: "Richiesta di follow accettata" + groupInvited: "Invito a un gruppo" + app: "Notifiche da applicazioni" + _actions: + reply: "Rispondi" + renote: "Rinota" +_deck: + alwaysShowMainColumn: "Mostra sempre la colonna principale" + columnAlign: "Allineare colonne" + addColumn: "Aggiungi colonna" + swapLeft: "Sposta a sinistra" + swapRight: "Sposta a destra" + swapUp: "Sposta in alto" + swapDown: "Sposta in basso" + stackLeft: "Impila a sinistra" + popRight: "Estrai a destra" + profile: "Profilo" + _columns: + main: "Principale" + widgets: "Widget" + notifications: "Notifiche" + tl: "Timeline" + antenna: "Antenne" + list: "Liste" + mentions: "Menzioni" + direct: "Diretta" +noThankYou: No grazie +addInstance: Aggiungi un'istanza +deleted: Eliminato diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml new file mode 100644 index 0000000..3e59acd --- /dev/null +++ b/locales/ja-JP.yml @@ -0,0 +1,2011 @@ +_lang_: "日本語" +headlineFrozenFriendsYume: "ずっと無料でオープンソースの非中央集権型ソーシャルメディアプラットフォーム🚀" +introFrozenFriendsYume: "ようこそ!FrozenFriendsYumeは、オープンソースの非中央集権型ソーシャルメディアプラットフォームです。\nいま起こっていることを共有したり、あなたについて皆に発信しましょう📡\n\ + 「リアクション」機能で、皆の投稿に素早く反応を追加できます👍\n新しい世界を探検しよう🚀" +monthAndDay: "{month}月 {day}日" +search: "検索" +notifications: "通知" +username: "ユーザー名" +password: "パスワード" +forgotPassword: "パスワードを忘れました" +fetchingAsApObject: "連合宇宙から取得中" +ok: "OK" +gotIt: "わかった!" +cancel: "キャンセル" +noThankYou: "やめておく" +enterUsername: "ユーザー名を入力" +renotedBy: "{user}がブースト" +noNotes: "投稿はありません" +noNotifications: "通知はありません" +instance: "サーバー" +settings: "設定" +basicSettings: "基本設定" +otherSettings: "その他の設定" +openInWindow: "ウィンドウで開く" +profile: "プロフィール" +timeline: "タイムライン" +noAccountDescription: "自己紹介はありません" +login: "ログイン" +loggingIn: "ログイン中" +logout: "ログアウト" +signup: "新規登録" +uploading: "アップロード中" +save: "保存" +users: "ユーザー" +addUser: "ユーザーを追加" +addInstance: "サーバーを追加" +favorite: "お気に入り" +favorites: "お気に入り" +calls: "通話" +memoriet: "Memoriet" +reversi: "リバーシ" +shogi: "将棋" +videoService: "動画サービス" +audioService: "音声サービス" +imageService: "画像サービス" +karaokeService: "カラオケ" +lua4frozen: "Lua4Frozen" +yumeFortune: "ゆめくじ" +yumeFortuneToday: "今日のゆめくじ" +yumeFortuneDraw: "くじを引く" +yumeFortuneShuffle: "もう一度ひく" +yumeFortuneBeforeTitle: "まだ引いていません" +yumeFortuneBeforeText: "ボタンを押すと、今日の小さな運勢を引けます。" +yumeFortuneResultBright: "ひらめき日和" +yumeFortuneResultBrightText: "小さな思いつきが、思ったより遠くまで届きそうです。" +yumeFortuneResultBrightHint: "下書きに残していた案をひとつ投稿してみましょう。" +yumeFortuneResultCalm: "整える日" +yumeFortuneResultCalmText: "急がず、見える場所を少し片づけると流れが良くなります。" +yumeFortuneResultCalmHint: "通知、フォロー、リストを軽く見直すのに向いています。" +yumeFortuneResultDeep: "深掘り日和" +yumeFortuneResultDeepText: "気になっていた話題を掘ると、良い会話のきっかけになります。" +yumeFortuneResultDeepHint: "長めのノートやクリップ整理が合いそうです。" +yumeFortuneResultPlay: "遊び心の日" +yumeFortuneResultPlayText: "いつもと違う絵文字や言い回しが、空気をやわらかくします。" +yumeFortuneResultPlayHint: "リアクションをひとつ新しく試してみましょう。" +yumeFortuneResultFresh: "新しい窓の日" +yumeFortuneResultFreshText: "まだ見ていないページに、ちょうどよい発見がありそうです。" +yumeFortuneResultFreshHint: "探索、ギャラリー、チャンネルを少し歩いてみましょう。" +unfavorite: "お気に入り解除" +favorited: "お気に入りに登録しました。" +alreadyFavorited: "既にお気に入りに登録されています。" +cantFavorite: "お気に入りに登録できませんでした。" +pin: "ピン留め" +unpin: "ピン留め解除" +copyContent: "内容をコピー" +copyLink: "リンクをコピー" +delete: "削除" +deleteAndEdit: "削除して編集" +deleteAndEditConfirm: "この投稿を削除してもう一度編集しますか?この投稿へのリアクション、ブースト、返信は全て失われます。" +addToList: "リストに追加" +sendMessage: "メッセージを送信" +copyUsername: "ユーザー名をコピー" +searchUser: "ユーザーを検索" +reply: "返信" +loadMore: "もっと読み込む" +showMore: "もっと見る" +showLess: "閉じる" +youGotNewFollower: "フォローされました" +receiveFollowRequest: "フォローリクエストされました" +followRequestAccepted: "フォローが承認されました" +mention: "メンション" +mentions: "あなた宛て" +directNotes: "ダイレクト投稿" +importAndExport: "インポートとエクスポート" +import: "インポート" +export: "エクスポート" +files: "ファイル" +download: "ダウンロード" +driveFileDeleteConfirm: "ファイル「{name}」を削除しますか?これにより、このファイルが添付されている投稿も削除されます。" +unfollowConfirm: "{name}さんのフォローを解除しますか?" +exportRequested: "エクスポートをリクエストしました。これには時間がかかる場合があります。エクスポートが終わると、「ドライブ」に追加されます。" +importRequested: "インポートをリクエストしました。これには時間がかかる場合があります。" +lists: "リスト" +noLists: "リストはありません" +note: "投稿" +notes: "投稿" +following: "フォロー" +followers: "フォロワー" +followsYou: "フォローされています" +createList: "リスト作成" +manageLists: "リストの管理" +error: "エラー" +somethingHappened: "問題が発生しました" +retry: "再試行" +pageLoadError: "ページの読み込みに失敗しました。" +pageLoadErrorDescription: "これは通常、ネットワークまたはブラウザキャッシュが原因です。キャッシュをクリアするか、しばらく待ってから再度試してください。" +serverIsDead: "サーバーの応答がありません。しばらく待ってから再度試してください。" +youShouldUpgradeClient: "このページを表示するためには、リロードして新しいバージョンのクライアントをご利用ください。" +enterListName: "リスト名を入力" +privacy: "プライバシー" +makeFollowManuallyApprove: "フォローを承認制にする" +defaultNoteVisibility: "デフォルトの公開範囲" +follow: "フォロー" +followRequest: "フォロー申請" +followRequests: "フォロー申請" +unfollow: "フォロー解除" +followRequestPending: "フォロー許可待ち" +enterEmoji: "絵文字を入力" +renote: "ブースト" +unrenote: "ブースト解除" +renoted: "ブーストしました。" +cantRenote: "この投稿はブーストできません。" +cantReRenote: "ブーストをブーストすることはできません。" +quote: "引用" +pinnedNote: "ピン留めされた投稿" +pinned: "ピン留め" +you: "あなた" +clickToShow: "クリックして表示" +sensitive: "閲覧注意" +add: "追加" +reaction: "リアクション" +enableEmojiReactions: "絵文字リアクションを有効にする" +showEmojisInReactionNotifications: "自分の投稿に対するリアクションの通知で絵文字を表示する" +reactionSetting: "ピッカーに表示するリアクション" +reactionSettingDescription2: "ドラッグして並び替え、クリックして削除、+を押して追加します。" +rememberNoteVisibility: "公開範囲を記憶する" +attachCancel: "添付取り消し" +markAsSensitive: "閲覧注意にする" +unmarkAsSensitive: "閲覧注意を解除する" +enterFileName: "ファイル名を入力" +mute: "ミュート" +unmute: "ミュート解除" +renoteMute: "ブーストをミュート" +renoteUnmute: "ブーストのミュートを解除" +block: "ブロック" +unblock: "ブロック解除" +suspend: "凍結" +unsuspend: "解凍" +blockConfirm: "ブロックしますか?" +unblockConfirm: "ブロックを解除しますか?" +suspendConfirm: "凍結しますか?" +unsuspendConfirm: "解凍しますか?" +selectList: "リストを選択" +selectAntenna: "アンテナを選択" +selectWidget: "ウィジェットを選択" +selectChannel: "チャンネルを選択" +editWidgets: "ウィジェットを編集" +editWidgetsExit: "編集を終了" +customEmojis: "カスタム絵文字" +emoji: "絵文字" +emojis: "絵文字" +emojiName: "絵文字名" +emojiUrl: "絵文字画像URL" +addEmoji: "絵文字を追加" +settingGuide: "おすすめ設定" +cacheRemoteFiles: "リモートのファイルをキャッシュする" +cacheRemoteFilesDescription: "この設定を無効にすると、リモートファイルをキャッシュせず直リンクします。サーバーのストレージを節約できますが、サムネイルが生成されないので通信量が増加します。" +flagAsBot: "Botとして設定🤖" +flagAsBotDescription: "このアカウントがBotである場合は、この設定をオンにします。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、FrozenFriendsYumeのシステム上での扱いがBotに合ったものになります。" +flagAsCat: "あなたは…猫?😺" +flagAsCatDescription: "このアカウントが猫であることを示す猫モードを有効にするには、このフラグをオンにします。" +flagSpeakAsCat: "猫語で話す" +flagSpeakAsCatDescription: "猫モードが有効の場合にオンにすると、あなたの投稿の「な」を「にゃ」に変換します。" +flagShowTimelineReplies: "タイムラインに投稿の返信を表示する" +flagShowTimelineRepliesDescription: "オンにすると、タイムラインにユーザーの他の投稿への返信も表示されます。" +autoAcceptFollowed: "フォローしているユーザーからのフォロー申請を自動承認" +addAccount: "アカウントを追加" +loginFailed: "ログインに失敗しました" +showOnRemote: "リモートで表示" +general: "全般" +accountMoved: "このユーザーは新しいアカウントに移行しました" +wallpaper: "壁紙" +setWallpaper: "壁紙を設定" +removeWallpaper: "壁紙を削除" +searchWith: "検索: {q}" +youHaveNoLists: "リストがありません" +followConfirm: "{name}をフォローしますか?" +proxyAccount: "プロキシアカウント" +proxyAccountDescription: "プロキシアカウントは、特定の条件下でユーザーのリモートフォローを代行するアカウントです。例えば、ユーザーがリモートユーザーをリストに入れたとき、リストに入れられたユーザーを誰もフォローしていないとアクティビティがサーバーに配達されないため、代わりにプロキシアカウントがフォローするようにします。" +host: "ホスト" +selectUser: "ユーザーを選択" +selectInstance: "サーバーを選択" +recipient: "宛先" +annotation: "注釈" +federation: "連合" +instances: "サーバー" +registeredAt: "初観測" +latestRequestSentAt: "直近のリクエスト送信" +latestRequestReceivedAt: "直近のリクエスト受信" +latestStatus: "直近のステータス" +storageUsage: "ストレージ使用量" +charts: "チャート" +perHour: "1時間ごと" +perDay: "1日ごと" +stopActivityDelivery: "アクティビティの配送を停止" +blockThisInstance: "このサーバーをブロック" +silenceThisInstance: "このサーバーをサイレンス" +operations: "操作" +software: "ソフトウェア" +version: "バージョン" +metadata: "メタデータ" +monitor: "モニター" +jobQueue: "ジョブキュー" +cpuAndMemory: "CPUとメモリ" +network: "ネットワーク" +disk: "ディスク" +instanceInfo: "サーバー情報" +statistics: "統計" +clearQueue: "キューをクリア" +clearQueueConfirmTitle: "キューをクリアしますか?" +clearQueueConfirmText: "未配達の投稿は配送されなくなります。通常この操作を行う必要はありません。" +clearCachedFiles: "キャッシュをクリア" +clearCachedFilesConfirm: "キャッシュされたリモートファイルをすべて削除しますか?" +blockedInstances: "ブロックしたサーバー" +blockedInstancesDescription: "ブロックしたいサーバーのホストを改行で区切って設定します。ブロックされたサーバーは、このサーバーとやり取りできなくなります。" +silencedInstances: "サイレンスしたサーバー" +silencedInstancesDescription: "サイレンスしたいサーバーのホストを改行で区切って設定します。サイレンスされたサーバーに所属するアカウントはすべて「サイレンス」として扱われ、フォローがすべてリクエストになり、フォロワーでないローカルアカウントにはメンションできなくなります。ブロックしたサーバーには影響しません。" +muteAndBlock: "ミュートとブロック" +mutedUsers: "ミュートしたユーザー" +blockedUsers: "ブロックしたユーザー" +noUsers: "ユーザーはいません" +noInstances: "サーバーがありません" +editProfile: "プロフィールを編集" +noteDeleteConfirm: "この投稿を削除しますか?" +pinLimitExceeded: "これ以上ピン留めできません" +intro: "FrozenFriendsYumeのインストールが完了しました!管理者アカウントを作成しましょう。" +done: "完了" +processing: "処理中…" +preview: "プレビュー" +default: "デフォルト" +defaultValueIs: "デフォルト: {value}" +noCustomEmojis: "絵文字はありません" +noJobs: "ジョブはありません" +federating: "連合中" +blocked: "ブロック中" +silenced: "サイレンス中" +suspended: "配信停止" +all: "全て" +subscribing: "購読中" +publishing: "配信中" +notResponding: "応答なし" +instanceFollowing: "サーバーのフォロー" +instanceFollowers: "サーバーのフォロワー" +instanceUsers: "このサーバーの利用者" +changePassword: "パスワードを変更" +security: "セキュリティ" +retypedNotMatch: "入力が一致しません。" +currentPassword: "現在のパスワード" +newPassword: "新しいパスワード" +newPasswordRetype: "新しいパスワード(再入力)" +attachFile: "ファイルを添付" +more: "もっと" +featured: "ハイライト" +usernameOrUserId: "ユーザー名かユーザーID" +noSuchUser: "ユーザーが見つかりません" +lookup: "照会" +announcements: "お知らせ" +imageUrl: "画像URL" +remove: "削除" +removed: "削除しました" +removeAreYouSure: "「{x}」を削除しますか?" +deleteAreYouSure: "「{x}」を削除しますか?" +resetAreYouSure: "リセットしますか?" +saved: "保存しました" +messaging: "チャット" +upload: "アップロード" +keepOriginalUploading: "オリジナル画像を保持" +keepOriginalUploadingDescription: "画像をアップロードする時にオリジナル版を保持します。オフにするとアップロード時にブラウザでWeb公開用画像を生成します。" +fromDrive: "ドライブから" +fromUrl: "URLから" +uploadFromUrl: "URLアップロード" +uploadFromUrlDescription: "アップロードしたいファイルのURL" +uploadFromUrlRequested: "アップロードをリクエストしました" +uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がかかる場合があります。" +explore: "みつける" +messageRead: "既読" +noMoreHistory: "これより過去の履歴はありません" +startMessaging: "チャットを開始" +manageGroups: "グループ管理" +nUsersRead: "{n}人が読みました" +agreeTo: "{0}に同意" +tos: "利用規約" +start: "始める" +home: "ホーム" +remoteUserCaution: "リモートユーザーのため、情報が不完全です。" +activity: "アクティビティ" +images: "画像" +birthday: "誕生日" +yearsOld: "{age}歳" +registeredDate: "登録日" +location: "場所" +theme: "テーマ" +themeForLightMode: "ライトモードで使うテーマ" +themeForDarkMode: "ダークモードで使うテーマ" +light: "ライト" +dark: "ダーク" +lightThemes: "明るいテーマ" +darkThemes: "暗いテーマ" +syncDeviceDarkMode: "デバイスのダークモードと同期する" +drive: "ドライブ" +fileName: "ファイル名" +selectFile: "ファイルを選択" +selectFiles: "ファイルを選択" +selectFolder: "フォルダーを選択" +selectFolders: "フォルダーを選択" +renameFile: "ファイル名を変更" +folderName: "フォルダー名" +createFolder: "フォルダーを作成" +renameFolder: "フォルダー名を変更" +deleteFolder: "フォルダーを削除" +addFile: "ファイルを追加" +emptyDrive: "ドライブは空です" +emptyFolder: "フォルダーは空です" +unableToDelete: "削除できません" +inputNewFileName: "新しいファイル名を入力してください" +inputNewDescription: "新しい説明を入力してください" +inputNewFolderName: "新しいフォルダ名を入力してください" +circularReferenceFolder: "移動先のフォルダーは、移動するフォルダーのサブフォルダーです。" +hasChildFilesOrFolders: "このフォルダは空でないため、削除できません。" +copyUrl: "URLをコピー" +rename: "名前を変更" +avatar: "アイコン" +banner: "バナー" +nsfw: "閲覧注意" +whenServerDisconnected: "サーバーとの接続が失われたとき" +disconnectedFromServer: "サーバーから切断されました" +reload: "リロード" +doNothing: "なにもしない" +reloadConfirm: "リロードしますか?" +watch: "ウォッチ" +unwatch: "ウォッチ解除" +accept: "許可" +reject: "拒否" +normal: "正常" +instanceName: "サーバー名" +instanceDescription: "サーバーの紹介文" +maintainerName: "管理者の名前" +maintainerEmail: "管理者のメールアドレス" +tosUrl: "利用規約URL" +thisYear: "今年" +thisMonth: "今月" +today: "今日" +dayX: "{day}日" +monthX: "{month}月" +yearX: "{year}年" +pages: "ページ" +integration: "連携" +connectService: "接続する" +disconnectService: "切断する" +enableLocalTimeline: "ローカルタイムラインを有効にする" +enableGlobalTimeline: "グローバルタイムラインを有効にする" +enableRecommendedTimeline: "おすすめタイムラインを有効にする" +disablingTimelinesInfo: "これらのタイムラインを無効化しても、利便性のため管理者およびモデレーターは引き続き利用できます。" +registration: "登録" +enableRegistration: "誰でも新規登録できるようにする" +invite: "招待" +driveCapacityPerLocalAccount: "ローカルユーザーひとりあたりのドライブ容量" +driveCapacityPerRemoteAccount: "リモートユーザーひとりあたりのドライブ容量" +inMb: "メガバイト単位" +iconUrl: "アイコン画像のURL (faviconなど)" +bannerUrl: "バナー画像のURL" +backgroundImageUrl: "背景画像のURL" +basicInfo: "基本情報" +pinnedUsers: "ピン留めユーザー" +pinnedUsersDescription: "「みつける」ページなどにピン留めしたいユーザーを改行で区切って記述します。" +pinnedPages: "ピン留めページ" +pinnedPagesDescription: "サーバーのトップページにピン留めしたいページのパスを改行で区切って記述します。" +pinnedClipId: "ピン留めするクリップのID" +pinnedNotes: "ピン留めされた投稿" +hcaptcha: "hCaptcha" +enableHcaptcha: "hCaptchaを有効にする" +hcaptchaSiteKey: "サイトキー" +hcaptchaSecretKey: "シークレットキー" +recaptcha: "reCAPTCHA" +enableRecaptcha: "reCAPTCHAを有効にする" +recaptchaSiteKey: "サイトキー" +recaptchaSecretKey: "シークレットキー" +avoidMultiCaptchaConfirm: "複数のCaptchaを使用すると干渉を起こす可能性があります。他のCaptchaを無効にしますか?キャンセルして複数のCaptchaを有効化したままにすることも可能です。" +antennas: "アンテナ" +manageAntennas: "アンテナの管理" +name: "名前" +antennaSource: "受信ソース" +antennaKeywords: "受信キーワード" +antennaExcludeKeywords: "除外キーワード" +antennaKeywordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります" +notifyAntenna: "新しい投稿を通知する" +withFileAntenna: "ファイルが添付された投稿のみ" +enableServiceworker: "ブラウザへのプッシュ通知を有効にする" +antennaUsersDescription: "ユーザー名を改行で区切って指定します" +antennaInstancesDescription: "サーバーを改行で区切って指定します" +caseSensitive: "大文字小文字を区別する" +withReplies: "返信を含む" +connectedTo: "次のアカウントに接続されています" +notesAndReplies: "投稿と返信" +withFiles: "ファイル付き" +silence: "サイレンス" +silenceConfirm: "サイレンスしますか?" +unsilence: "サイレンス解除" +unsilenceConfirm: "サイレンスを解除しますか?" +popularUsers: "人気のユーザー" +recentlyUpdatedUsers: "最近投稿したユーザー" +recentlyRegisteredUsers: "最近登録したユーザー" +recentlyDiscoveredUsers: "最近発見されたユーザー" +exploreUsersCount: "{count}のユーザーがいます" +exploreFediverse: "Fediverseを探索" +popularTags: "人気のタグ" +userList: "リスト" +about: "情報" +aboutFrozenFriendsYume: "FrozenFriendsYumeについて" +administrator: "管理者" +token: "トークン" +twoStepAuthentication: "二段階認証" +moderator: "モデレーター" +moderation: "モデレーション" +nUsersMentioned: "{n}人が投稿" +securityKey: "セキュリティキー" +securityKeyName: "キーの名前" +registerSecurityKey: "セキュリティキーを登録する" +lastUsed: "最後の使用" +unregister: "登録を解除" +passwordLessLogin: "パスワード無しでログイン" +resetPassword: "パスワードをリセット" +newPasswordIs: "新しいパスワードは「{password}」です" +reduceUiAnimation: "UIのアニメーションを減らす" +share: "共有" +notFound: "見つかりません" +notFoundDescription: "指定されたURLに該当するページはありませんでした。" +uploadFolder: "既定アップロード先" +cacheClear: "キャッシュを削除" +markAsReadAllNotifications: "すべての通知を既読にする" +markAsReadAllUnreadNotes: "すべての投稿を既読にする" +markAsReadAllTalkMessages: "すべてのチャットを既読にする" +help: "ヘルプ" +inputMessageHere: "ここにメッセージを入力" +close: "閉じる" +group: "グループ" +groups: "グループ" +createGroup: "グループを作成" +ownedGroups: "所有グループ" +joinedGroups: "参加しているグループ" +invites: "招待" +groupName: "グループ名" +members: "メンバー" +transfer: "譲渡" +messagingWithUser: "ユーザーとチャット" +messagingWithGroup: "グループでチャット" +title: "タイトル" +text: "テキスト" +enable: "有効にする" +next: "次へ" +retype: "再入力" +noteOf: "{user}の投稿" +inviteToGroup: "グループに招待" +quoteAttached: "引用" +quoteQuestion: "引用として添付しますか?" +noMessagesYet: "まだチャットはありません" +newMessageExists: "新しいメッセージがあります" +onlyOneFileCanBeAttached: "メッセージに添付できるファイルはひとつです" +signinRequired: "続行する前に、サインアップまたはサインインが必要です" +invitations: "招待" +invitationCode: "招待コード" +checking: "確認しています" +available: "利用できます" +unavailable: "利用できません" +usernameInvalidFormat: "a~z、A~Z、0~9、_が使えます" +tooShort: "短すぎます" +tooLong: "長すぎます" +weakPassword: "弱いパスワード" +normalPassword: "普通のパスワード" +strongPassword: "強いパスワード" +passwordMatched: "一致しました" +passwordNotMatched: "一致していません" +signinWith: "{x}でログイン" +signinFailed: "ログインできませんでした。ユーザー名とパスワードを確認してください。" +tapSecurityKey: "セキュリティキーにタッチ" +or: "もしくは" +language: "言語" +uiLanguage: "UIの表示言語" +groupInvited: "グループに招待されました" +aboutX: "{x}について" +useOsNativeEmojis: "OSネイティブの絵文字を使用" +disableDrawer: "メニューをドロワーで表示しない" +youHaveNoGroups: "グループがありません" +joinOrCreateGroup: "既存のグループに招待してもらうか、新しくグループを作成してください。" +noHistory: "履歴はありません" +signinHistory: "ログイン履歴" +disableAnimatedMfm: "動きのあるMFMを無効にする" +doing: "やっています" +category: "カテゴリ" +tags: "タグ" +docSource: "このドキュメントのソース" +createAccount: "アカウントを作成" +existingAccount: "既存のアカウント" +regenerate: "再生成" +fontSize: "フォントサイズ" +noFollowRequests: "フォロー申請はありません" +openImageInNewTab: "画像を新しいタブで開く" +dashboard: "ダッシュボード" +local: "ローカル" +remote: "リモート" +total: "合計" +weekOverWeekChanges: "前週比" +dayOverDayChanges: "前日比" +appearance: "アピアランス" +clientSettings: "クライアント設定" +accountSettings: "アカウント設定" +promotion: "プロモーション" +promote: "プロモート" +numberOfDays: "日数" +hideThisNote: "この投稿を非表示" +showFeaturedNotesInTimeline: "タイムラインにおすすめの投稿を表示する" +objectStorage: "オブジェクトストレージ" +useObjectStorage: "オブジェクトストレージを使用" +objectStorageBaseUrl: "Base URL" +objectStorageBaseUrlDesc: "参照に使用するURL。CDNやProxyを使用している場合はそのURL、S3: 'https://.s3.amazonaws.com'、GCS等: + 'https://storage.googleapis.com/'。" +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "使用サービスのbucket名を指定してください。" +objectStoragePrefix: "Prefix" +objectStoragePrefixDesc: "このprefixのディレクトリ下に格納されます。" +objectStorageEndpoint: "Endpoint" +objectStorageEndpointDesc: "S3の場合は空、それ以外の場合は各サービスのendpointを指定してください。''または':'のように指定します。" +objectStorageRegion: "Region" +objectStorageRegionDesc: "'xx-east-1'のようなregionを指定してください。使用サービスにregionの概念がない場合は、空または'us-east-1'にしてください。" +objectStorageUseSSL: "SSLを使用する" +objectStorageUseSSLDesc: "API接続にhttpsを使用しない場合はオフにしてください" +objectStorageUseProxy: "Proxyを利用する" +objectStorageUseProxyDesc: "API接続にproxyを利用しない場合はオフにしてください" +objectStorageSetPublicRead: "アップロード時に'public-read'を設定する" +serverLogs: "サーバーログ" +deleteAll: "全て削除" +showFixedPostForm: "タイムライン上部に投稿フォームを表示する" +newNoteRecived: "新しい投稿があります" +sounds: "サウンド" +listen: "聴く" +none: "なし" +showInPage: "ページで表示" +popout: "ポップアウト" +volume: "音量" +masterVolume: "マスター音量" +details: "詳細" +chooseEmoji: "絵文字を選択" +unableToProcess: "操作を完了できません" +recentUsed: "最近使用" +install: "インストール" +uninstall: "アンインストール" +installedApps: "インストールされたアプリ" +nothing: "まだ何もありません" +installedDate: "インストール日時" +lastUsedDate: "最終使用日時" +state: "状態" +sort: "ソート" +ascendingOrder: "昇順" +descendingOrder: "降順" +scratchpad: "スクラッチパッド" +scratchpadDescription: "スクラッチパッドは、AiScriptの実験環境を提供します。FrozenFriendsYumeと対話するコードの記述、実行、結果の確認ができます。" +output: "出力" +script: "スクリプト" +disablePagesScript: "ページのスクリプトを無効にする" +updateRemoteUser: "リモートユーザー情報の更新" +deleteAllFiles: "すべてのファイルを削除" +deleteAllFilesConfirm: "すべてのファイルを削除しますか?" +removeAllFollowing: "フォローを全解除" +removeAllFollowingDescription: "{host}からのフォローをすべて解除します。そのサーバーがもう存在しなくなった場合などに実行してください。" +userSuspended: "このユーザーは凍結されています。" +userSilenced: "このユーザーはサイレンスされています。" +yourAccountSuspendedTitle: "アカウントが凍結されています" +yourAccountSuspendedDescription: "このアカウントは、サーバーの利用規約に違反したなどの理由により、凍結されています。詳細については管理者までお問い合わせください。新しいアカウントを作らないでください。" +menu: "メニュー" +divider: "分割線" +addItem: "項目を追加" +relays: "リレー" +addRelay: "リレーの追加" +inboxUrl: "inboxのURL" +addedRelays: "追加済みのリレー" +serviceworkerInfo: "プッシュ通知を行うには有効にする必要があります。" +deletedNote: "削除された投稿" +invisibleNote: "非公開の投稿" +enableInfiniteScroll: "自動でもっと見る" +visibility: "公開範囲" +poll: "アンケート" +useCw: "内容を隠す" +enablePlayer: "プレイヤーを開く" +disablePlayer: "プレイヤーを閉じる" +expandTweet: "ツイートを展開する" +themeEditor: "テーマエディター" +description: "説明" +describeFile: "説明を付ける" +enterFileDescription: "説明を入力" +author: "作者" +leaveConfirm: "未保存の変更があります。破棄しますか?" +manage: "管理" +plugins: "プラグイン" +preferencesBackups: "設定のバックアップ" +deck: "デッキ" +undeck: "デッキ解除" +useBlurEffectForModal: "モーダルにぼかし効果を使用" +useFullReactionPicker: "フル機能リアクションピッカーを使用" +width: "幅" +height: "高さ" +large: "大" +medium: "中" +small: "小" +generateAccessToken: "アクセストークンの発行" +permission: "権限" +enableAll: "全て有効にする" +disableAll: "全て無効にする" +tokenRequested: "アカウントへのアクセス許可" +pluginTokenRequestedDescription: "このプラグインはここで設定した権限を行使できるようになります。" +notificationType: "通知の種類" +edit: "編集" +emailServer: "メールサーバー" +enableEmail: "メール配信機能を有効化する" +emailConfigInfo: "メールアドレスの確認やパスワードリセットの際に使います" +email: "メール" +emailAddress: "メールアドレス" +smtpConfig: "SMTP サーバーの設定" +smtpHost: "ホスト" +smtpPort: "ポート" +smtpUser: "ユーザー名" +smtpPass: "パスワード" +emptyToDisableSmtpAuth: "ユーザー名とパスワードを空欄にすることで、SMTP認証を無効化出来ます" +smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する" +smtpSecureInfo: "STARTTLS使用時はオフにします。" +testEmail: "配信テスト" +wordMute: "ワードミュート" +regexpError: "正規表現エラー" +regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが発生しました:" +instanceMute: "サーバーミュート" +userSaysSomething: "{name}が何かを言いました" +userSaysSomethingReason: "{name}が{reason}と言いました" +userSaysSomethingReasonReply: "{name}が{reason}を含む投稿に返信しました" +userSaysSomethingReasonRenote: "{name}が{reason}を含む投稿をブーストしました" +userSaysSomethingReasonQuote: "{name}が{reason}を含む投稿を引用しました" +makeActive: "アクティブにする" +display: "表示" +copy: "コピー" +metrics: "メトリクス" +overview: "概要" +logs: "ログ" +delayed: "遅延" +database: "データベース" +channel: "チャンネル" +create: "作成" +notificationSetting: "通知設定" +notificationSettingDesc: "表示する通知の種別を選択してください。" +useGlobalSetting: "グローバル設定を使う" +useGlobalSettingDesc: "オンにすると、アカウントの通知設定が使用されます。オフにすると、個別に設定できるようになります。" +other: "その他" +regenerateLoginToken: "ログイントークンを再生成" +regenerateLoginTokenDescription: "ログインに使用される内部トークンを再生成します。通常この操作を行う必要はありません。再生成すると、全てのデバイスでログアウトされます。" +setMultipleBySeparatingWithSpace: "スペースで区切って複数設定できます。" +fileIdOrUrl: "ファイルIDまたはURL" +behavior: "動作" +sample: "サンプル" +abuseReports: "通報" +reportAbuse: "通報" +reportAbuseOf: "{name}を通報する" +fillAbuseReportDescription: "通報理由の詳細を記入してください。対象の投稿がある場合はそのURLも記入してください。" +abuseReported: "内容が送信されました。ご報告ありがとうございました。" +reporter: "通報者" +reporteeOrigin: "通報先" +reporterOrigin: "通報元" +forwardReport: "リモートサーバーに通報を転送する" +forwardReportIsAnonymous: "リモートサーバーからはあなたの情報は見られず、匿名のシステムアカウントとして表示されます。" +send: "送信" +abuseMarkAsResolved: "対応済みにする" +openInNewTab: "新しいタブで開く" +openInSideView: "サイドビューで開く" +defaultNavigationBehaviour: "デフォルトのナビゲーション" +editTheseSettingsMayBreakAccount: "これらの設定を編集するとアカウントが破損する可能性があります。" +instanceTicker: "投稿のサーバー情報" +waitingFor: "{x}を待っています" +random: "ランダム" +system: "システム" +switchUi: "UI切り替え" +desktop: "デスクトップ" +clip: "クリップ" +createNew: "新規作成" +optional: "任意" +createNewClip: "新しいクリップを作成" +unclip: "クリップ解除" +confirmToUnclipAlreadyClippedNote: "この投稿はすでにクリップ「{name}」に含まれています。投稿をこのクリップから除外しますか?" +public: "公開" +i18nInfo: "FrozenFriendsYumeは有志によって様々な言語に翻訳されています。{link}で翻訳に協力できます。" +manageAccessTokens: "アクセストークンの管理" +accountInfo: "アカウント情報" +notesCount: "投稿の数" +repliesCount: "返信した数" +renotesCount: "ブーストした数" +repliedCount: "返信された数" +renotedCount: "ブーストされた数" +followingCount: "フォロー数" +followersCount: "フォロワー数" +sentReactionsCount: "リアクションした数" +receivedReactionsCount: "リアクションされた数" +pollVotesCount: "アンケートに投票した数" +pollVotedCount: "アンケートに投票された数" +yes: "はい" +no: "いいえ" +driveFilesCount: "ドライブのファイル数" +driveUsage: "ドライブ使用量" +noCrawle: "クローラーによるインデックスを拒否" +noCrawleDescription: "検索エンジンにあなたのプロフィールや投稿、ページなどのコンテンツを登録(インデックス)しないよう要請します。" +lockedAccountInfo: "フォローを承認制にしても、投稿の公開範囲を「フォロワー」にしない限り、誰でもあなたの投稿を見られます。" +alwaysMarkSensitive: "デフォルトでメディアを閲覧注意にする" +loadRawImages: "添付画像のサムネイルをオリジナル画質にする" +disableShowingAnimatedImages: "アニメーション画像を再生しない" +verificationEmailSent: "確認のメールを送信しました。メールに記載されたリンクにアクセスして、設定を完了してください。" +notSet: "未設定" +emailVerified: "メールアドレスが確認されました" +noteFavoritesCount: "お気に入りの投稿の数" +pageLikesCount: "ページにいいねした数" +pageLikedCount: "ページにいいねされた数" +contact: "連絡先" +useSystemFont: "システムのデフォルトのフォントを使う" +clips: "クリップ" +experimentalFeatures: "実験的機能" +developer: "開発者" +makeExplorable: "アカウントを見つけやすくする" +makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らなくなります。" +showGapBetweenNotesInTimeline: "タイムラインの投稿を離して表示する" +duplicate: "複製" +left: "左" +center: "中央" +wide: "広い" +narrow: "狭い" +reloadToApplySetting: "設定はページリロード後に反映されます。今すぐリロードしますか?" +needReloadToApply: "反映には再起動が必要です。" +showTitlebar: "タイトルバーを表示する" +clearCache: "キャッシュをクリア" +onlineUsersCount: "{n}人がオンライン" +nUsers: "{n}ユーザー" +nNotes: "{n}投稿" +sendErrorReports: "エラーリポートを送信" +sendErrorReportsDescription: "オンにすると、問題が発生したときにエラーの詳細情報がFrozenFriendsYumeに共有され、ソフトウェアの品質向上に役立てられます。\n\ + エラー情報には、OSのバージョン、ブラウザの種類、行動履歴などが含まれます。" +myTheme: "マイテーマ" +backgroundColor: "背景" +accentColor: "アクセント" +textColor: "文字" +saveAs: "名前を付けて保存" +advanced: "高度" +value: "値" +createdAt: "作成日時" +updatedAt: "更新日時" +saveConfirm: "保存しますか?" +deleteConfirm: "削除しますか?" +invalidValue: "有効な値ではありません。" +registry: "レジストリ" +closeAccount: "アカウントを閉鎖する" +currentVersion: "現在のバージョン" +latestVersion: "最新のバージョン" +youAreRunningUpToDateClient: "お使いのクライアントは最新です。" +newVersionOfClientAvailable: "新しいバージョンのクライアントが利用可能です。" +usageAmount: "使用量" +capacity: "容量" +inUse: "使用中" +editCode: "コードを編集" +apply: "適用" +receiveAnnouncementFromInstance: "サーバーからのお知らせを受け取る" +emailNotification: "メール通知" +publish: "公開" +inChannelSearch: "チャンネル内検索" +useReactionPickerForContextMenu: "右クリックでリアクションピッカーを開く" +typingUsers: "{users}が入力中" +jumpToSpecifiedDate: "特定の日付にジャンプ" +showingPastTimeline: "過去のタイムラインを表示しています" +clear: "クリア" +markAllAsRead: "全て既読にする" +goBack: "戻る" +unlikeConfirm: "いいねを解除しますか?" +fullView: "フルビュー" +quitFullView: "フルビュー解除" +addDescription: "説明を追加" +userPagePinTip: "個々の投稿のメニューから「ピン留め」を選択することで、ここに投稿を表示できます。" +notSpecifiedMentionWarning: "宛先に含まれていないメンションがあります" +info: "情報" +userInfo: "ユーザー情報" +unknown: "不明" +onlineStatus: "オンライン状態" +hideOnlineStatus: "オンライン状態を隠す" +hideOnlineStatusDescription: "オンライン状態を隠すと、検索などの一部機能において利便性が低下することがあります。" +online: "オンライン" +active: "アクティブ" +offline: "オフライン" +notRecommended: "非推奨" +botProtection: "Botプロテクション" +instanceBlocking: "連合の管理" +selectAccount: "アカウントを選択" +switchAccount: "アカウントを切り替え" +enabled: "有効" +disabled: "無効" +quickAction: "クイックアクション" +user: "ユーザー" +administration: "管理" +accounts: "アカウント" +switch: "切り替え" +noMaintainerInformationWarning: "管理者情報が設定されていません。" +noBotProtectionWarning: "Botプロテクションが設定されていません。" +configure: "設定する" +postToGallery: "ギャラリーへ投稿" +gallery: "ギャラリー" +recentPosts: "最近の投稿" +popularPosts: "人気の投稿" +shareWithNote: "投稿で共有" +ads: "広告" +expiration: "期限" +memo: "メモ" +priority: "優先度" +high: "高" +middle: "中" +low: "低" +emailNotConfiguredWarning: "メールアドレスの設定がされていません。" +ratio: "比率" +secureMode: "セキュアモード (Authorized Fetch)" +instanceSecurity: "サーバーのセキュリティー" +secureModeInfo: "認証情報の無いリモートサーバーからのリクエストに応えません。" +privateMode: "非公開モード" +privateModeInfo: "有効にすると、許可したサーバーのみからリクエストを受け付けます。" +allowedInstances: "許可されたサーバー" +allowedInstancesDescription: "許可したいサーバーのホストを改行で区切って設定します。非公開モードだけで有効です。" +previewNoteText: "本文をプレビュー" +customCss: "カスタムCSS" +customCssWarn: "この設定は必ず知識のある方が行ってください。不適切な設定を行うとクライアントが正常に使用できなくなる恐れがあります。" +global: "グローバル" +recommended: "推奨" +squareAvatars: "アイコンを四角形で表示" +seperateRenoteQuote: "ブーストと引用のボタンを分ける" +sent: "送信" +received: "受信" +searchResult: "検索結果" +hashtags: "ハッシュタグ" +troubleshooting: "トラブルシューティング" +useBlurEffect: "UIにぼかし効果を使用" +learnMore: "詳しく" +iceshrimpUpdated: "FrozenFriendsYumeが更新されました!" +iceshrimpUpdatedWithVersion: "アップデートしました!バージョン{version}" +whatIsNew: "更新情報を見る" +translate: "翻訳" +translatedFrom: "{x}から翻訳" +accountDeletionInProgress: "アカウントの削除が進行中です" +usernameInfo: "サーバー上であなたのアカウントを一意に識別するための名前です。アルファベット(a~z, A~Z)、数字(0~9)、およびアンダーバー(_)が使用できます。ユーザー名は後から変更できません。" +aiChanMode: "藍モード(クラシックUI)" +enterSendsMessage: "メッセージングでReturnキーを押すと、メッセージが送信されます(デフォルトはCtrl + Returnです)" +keepCw: "CWを維持する" +pubSub: "Pub/Subのアカウント" +lastCommunication: "直近の通信" +resolved: "解決済み" +unresolved: "未解決" +breakFollow: "フォロワーを解除" +breakFollowConfirm: "フォロワーから削除しますか?" +itsOn: "オンになっています" +itsOff: "オフになっています" +emailRequiredForSignup: "アカウント登録にメールアドレスを必須にする" +unread: "未読" +filter: "フィルタ" +controlPanel: "コントロールパネル" +manageAccounts: "アカウントを管理" +makeReactionsPublic: "リアクション一覧を公開する" +makeReactionsPublicDescription: "あなたがしたリアクション一覧を誰でも見れるようにします。" +classic: "中央寄せ" +muteThread: "スレッドをミュート" +unmuteThread: "スレッドのミュートを解除" +ffVisibility: "つながりの公開範囲" +ffVisibilityDescription: "自分のフォロー/フォロワー情報の公開範囲を設定できます。" +continueThread: "さらにスレッドを見る" +deleteAccountConfirm: "アカウントが削除されます。よろしいですか?" +incorrectPassword: "パスワードが間違っています。" +voteConfirm: "「{choice}」に投票しますか?" +hide: "隠す" +leaveGroup: "グループから抜ける" +leaveGroupConfirm: "「{name}」から抜けますか?" +useDrawerReactionPickerForMobile: "モバイルデバイスのときドロワーで表示" +clickToFinishEmailVerification: "[{ok}]を押して、メールアドレスの確認を完了してください。" +overridedDeviceKind: "デバイスタイプ" +smartphone: "スマートフォン" +tablet: "タブレット" +auto: "自動" +showLocalPosts: "ローカルの投稿を表示する場所" +homeTimeline: "ホームタイムライン" +socialTimeline: "ソーシャルタイムライン" +themeColor: "テーマカラー" +size: "サイズ" +numberOfColumn: "列の数" +searchByGoogle: "検索" +instanceDefaultLightTheme: "サーバーの標準ライトテーマ" +instanceDefaultDarkTheme: "サーバーの標準ダークテーマ" +instanceDefaultThemeDescription: "JSON形式のテーマコードを記入します。" +mutePeriod: "ミュートする期限" +indefinitely: "無期限" +tenMinutes: "10分" +oneHour: "1時間" +oneDay: "1日" +oneWeek: "1週間" +reflectMayTakeTime: "反映されるまで時間がかかる場合があります。" +failedToFetchAccountInformation: "アカウント情報の取得に失敗しました" +rateLimitExceeded: "レート制限を超えました" +cropImage: "画像のクロップ" +cropImageAsk: "画像をクロップしますか?" +file: "ファイル" +recentNHours: "直近{n}時間" +recentNDays: "直近{n}日" +noEmailServerWarning: "メールサーバーの設定がされていません。" +thereIsUnresolvedAbuseReportWarning: "未対応の通報があります。" +check: "チェック" +driveCapOverrideLabel: "このユーザーのドライブ容量上限を変更" +driveCapOverrideCaption: "0以下を指定すると解除されます。" +requireAdminForView: "閲覧するには管理者アカウントでログインしている必要があります。" +isSystemAccount: "システムにより自動で作成・管理されているアカウントです。モデレーション・編集・削除を行うとサーバーの動作が不正になる可能性があるため、操作しないでください。" +typeToConfirm: "この操作を行うには {x} と入力してください" +deleteAccount: "アカウント削除" +document: "ドキュメント" +numberOfPageCache: "ページキャッシュ数" +numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。" +logoutConfirm: "ログアウトしますか?" +lastActiveDate: "最終利用日時" +statusbar: "ステータスバー" +pleaseSelect: "選択してください" +reverse: "反転" +colored: "色付き" +refreshInterval: "更新間隔" +label: "ラベル" +type: "タイプ" +speed: "速度" +slow: "遅い" +fast: "速い" +sensitiveMediaDetection: "センシティブなメディアの検出" +localOnly: "ローカルのみ" +remoteOnly: "リモートのみ" +failedToUpload: "アップロード失敗" +cannotUploadBecauseInappropriate: "不適切な内容を含む可能性があると判定されたためアップロードできません。" +cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いためアップロードできません。" +cannotUploadBecauseExceedsFileSizeLimit: "ファイルサイズの制限を超えているためアップロードできません。" +beta: "ベータ" +enableAutoSensitive: "自動閲覧注意フラグ判定" +enableAutoSensitiveDescription: "利用可能な場合は、機械学習を利用して自動でメディアに閲覧注意フラグを設定します。この機能をオフにしても、サーバーによっては自動で設定されることがあります。" +activeEmailValidationDescription: "ユーザーのメールアドレスのバリデーションを、捨てアドかどうかや実際に通信可能かどうかなどを判定しより積極的に行います。オフにすると単に文字列として正しいかどうかのみチェックされます。" +showAds: "広告を表示する" +navbar: "ナビゲーションバー" +shuffle: "シャッフル" +account: "アカウント" +move: "移動" +pushNotification: "プッシュ通知" +subscribePushNotification: "プッシュ通知を有効化" +unsubscribePushNotification: "プッシュ通知を停止する" +pushNotificationAlreadySubscribed: "プッシュ通知は有効です" +pushNotificationNotSupported: "ブラウザまたはサーバーがプッシュ通知に非対応です" +sendPushNotificationReadMessage: "通知やメッセージが既読になったらプッシュ通知を削除する" +sendPushNotificationReadMessageCaption: "「{emptyPushNotificationMessage}」という通知が一瞬表示されるようになります。端末の電池消費量が増加する可能性があります。" +adminCustomCssWarn: "この設定は、それが何をするものであるかを知っている場合のみ使用してください。不適切な値を入力すると、クライアントが正常に動作しなくなる可能性があります。ユーザー設定でCSSをテストし、正しく動作することを確認してください。" +customMOTD: "カスタムMOTD(スプラッシュスクリーンメッセージ)" +customMOTDDescription: "ユーザがページをロード/リロードするたびにランダムに表示される、改行で区切られたMOTD(スプラッシュスクリーン)用のカスタムメッセージ" +customSplashIcons: "カスタムスプラッシュスクリーンアイコン" +customSplashIconsDescription: "ユーザがページをロード/リロードするたびにランダムに表示される、改行で区切られたカスタムスプラッシュスクリーンアイコンの + URL。画像は静的なURLで、できればすべて192x192にリサイズしてください。" +showUpdates: "FrozenFriendsYumeの更新時にポップアップを表示する" +recommendedInstances: "おすすめサーバー" +recommendedInstancesDescription: "おすすめタイムラインに表示するサーバーを改行区切りで入力してください。" +caption: "自動キャプション" +splash: "スプラッシュスクリーン" +updateAvailable: "アップデートがありますよ!" +swipeOnDesktop: "デスクトップでモバイルスタイルのスワイプを可能にする" +logoImageUrl: "ロゴのURL" +showAdminUpdates: "新しいFrozenFriendsYumeのバージョンが利用可能なときに通知する(管理者のみ)" +replayTutorial: "もう一度チュートリアルを見る" +migration: "アカウントの引っ越し" +moveTo: "このアカウントを新しいアカウントに引っ越す" +moveToLabel: "引っ越し先のアカウント:" +moveAccount: "引っ越し実行!" +moveAccountDescription: "この操作は取り消せません。まずは引っ越し先のアカウントでこのアカウントに対しエイリアスを作成したことを確認してください。エイリアス作成後、引っ越し先のアカウントをこのように入力してください:@person@server.com" +moveFrom: "別のアカウントからこのアカウントに引っ越す" +moveFromLabel: "引っ越し元のアカウント:" +moveFromDescription: "別のアカウントからこのアカウントにフォロワーを引き継いで引っ越したい場合、ここでエイリアスを作成しておく必要があります。必ず引っ越しを実行する前に作成してください!引っ越し元のアカウントをこのように入力してください:@person@server.com" +migrationConfirm: "本当にこのアカウントを {account} に引っ越しますか?一度引っ越しを行うと取り消せず、二度とこのアカウントを元の状態で使用できなくなります。\n\ + この操作を行う前に引っ越し先のアカウントでエイリアスを作成する必要があります。エイリアスが作成されているか、必ず確認してください。" +defaultReaction: "リモートとローカルの投稿に対するデフォルトの絵文字リアクション" +license: "ライセンス" +indexPosts: "投稿をインデックス" +indexFrom: "この投稿ID以降をインデックスする" +indexFromDescription: "空白で全ての投稿を指定します" +indexNotice: "インデックスを開始しました。完了まで時間がかかる場合があるため、少なくとも1時間はサーバーを再起動しないでください。" +customKaTeXMacro: "カスタムKaTeXマクロ" +customKaTeXMacroDescription: "数式入力を楽にするためのマクロを設定しましょう!記法はLaTeXにおけるコマンドの定義と同様に \\newcommand{\\ + name}{content} または \\newcommand{\\add}[2]{#1 + #2} のように記述します。後者の例では \\add{3}{foo} + が 3 + foo に展開されます。また、マクロの名前を囲む波括弧を丸括弧 () および角括弧 [] に変更した場合、マクロの引数に使用する括弧が変更されます。マクロの定義は一行に一つのみで、途中で改行はできません。マクロの定義が無効な行は無視されます。文字列を単純に置換する機能のみに対応していて、条件分岐などの高度な構文は使用できません。" +enableCustomKaTeXMacro: "カスタムKaTeXマクロを有効にする" +preventAiLearning: "AIによる学習を防止" +preventAiLearningDescription: "投稿したノート、添付した画像などのコンテンツを学習の対象にしないようAIに要求します。これはnoaiフラグをHTMLレスポンスに含めることによって実現されます。" +noGraze: "ブラウザの拡張機能「Graze for Mastodon」は、FrozenFriendsYumeの動作を妨げるため、無効にしてください。" +enableServerMachineStats: "サーバーのマシン情報を公開する" +enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする" +showPopup: "ポップアップを表示してユーザーに知らせる" +showWithSparkles: "タイトルをキラキラさせる" +youHaveUnreadAnnouncements: "未読のお知らせがあります" +neverShow: "今後表示しない" +remindMeLater: "また後で" + +_sensitiveMediaDetection: + description: "機械学習を使って自動でセンシティブなメディアを検出し、モデレーションに役立てられます。サーバーの負荷が少し増えます。" + sensitivity: "検出感度" + sensitivityDescription: "感度を低くすると、誤検知(偽陽性)が減ります。感度を高くすると、検知漏れ(偽陰性)が減ります。" + setSensitiveFlagAutomatically: "閲覧注意(NSFW)フラグを設定する" + setSensitiveFlagAutomaticallyDescription: "この設定をオフにしても内部的に判定結果は保持されます。" + analyzeVideos: "動画の解析を有効化" + analyzeVideosDescription: "静止画に加えて動画も解析するようにします。サーバーの負荷が少し増えます。" +_emailUnavailable: + used: "既に使用されています" + format: "形式が正しくありません" + disposable: "恒久的に使用可能なアドレスではありません" + mx: "正しいメールサーバーではありません" + smtp: "メールサーバーが応答しません" +_ffVisibility: + public: "公開" + followers: "フォロワーだけに公開" + private: "非公開" +_signup: + almostThere: "ほとんど完了です" + emailAddressInfo: "あなたが使っているメールアドレスを入力してください。メールアドレスが公開されることはありません。" + emailSent: "入力されたメールアドレス({email})宛に確認のメールが送信されました。メールに記載されたリンクにアクセスすると、アカウントの作成が完了します。" +_accountDelete: + accountDelete: "アカウントの削除" + mayTakeTime: "アカウントの削除は負荷のかかる処理であるため、作成したコンテンツの数やアップロードしたファイルの数が多いと完了までに時間がかかることがあります。" + sendEmail: "アカウントの削除が完了した際に、登録されていたメールアドレス宛に通知を送信します。" + requestAccountDelete: "アカウント削除をリクエスト" + started: "削除処理を開始しました。" + inProgress: "アカウント削除が進行中" +_ad: + back: "戻る" + reduceFrequencyOfThisAd: "この広告の表示頻度を下げる" +_forgotPassword: + enterEmail: "アカウントに登録したメールアドレスを入力してください。そのアドレス宛てに、パスワードリセット用のリンクが送信されます。" + ifNoEmail: "メールアドレスを登録していない場合は、管理者までお問い合わせください。" + contactAdmin: "このインスタンスではメールアドレスの登録がサポートされていないため、パスワードリセットを行う場合は管理者までお問い合わせください。" +_gallery: + my: "自分の投稿" + liked: "いいねした投稿" + like: "いいね!" + unlike: "いいね解除" +_email: + _follow: + title: "フォローされました" + _receiveFollowRequest: + title: "フォローリクエストを受け取りました" +_plugin: + install: "プラグインのインストール" + installWarn: "信頼できないプラグインはインストールしないでください。" + manage: "プラグインの管理" +_preferencesBackups: + list: "作成したバックアップ" + saveNew: "新規保存" + loadFile: "ファイルを読み込み" + apply: "このデバイスに適用" + save: "上書き保存" + inputName: "バックアップ名を入力" + cannotSave: "保存できません" + nameAlreadyExists: "バックアップ名「{name}」は既に存在します。違う名前を指定してください。" + applyConfirm: "バックアップ「{name}」を現在のデバイスに適用しますか?現在のデバイス設定は失われます。" + saveConfirm: "{name}に上書き保存しますか?" + deleteConfirm: "{name}を削除しますか?" + renameConfirm: "「{old}」を「{new}」に変更しますか?" + noBackups: "バックアップはありません。「新規保存」で現在のクライアント設定をサーバーに保存できます。" + createdAt: "作成日時: {date} {time}" + updatedAt: "更新日時: {date} {time}" + cannotLoad: "読み込めません。" + invalidFile: "ファイル形式が違います。" + delete: バックアップを削除 +_registry: + scope: "スコープ" + key: "キー" + keys: "キー" + domain: "ドメイン" + createKey: "キーを作成" +_aboutFrozenFriendsYume: + about: "FrozenFriendsYumeは、2023年に生まれた新しいFirefish & Misskeyのforkです。" + contributors: "主なコントリビューター" + allContributors: "全てのコントリビューター" + source: "FrozenFriendsYumeの開発" + translation: "翻訳" + donate: "FrozenFriendsYumeに寄付" + morePatrons: "他にも多くの方が支援してくれています。ありがとうございます! 🥰" + patrons: "支援者" + patronsList: 寄付額ではなく時系列順に並んでいます。上記のリンクから寄付を行ってここにあなたのIDを載せましょう! + pleaseDonateToFrozenFriendsYume: FrozenFriendsYume開発への寄付をご検討ください。 + pleaseDonateToHost: また、このサーバー {host} の運営者への寄付もご検討ください。 + donateHost: '{host} に寄付する' + donateTitle: FrozenFriendsYumeを気に入りましたか? + documentation: ドキュメント + chatroom: チャットルーム + changelog: 変更履歴 + sponsors: FrozenFriendsYumeの支援者 +_nsfw: + respect: "閲覧注意のメディアは隠す" + ignore: "閲覧注意のメディアを隠さない" + force: "常にメディアを隠す" +_mfm: + cheatSheet: "MFMチートシート" + intro: "MFMは、FrozenFriendsYumeやMisskey、Akkomaなどの投稿とチャットで使用できるマークアップ言語です。ここでは、MFMで使用可能な構文一覧が確認できます。" + dummy: "FrozenFriendsYumeでFediverseの世界が広がります" + mention: "メンション" + mentionDescription: "アットマーク + ユーザー名で、特定のユーザーを示せます。" + hashtag: "ハッシュタグ" + hashtagDescription: "ナンバーサイン + タグで、ハッシュタグを示せます。" + url: "URL" + urlDescription: "URLを表示できます。" + link: "リンク" + linkDescription: "文章の特定の範囲を、URLに紐づけられます。" + bold: "太字" + boldDescription: "文字を太く表示して強調できます。" + small: "目立たなく" + smallDescription: "内容を小さく・薄く表示させられます。" + center: "中央寄せ" + centerDescription: "内容を中央寄せで表示させられます。" + inlineCode: "コード(インライン)" + inlineCodeDescription: "プログラムなどのコードをインラインでシンタックスハイライトします。" + blockCode: "コード(ブロック)" + blockCodeDescription: "複数行のプログラムなどのコードをブロックでシンタックスハイライトします。" + inlineMath: "数式(インライン)" + inlineMathDescription: "数式(KaTeX)をインラインで表示します。" + blockMath: "数式(ブロック)" + blockMathDescription: "数式(KaTeX)をブロックで表示します。" + quote: "引用" + quoteDescription: "内容が引用であることを示せます。" + emoji: "カスタム絵文字" + emojiDescription: "コロンでカスタム絵文字名を囲むと、カスタム絵文字を表示させられます。" + search: "検索" + searchDescription: "検索ボックスを表示させられます。" + flip: "反転" + flipDescription: "内容を上下または左右に反転させます。" + jelly: "アニメーション(びよんびよん)" + jellyDescription: "びよんびよんするアニメーションを与えます。" + tada: "アニメーション(じゃーん)" + tadaDescription: "ジャーン!という感じのアニメーションを与えます。" + jump: "アニメーション(ジャンプ)" + jumpDescription: "飛び跳ねるようなアニメーションを与えます。" + bounce: "アニメーション(バウンド)" + bounceDescription: "ぽよんぽよん弾むようなアニメーションを与えます。" + shake: "アニメーション(ぶるぶる)" + shakeDescription: "ぶるぶる震えるアニメーションを与えます。" + twitch: "アニメーション(ブレ)" + twitchDescription: "激しくブレるアニメーションを与えます。" + spin: "アニメーション(回転)" + spinDescription: "回転するアニメーションを与えます。" + x2: "大きく" + x2Description: "内容を大きく表示します。" + x3: "とても大きく" + x3Description: "内容をとても大きく表示します。" + x4: "究極に大きく" + x4Description: "内容を究極に大きく表示します。" + blur: "ぼかし" + blurDescription: "内容をぼかすことができます。ポインターを上に乗せるとはっきり見えるようになります。" + font: "フォント" + fontDescription: "内容のフォントを指定できます。" + rainbow: "レインボー" + rainbowDescription: "内容をレインボーにします。" + sparkle: "キラキラ" + sparkleDescription: "キラキラしたパーティクルのエフェクトを追加します。" + rotate: "回転" + rotateDescription: "指定した角度で回転させます。" + plain: "プレーン" + plainDescription: "内側の構文を全て無効にします。" + position: 位置 + stop: MFMを停止 + alwaysPlay: MFMアニメーションを自動再生する + play: MFMを再生 + warn: MFMアニメーションは激しい動きを含む可能性があります。 + positionDescription: 位置を指定した値だけずらします。 + foreground: 文字色 + backgroundDescription: 背景の色を変更します。 + background: 背景色 + scale: 拡大・縮小 + scaleDescription: 大きさを指定した値に拡大・縮小します。 + foregroundDescription: 文字の色を変更します。 + fade: フェード + fadeDescription: フェードインとフェードアウトする。 + crop: 切り抜き + cropDescription: 内容を切り抜く。 + advancedDescription: オフにすると、アニメーション再生中を除いて基本的なMFMだけ表示します。 + advanced: 高度なMFM +_instanceTicker: + none: "表示しない" + remote: "リモートユーザーに表示" + always: "常に表示" +_serverDisconnectedBehavior: + reload: "自動でリロード" + dialog: "ダイアログで警告" + quiet: "控えめに警告" + nothing: "何もしない" +_channel: + create: "チャンネルを作成" + edit: "チャンネルを編集" + setBanner: "バナーを設定" + removeBanner: "バナーを削除" + featured: "トレンド" + owned: "管理中" + following: "フォロー中" + usersCount: "{n}人が参加中" + notesCount: "{n}投稿があります" + nameAndDescription: "名前と説明" + nameOnly: "名前のみ" +_messaging: + dms: "プライベート" + groups: "グループ" +_menuDisplay: + sideFull: "横" + sideIcon: "横(アイコン)" + top: "上部" + hide: "隠す" +_wordMute: + muteWords: "ミュートするワード" + muteWordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。" + muteWordsDescription2: "キーワードをスラッシュで囲むと正規表現になります。" + softDescription: "指定した条件の投稿をタイムラインから隠します。" + hardDescription: "指定した条件の投稿をタイムラインに追加しないようにします。追加されなかった投稿は、条件を変更しても除外されたままになります。" + soft: "ソフト" + hard: "ハード" + mutedNotes: "ミュートされた投稿" +_instanceMute: + instanceMuteDescription: "ミュートしたサーバーのユーザーへの返信を含めて、設定したサーバーの全ての投稿とブーストをミュートします。" + instanceMuteDescription2: "改行で区切って設定します" + title: "設定したサーバーの投稿を隠します。" + heading: "ミュートするサーバー" +_theme: + explore: "テーマを探す" + install: "テーマのインストール" + manage: "テーマの管理" + code: "テーマコード" + description: "説明" + installed: "{name}をインストールしました" + installedThemes: "インストールされたテーマ" + builtinThemes: "標準のテーマ" + alreadyInstalled: "そのテーマは既にインストールされています" + invalid: "テーマの形式が間違っています" + make: "テーマを作る" + base: "ベース" + addConstant: "定数を追加" + constant: "定数" + defaultValue: "デフォルト値" + color: "色" + refProp: "プロパティを参照" + refConst: "定数を参照" + key: "キー" + func: "関数" + funcKind: "関数の種類" + argument: "引数" + basedProp: "元にするプロパティの名前" + alpha: "不透明度" + darken: "暗さ" + lighten: "明るさ" + inputConstantName: "定数名を入力してください" + importInfo: "ここにテーマコードを貼り付けて、エディターにインポートできます" + deleteConstantConfirm: "定数 {const} を削除しても良いですか?" + keys: + accent: "アクセント" + bg: "背景" + fg: "文字" + focus: "フォーカス" + indicator: "インジケーター" + panel: "パネル" + shadow: "影" + header: "ヘッダー" + navBg: "サイドバーの背景" + navFg: "サイドバーの文字" + navHoverFg: "サイドバー文字(ホバー)" + navActive: "サイドバー文字(アクティブ)" + navIndicator: "サイドバーのインジケーター" + link: "リンク" + hashtag: "ハッシュタグ" + mention: "メンション" + mentionMe: "あなた宛てメンション" + renote: "ブースト" + modalBg: "モーダルの背景" + divider: "分割線" + scrollbarHandle: "スクロールバーの取っ手" + scrollbarHandleHover: "スクロールバーの取っ手(ホバー)" + dateLabelFg: "日付ラベルの文字" + infoBg: "情報の背景" + infoFg: "情報の文字" + infoWarnBg: "警告の背景" + infoWarnFg: "警告の文字" + cwBg: "CW ボタンの背景" + cwFg: "CW ボタンの文字" + cwHoverBg: "CW ボタンの背景 (ホバー)" + toastBg: "通知トーストの背景" + toastFg: "通知トーストの文字" + buttonBg: "ボタンの背景" + buttonHoverBg: "ボタンの背景 (ホバー)" + inputBorder: "入力ボックスの縁取り" + listItemHoverBg: "リスト項目の背景 (ホバー)" + driveFolderBg: "ドライブフォルダーの背景" + wallpaperOverlay: "壁紙のオーバーレイ" + badge: "バッジ" + messageBg: "チャットの背景" + accentDarken: "アクセント (暗め)" + accentLighten: "アクセント (明るめ)" + fgHighlighted: "強調された文字" +_sfx: + note: "投稿" + noteMy: "投稿(自分)" + notification: "通知" + chat: "チャット" + chatBg: "チャット(バックグラウンド)" + antenna: "アンテナ受信" + channel: "チャンネル通知" +_ago: + future: "未来" + justNow: "たった今" + secondsAgo: "{n}秒前" + minutesAgo: "{n}分{n2}秒前" + hoursAgo: "{n}時間{n2}分前" + daysAgo: "{n}日{n2}時間前" + weeksAgo: "{n}週間{n2}日前" + monthsAgo: "{n}ヶ月{n2}週間前" + yearsAgo: "{n}年{n2}ヶ月前" +_time: + second: "秒" + minute: "分" + hour: "時間" + day: "日" +_tutorial: + title: "FrozenFriendsYumeの使い方" + step1_1: "ようこそ!" + step1_2: "使い始める前に、いくつか設定を済ませましょう。すぐできますよ!" + step2_1: "最初に、あなたのプロフィールを作りましょう。" + step2_2: "プロフィールを設定することで、他の人があなたの投稿を見たり、フォローしたりするときの助けになります。" + step3_1: "それでは、何人かフォローしてみましょう!" + step3_2: "あなたのホームとソーシャルタイムラインは、あなたが誰をフォローしているかで決まります。まずは、いくつかのアカウントをフォローしてみましょう。\n\ + プロフィールの右上にある丸い+ボタンをクリックするとフォローできます。" + step4_1: "投稿してみましょう!" + step4_2: "最初は{introduction}に投稿したり、シンプルに「こんにちは、アカウント作ってみました!」などの投稿をする人もいます。" + step5_1: "タイムライン、タイムラインだらけ!" + step5_2: "あなたのサーバーでは{timelines}種類のタイムラインが有効になっています。" + step5_3: "ホーム{icon}タイムラインでは、あなたがフォローしているアカウントの投稿を見られます。" + step5_4: "ローカル{icon}タイムラインでは、このサーバーにいるみんなの投稿を見られます。" + step5_5: "ソーシャル{icon}タイムラインでは、ホームタイムラインとローカルタイムラインの投稿が両方表示されます。" + step5_6: "おすすめ{icon}タイムラインでは、管理人がおすすめするサーバーの投稿を見られます。" + step5_7: "グローバル{icon}タイムラインでは、接続している他のすべてのサーバーからの投稿を見られます。" + step6_1: "じゃあ、ここはどんな場所なの?" + step6_2: "実は、あなたはただFrozenFriendsYumeに参加しただけではありません。ここは、何千もの相互接続されたサーバーが構成する Fediverse への入口です。" + step6_3: "それぞれのサーバーでは必ずしもFrozenFriendsYumeが使われているわけではなく、異なる動作をするサーバーもあります。しかし、あなたは他のサーバーのアカウントもフォローしたり、返信・ブーストができます。一見難しそうですが大丈夫!すぐ慣れます。" + step6_4: "これで完了です。お楽しみください!" +_2fa: + alreadyRegistered: "既に設定は完了しています。" + registerTOTP: "認証アプリの設定を開始" + step1: "まず、{a}や{b}などの認証アプリをお使いのデバイスにインストールします。" + step2: "次に、表示されているQRコードをアプリでスキャンします。" + step2Click: "QRコードをクリックすると、お使いの端末にインストールされている認証アプリやキーリングに登録できます。" + step2Url: "デスクトップアプリでは次のURIを入力します:" + step3Title: "確認コードを入力" + step3: "アプリに表示されている確認コード(トークン)を入力して完了です。" + step4: "これからログインするときも、同じように確認コードを入力します。" + securityKeyNotSupported: "お使いのブラウザはセキュリティキーに対応していません。" + registerTOTPBeforeKey: "セキュリティキー・パスキーを登録するには、まず認証アプリの設定を行なってください。" + securityKeyInfo: "FIDO2をサポートするハードウェアセキュリティキー、端末の生体認証やPINロック、パスキーといった、WebAuthn由来の鍵を登録します。" + chromePasskeyNotSupported: "Chromeのパスキーは現在サポートしていません。" + registerSecurityKey: "セキュリティキー・パスキーを登録する" + securityKeyName: "キーの名前を入力" + tapSecurityKey: "ブラウザの指示に従い、セキュリティキーやパスキーを登録してください" + removeKey: "セキュリティキーを削除" + removeKeyConfirm: "{name}を削除しますか?" + whyTOTPOnlyRenew: "セキュリティキーが登録されている場合、認証アプリの設定は解除できません。" + renewTOTP: "認証アプリを再設定" + renewTOTPConfirm: "今までの認証アプリの確認コードは使用できなくなります" + renewTOTPOk: "再設定する" + renewTOTPCancel: "やめておく" + token: "多要素認証トークン" +_permissions: + "read:account": "アカウントの情報を見る" + "write:account": "アカウントの情報を変更する" + "read:blocks": "ブロックを見る" + "write:blocks": "ブロックを操作する" + "read:drive": "ドライブを見る" + "write:drive": "ドライブを操作する" + "read:favorites": "お気に入りを見る" + "write:favorites": "お気に入りを操作する" + "read:following": "フォローの情報を見る" + "write:following": "フォロー・フォロー解除する" + "read:messaging": "チャットを見る" + "write:messaging": "チャットを操作する" + "read:mutes": "ミュートを見る" + "write:mutes": "ミュートを操作する" + "write:notes": "投稿を作成・削除する" + "read:notifications": "通知を見る" + "write:notifications": "通知を操作する" + "read:reactions": "リアクションを見る" + "write:reactions": "リアクションを操作する" + "write:votes": "投票する" + "read:pages": "ページを見る" + "write:pages": "ページを操作する" + "read:page-likes": "ページのいいねを見る" + "write:page-likes": "ページのいいねを操作する" + "read:user-groups": "ユーザーグループを見る" + "write:user-groups": "ユーザーグループを操作する" + "read:channels": "チャンネルを見る" + "write:channels": "チャンネルを操作する" + "read:gallery": "ギャラリーを見る" + "write:gallery": "ギャラリーを操作する" + "read:gallery-likes": "ギャラリーのいいねを見る" + "write:gallery-likes": "ギャラリーのいいねを操作する" +_auth: + shareAccess: "「{name}」がアカウントにアクセスすることを許可しますか?" + shareAccessAsk: "アカウントへのアクセスを許可しますか?" + permissionAsk: "このアプリケーションは次の権限を要求しています:" + pleaseGoBack: "アプリケーションに戻り続行してください" + callback: "アプリケーションに戻っています" + denied: "アクセスを拒否しました" + copyAsk: "以下の認証コードをアプリケーションにコピーしてください:" + allPermissions: 全てのアクセス権 +_antennaSources: + all: "全ての投稿" + homeTimeline: "フォローしているユーザーの投稿" + users: "指定した一人または複数のユーザーの投稿" + userList: "指定したリストのユーザーの投稿" + userGroup: "指定したグループのユーザーの投稿" + instances: "指定したサーバーの全ユーザーの投稿" +_weekday: + sunday: "日曜日" + monday: "月曜日" + tuesday: "火曜日" + wednesday: "水曜日" + thursday: "木曜日" + friday: "金曜日" + saturday: "土曜日" +_widgets: + memo: "付箋" + notifications: "通知" + timeline: "タイムライン" + calendar: "カレンダー" + trends: "トレンド" + clock: "時計" + rss: "RSSリーダー" + rssTicker: "RSSティッカー" + activity: "アクティビティ" + photos: "フォト" + digitalClock: "デジタル時計" + unixClock: "UNIX時計" + federation: "連合" + postForm: "投稿フォーム" + slideshow: "スライドショー" + button: "ボタン" + onlineUsers: "オンラインユーザー" + jobQueue: "ジョブキュー" + serverMetric: "サーバーメトリクス" + aiscript: "AiScriptコンソール" + userList: "ユーザーリスト" + _userList: + chooseList: "リストを選択" + meiliStatus: サーバーステータス + serverInfo: サーバー情報 + meiliSize: インデックスサイズ + meiliIndexCount: インデックス済みの投稿 +_cw: + hide: "隠す" + show: "もっと見る" + chars: "{count}文字" + files: "{count}ファイル" +_poll: + noOnlyOneChoice: "選択肢は最低2つ必要です" + choiceN: "選択肢{n}" + noMore: "これ以上追加できません" + canMultipleVote: "複数回答可" + expiration: "期限" + infinite: "無期限" + at: "日時指定" + after: "経過指定" + deadlineDate: "期日" + deadlineTime: "時間" + duration: "期間" + votesCount: "{n}票" + totalVotes: "計{n}票" + vote: "投票する" + showResult: "結果を見る" + voted: "投票済み" + closed: "終了済み" + remainingDays: "終了まであと{d}日{h}時間" + remainingHours: "終了まであと{h}時間{m}分" + remainingMinutes: "終了まであと{m}分{s}秒" + remainingSeconds: "終了まであと{s}秒" +_visibility: + public: "公開" + publicDescription: "全ての公開タイムラインに配信されます" + home: "未収載" + homeDescription: "ホームタイムラインのみに公開" + followers: "フォロワー" + followersDescription: "フォロワーと会話相手のみに公開" + specified: "ダイレクト" + specifiedDescription: "指定したユーザーのみに公開" + localOnly: "ローカルのみ" + localOnlyDescription: "リモートユーザーには非公開" +_postForm: + replyPlaceholder: "この投稿に返信…" + quotePlaceholder: "この投稿を引用…" + channelPlaceholder: "チャンネルに投稿…" + _placeholders: + a: "いまどうしてる?" + b: "何かありましたか?" + c: "何をお考えですか?" + d: "言いたいことは?" + e: "ここに書いてください" + f: "あなたが書くのを待っています…" +_profile: + name: "名前" + username: "ユーザー名" + description: "自己紹介" + youCanIncludeHashtags: "ハッシュタグを含められます。" + metadata: "追加情報" + metadataEdit: "追加情報を編集" + metadataDescription: "プロフィールに表として追加情報を表示できます。{a}タグまたは{l}タグを{rel}とともに追加すると、プロフィールのリンクを確認できます。" + metadataLabel: "ラベル" + metadataContent: "内容" + changeAvatar: "アバター画像を変更" + changeBanner: "バナー画像を変更" + locationDescription: "英語表記の都市名から始まる内容を入力すると、現地時間がユーザーページに表示されます。" +_exportOrImport: + allNotes: "全ての投稿" + followingList: "フォロー" + muteList: "ミュート" + blockingList: "ブロック" + userLists: "リスト" + excludeMutingUsers: "ミュートしているユーザーを除外" + excludeInactiveUsers: "使われていないアカウントを除外" +_charts: + federation: "連合" + apRequest: "リクエスト" + usersIncDec: "ユーザーの増減" + usersTotal: "ユーザーの合計" + activeUsers: "アクティブユーザー数" + notesIncDec: "投稿の増減" + localNotesIncDec: "ローカルの投稿の増減" + remoteNotesIncDec: "リモートの投稿の増減" + notesTotal: "投稿の合計" + filesIncDec: "ファイルの増減" + filesTotal: "ファイルの合計" + storageUsageIncDec: "ストレージ使用量の増減" + storageUsageTotal: "ストレージ使用量の合計" +_instanceCharts: + requests: "リクエスト" + users: "ユーザーの増減" + usersTotal: "ユーザーの累積" + notes: "投稿の増減" + notesTotal: "投稿の累積" + ff: "フォロー/フォロワーの増減" + ffTotal: "フォロー/フォロワーの累積" + cacheSize: "キャッシュサイズの増減" + cacheSizeTotal: "キャッシュサイズの累積" + files: "ファイル数の増減" + filesTotal: "ファイル数の累積" +_timelines: + home: "ホーム" + local: "ローカル" + recommended: "おすすめ" + social: "ソーシャル" + global: "グローバル" +_pages: + newPage: "ページの作成" + editPage: "ページの編集" + readPage: "ソースを表示中" + created: "ページを作成しました" + updated: "ページを更新しました" + deleted: "ページを削除しました" + pageSetting: "ページ設定" + nameAlreadyExists: "指定されたページURLは既に存在しています" + invalidNameTitle: "不正なページURLです" + invalidNameText: "空白でないか確認してください" + editThisPage: "このページを編集" + viewSource: "ソースを表示" + viewPage: "ページを見る" + like: "いいね" + unlike: "いいね解除" + my: "自分のページ" + liked: "いいねしたページ" + featured: "人気" + inspector: "インスペクター" + contents: "コンテンツ" + content: "ページブロック" + variables: "変数" + title: "タイトル" + url: "ページURL" + summary: "ページの要約" + alignCenter: "中央寄せ" + hideTitleWhenPinned: "ピン留めされているときにタイトルを非表示" + font: "フォント" + fontSerif: "セリフ" + fontSansSerif: "サンセリフ" + eyeCatchingImageSet: "アイキャッチ画像を設定" + eyeCatchingImageRemove: "アイキャッチ画像を削除" + chooseBlock: "ブロックを追加" + selectType: "種類を選択" + enterVariableName: "変数名を決めてください" + variableNameIsAlreadyUsed: "その変数名は既に使われています" + contentBlocks: "コンテンツ" + inputBlocks: "入力" + specialBlocks: "特殊" + blocks: + text: "テキスト" + textarea: "テキストエリア" + section: "セクション" + image: "画像" + button: "ボタン" + if: "もし" + _if: + variable: "変数" + post: "投稿フォーム" + _post: + text: "内容" + attachCanvasImage: "キャンバスの画像を添付する" + canvasId: "キャンバスID" + textInput: "テキスト入力" + _textInput: + name: "変数名" + text: "タイトル" + default: "デフォルト値" + textareaInput: "複数行テキスト入力" + _textareaInput: + name: "変数名" + text: "タイトル" + default: "デフォルト値" + numberInput: "数値入力" + _numberInput: + name: "変数名" + text: "タイトル" + default: "デフォルト値" + canvas: "キャンバス" + _canvas: + id: "キャンバスID" + width: "幅" + height: "高さ" + note: "投稿の埋め込み" + _note: + id: "投稿のID" + idDescription: "投稿のURLをペーストして設定することもできます。" + detailed: "詳細な表示" + switch: "スイッチ" + _switch: + name: "変数名" + text: "タイトル" + default: "デフォルト値" + counter: "カウンター" + _counter: + name: "変数名" + text: "タイトル" + inc: "増加値" + _button: + text: "タイトル" + colored: "色付き" + action: "ボタンを押したときの動作" + _action: + dialog: "ダイアログを表示する" + _dialog: + content: "内容" + resetRandom: "乱数をリセット" + pushEvent: "イベントを送信させる" + _pushEvent: + event: "イベント名" + message: "押したときに表示するメッセージ" + variable: "送信する変数" + no-variable: "なし" + callAiScript: "AiScript呼び出し" + _callAiScript: + functionName: "関数名" + radioButton: "選択肢" + _radioButton: + name: "変数名" + title: "タイトル" + values: "改行で区切った選択肢" + default: "デフォルト値" + script: + categories: + flow: "制御" + logical: "論理演算" + operation: "計算" + comparison: "比較" + random: "ランダム" + value: "値" + fn: "関数" + text: "テキスト操作" + convert: "変換" + list: "リスト" + blocks: + text: "テキスト" + multiLineText: "テキスト(複数行)" + textList: "テキストのリスト" + _textList: + info: "ひとつひとつを改行で区切ってください" + strLen: "テキストの長さ" + _strLen: + arg1: "テキスト" + strPick: "文字取り出し" + _strPick: + arg1: "テキスト" + arg2: "文字の位置" + strReplace: "テキスト置き換え" + _strReplace: + arg1: "テキスト" + arg2: "置き換え前" + arg3: "置き換え後" + strReverse: "テキストを反転" + _strReverse: + arg1: "テキスト" + join: "テキストを連結" + _join: + arg1: "リスト" + arg2: "区切り" + add: "足す" + _add: + arg1: "A" + arg2: "B" + subtract: "引く" + _subtract: + arg1: "A" + arg2: "B" + multiply: "掛ける" + _multiply: + arg1: "A" + arg2: "B" + divide: "割る" + _divide: + arg1: "A" + arg2: "B" + mod: "割った余り" + _mod: + arg1: "A" + arg2: "B" + round: "小数を丸める" + _round: + arg1: "数値" + eq: "AとBが同じ" + _eq: + arg1: "A" + arg2: "B" + notEq: "AとBが異なる" + _notEq: + arg1: "A" + arg2: "B" + and: "AかつB" + _and: + arg1: "A" + arg2: "B" + or: "AまたはB" + _or: + arg1: "A" + arg2: "B" + lt: "< AがBより小さい" + _lt: + arg1: "A" + arg2: "B" + gt: "> AがBより大きい" + _gt: + arg1: "A" + arg2: "B" + ltEq: "<= AがBと同じか小さい" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: ">= AがBと同じか大きい" + _gtEq: + arg1: "A" + arg2: "B" + if: "分岐" + _if: + arg1: "もし" + arg2: "なら" + arg3: "そうでなければ" + not: "否定" + _not: + arg1: "否定" + random: "ランダム" + _random: + arg1: "確率" + rannum: "乱数" + _rannum: + arg1: "最小" + arg2: "最大" + randomPick: "リストからランダムに選択" + _randomPick: + arg1: "リスト" + dailyRandom: "ランダム (ユーザーごとに日替わり)" + _dailyRandom: + arg1: "確率" + dailyRannum: "乱数 (ユーザーごとに日替わり)" + _dailyRannum: + arg1: "最小" + arg2: "最大" + dailyRandomPick: "リストからランダムに選択 (ユーザーごとに日替わり)" + _dailyRandomPick: + arg1: "リスト" + seedRandom: "ランダム (シード)" + _seedRandom: + arg1: "シード" + arg2: "確率" + seedRannum: "乱数 (シード)" + _seedRannum: + arg1: "シード" + arg2: "最小" + arg3: "最大" + seedRandomPick: "リストからランダムに選択 (シード)" + _seedRandomPick: + arg1: "シード" + arg2: "リスト" + DRPWPM: "確率付きリストからランダムに選択 (ユーザーごとに日替わり)" + _DRPWPM: + arg1: "テキストのリスト" + pick: "リストから選択" + _pick: + arg1: "リスト" + arg2: "位置" + listLen: "リストの長さを取得" + _listLen: + arg1: "リスト" + number: "数値" + stringToNumber: "テキストを数値に" + _stringToNumber: + arg1: "テキスト" + numberToString: "数値をテキストに" + _numberToString: + arg1: "数値" + splitStrByLine: "テキストを行で分割" + _splitStrByLine: + arg1: "テキスト" + ref: "変数" + aiScriptVar: "AiScript変数" + fn: "関数" + _fn: + slots: "スロット" + slots-info: "スロットひとつひとつを改行で区切ってください" + arg1: "出力" + for: "繰り返し" + _for: + arg1: "回数" + arg2: "処理" + typeError: "スロット{slot}は\"{expect}\"を受け付けますが、\"{actual}\"が入れられています!" + thereIsEmptySlot: "スロット{slot}が空です!" + types: + string: "テキスト" + number: "数値" + boolean: "フラグ" + array: "リスト" + stringArray: "テキストのリスト" + emptySlot: "空のスロット" + enviromentVariables: "環境変数" + pageVariables: "ページ要素" + argVariables: "入力スロット" +_relayStatus: + requesting: "承認待ち" + accepted: "承認済み" + rejected: "拒否済み" +_notification: + fileUploaded: "ファイルがアップロードされました" + youGotMention: "{name}からのメンション" + youGotReply: "{name}からのリプライ" + youGotQuote: "{name}による引用" + youRenoted: "{name}がブーストしました" + youGotPoll: "{name}が投票しました" + youGotMessagingMessageFromUser: "{name}からのチャットがあります" + youGotMessagingMessageFromGroup: "{name}のチャットがあります" + youWereFollowed: "フォローされました" + youReceivedFollowRequest: "フォローリクエストが来ました" + yourFollowRequestAccepted: "フォローリクエストが承認されました" + youWereInvitedToGroup: "{userName}があなたをグループに招待しました" + pollEnded: "アンケートの結果が出ました" + emptyPushNotificationMessage: "プッシュ通知の更新をしました" + _types: + all: "すべて" + follow: "フォロー" + mention: "メンション" + reply: "リプライ" + renote: "ブースト" + quote: "引用" + reaction: "リアクション" + pollVote: "アンケートに投票された" + pollEnded: "アンケートが終了" + receiveFollowRequest: "フォロー申請を受け取った" + followRequestAccepted: "フォローが受理された" + groupInvited: "グループに招待された" + app: "連携アプリからの通知" + _actions: + followBack: "フォローバック" + reply: "返信" + renote: "ブースト" + reacted: がリアクションしました + renoted: がブーストしました + voted: が投票しました +_deck: + alwaysShowMainColumn: "常にメインカラムを表示" + columnAlign: "カラムの寄せ" + addColumn: "カラムを追加" + configureColumn: "カラムの設定" + swapLeft: "左に移動" + swapRight: "右に移動" + swapUp: "上に移動" + swapDown: "下に移動" + stackLeft: "左に重ねる" + popRight: "右に出す" + profile: "ワークスペース" + newProfile: "新規ワークスペース" + renameProfile: "ワークスペース名を変更" + deleteProfile: "ワークスペースを削除" + nameAlreadyExists: "この名前のワークスペースは既に存在します。" + introduction: "カラムを組み合わせて自分だけのインターフェイスを作りましょう!" + introduction2: "画面の右にある + を押して、いつでもカラムを追加できます。" + widgetsIntroduction: "カラムのメニューから、「ウィジェットの編集」を選択してウィジェットを追加してください" + _columns: + main: "メイン" + widgets: "ウィジェット" + notifications: "通知" + tl: "タイムライン" + antenna: "アンテナ" + list: "リスト" + channel: "チャンネル" + mentions: "あなた宛て" + direct: "ダイレクト" +noteId: 投稿のID +hiddenTagsDescription: 'トレンドと「みつける」から除外したいハッシュタグを(先頭の # を除いて)改行区切りで入力してください。この設定はトレンドと「みつける」以外には影響しません。' +hiddenTags: 非表示にするハッシュタグ +apps: "アプリ" +sendModMail: モデレーション通知を送る +deleted: 削除済み +editNote: 投稿を編集 +edited: '編集済み: {date} {time}' +signupsDisabled: + 現在、このサーバーでは新規登録が一般開放されていません。招待コードをお持ちの場合には、以下の欄に入力してください。招待コードをお持ちでない場合にも、新規登録を開放している他のサーバーには入れますよ! +findOtherInstance: 他のサーバーを探す +newer: 新しい投稿 +older: 古い投稿 +accessibility: アクセシビリティ +jumpToPrevious: 前に戻る +cw: 閲覧注意 +silencedWarning: スパムの可能性があるため、これらのユーザーが所属するサーバーは管理者によりサイレンスされています。 +searchPlaceholder: Fediverseを検索 +channelFederationWarn: 現時点では、チャンネルは他のサーバーへ連合しません +listsDesc: リストでは指定したユーザーだけのタイムラインを作れます。リストには「タイムライン」のページからアクセスできます。 +antennasDesc: "アンテナでは指定した条件に合致する投稿が表示されます。\nアンテナには「タイムライン」のページからアクセスできます。" +expandOnNoteClickDesc: オフの場合、右クリックメニューか日付をクリックすることで開けます。 +expandOnNoteClick: クリックで投稿の詳細を開く +clipsDesc: クリップは分類と共有ができるブックマークです。各投稿のメニューからクリップを作成できます。 +_dialog: + charactersExceeded: "最大文字数を超えています! 現在 {current} / 制限 {max}" + charactersBelow: "最小文字数を下回っています! 現在 {current} / 制限 {min}" +_filters: + followersOnly: フォロワーのみ + fromUser: ユーザーを指定 + withFile: 添付ファイルあり + fromDomain: ドメインを指定 + notesBefore: 指定の日付以前 + notesAfter: 指定の日付以降 + followingOnly: フォロー中のみ +isModerator: モデレーター +audio: 音声 +image: 画像 +video: 動画 +isBot: このアカウントはBotです +isLocked: このアカウントのフォローは承認制です +isAdmin: 管理者 +isPatron: FrozenFriendsYume 後援者 +_skinTones: + light: ペールオレンジ + mediumLight: ミディアムライト + medium: ミディアム + mediumDark: ミディアムダーク + yellow: 黄色 + dark: 茶色 +removeReaction: リアクションを取り消す +alt: 代替テキスト +swipeOnMobile: ページ間のスワイプを有効にする +reactionPickerSkinTone: 優先する絵文字のスキン色 +xl: 特大 +donationLink: 寄付ページへのリンク +removeMember: メンバーを削除 +removeQuote: 引用を削除 +removeRecipient: 宛先を削除 +_feeds: + atom: Atom + rss: RSS + jsonFeed: JSONフィード + copyFeed: フィードのURLをコピー +alwaysExpandCws: 閲覧注意投稿を常に展開する +searchEmptyQuery: 検索する言葉を入力してください。 +verifiedLink: 認証済みリンク +minorBadgeKDescription: "Kバッジは、低年齢向けの安全な交流を望むユーザー向けの目印です。K/T/Eの中から設定できるのは1つだけです。" +minorBadgeTDescription: "Tバッジは、ティーン向けの交流範囲を示す目印です。K/T/Eの中から設定できるのは1つだけです。" +minorBadgeEDescription: "Eバッジは、成人向けまたは未成年に見せたくないアカウント向けの目印です。K/Tバッジのユーザーからは表示されません。" +_cwStyle: + modern: モダン + classic: クラシック (Misskey/Foundkey-like) + alternative: 代替(Firefish-like) +cwStyle: 閲覧注意投稿の表示スタイル +searchNotLoggedIn_2: しかし、ハッシュタグ検索とユーザー検索は利用できます。 +bite: 噛みます。 +biteBack: 噛み返した。 +bitYou: '' +bitYourNote: 投稿を噛みました。 diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml new file mode 100644 index 0000000..ea50eb5 --- /dev/null +++ b/locales/ja-KS.yml @@ -0,0 +1,1415 @@ +--- +_lang_: "日本語 (関西弁)" +headlineIceshrimp: "ノートでつながるネットワーク" +introIceshrimp: "ようお越し!Iceshrimpは、オープンソースの分散型マイクロブログサービスやねん。\n「ノート」を作って、いま起こっとることを共有したり、あんたについて皆に発信しよう📡\n「リアクション」機能で、皆のノートに素早く反応を追加したりもできるで✌\nほな新しい世界を探検しよか🚀" +monthAndDay: "{month}月 {day}日" +search: "探す" +notifications: "通知" +username: "ユーザー名" +password: "パスワード" +forgotPassword: "パスワード忘れてん" +fetchingAsApObject: "今ちと連合に照会しとるで" +ok: "OKや" +gotIt: "ほい" +cancel: "やめとく" +enterUsername: "ユーザー名を入れてや" +renotedBy: "{user}がRenote" +noNotes: "ノートはあらへん" +noNotifications: "通知はあらへん" +instance: "インスタンス" +settings: "設定" +basicSettings: "基本設定" +otherSettings: "その他の設定" +openInWindow: "ウィンドウで開くで" +profile: "プロフィール" +timeline: "タイムライン" +noAccountDescription: "自己紹介食ってもた" +login: "ログイン" +loggingIn: "ログインしよるで" +logout: "ログアウト" +signup: "新規登録" +uploading: "アップロードしとるで" +save: "保存" +users: "ユーザー" +addUser: "ユーザーを追加や" +favorite: "お気に入り" +favorites: "お気に入り" +unfavorite: "やっぱ気に入らん" +favorited: "お気に入りに登録したで" +alreadyFavorited: "もうお気に入りに入れとるがな。" +cantFavorite: "アカン、お気に入り登録できへんかったで。" +pin: "ピン留めしとく" +unpin: "やっぱピン留めせん" +copyContent: "内容をコピー" +copyLink: "リンクをコピー" +delete: "ほかす" +deleteAndEdit: "ほかして直す" +deleteAndEditConfirm: "このノートをほかして書き直すんか?このノートへのリアクション、Renote、返信も全部消えてまうで。" +addToList: "リストに入れたる" +sendMessage: "メッセージを送る" +copyUsername: "ユーザー名をコピー" +searchUser: "ユーザーを検索" +reply: "返事" +loadMore: "まだまだあるで!" +showMore: "まだまだあるで!" +showLess: "閉じる" +youGotNewFollower: "フォローされたで" +receiveFollowRequest: "フォローリクエストされたで" +followRequestAccepted: "フォローが承認されたで" +mention: "メンション" +mentions: "うち宛て" +directNotes: "ダイレクト投稿" +importAndExport: "インポートとエクスポート" +import: "インポート" +export: "エクスポート" +files: "ファイル" +download: "ダウンロード" +driveFileDeleteConfirm: "ファイル「{name}」を消してしもうてええか?このファイルを添付したノートも消えてまうで。" +unfollowConfirm: "{name}のフォローを解除してもええんか?" +exportRequested: "エクスポートしてな、ってリクエストしたけど、これ多分めっちゃ時間かかるで。エクスポート終わったら「ドライブ」に突っ込んどくで。" +importRequested: "インポートしてな、ってリクエストしたけど、これ多分めっちゃ時間かかるで。" +lists: "リスト" +noLists: "リストなんてあらへんで" +note: "ノート" +notes: "ノート" +following: "フォロー" +followers: "フォロワー" +followsYou: "フォローされとるで" +createList: "リスト作る" +manageLists: "リストの管理" +error: "エラー" +somethingHappened: "なんかアカンことが起こったで" +retry: "もっぺんやる?" +pageLoadError: "ページの読み込みに失敗してしもうたで…" +pageLoadErrorDescription: "これは普通、ネットワークかブラウザキャッシュが原因やからね。キャッシュをクリアするか、もうちっとだけ待ってくれへんか?" +serverIsDead: "The server is not responding. Please wait for a while before trying again." +youShouldUpgradeClient: "To display this page, please reload and use a new version client. " +enterListName: "リスト名を入れてや" +privacy: "プライバシー" +makeFollowManuallyApprove: "自分が認めた人だけがこのアカウントをフォローできるようにする" +defaultNoteVisibility: "もとからの公開範囲" +follow: "フォロー" +followRequest: "フォローを頼む" +followRequests: "フォロー申請" +unfollow: "フォローやめる" +followRequestPending: "フォロー許してくれるん待っとる" +enterEmoji: "絵文字を入れてや" +renote: "Renote" +unrenote: "Renoteやめる" +renoted: "Renoteしたで。" +cantRenote: "この投稿はRenoteできへんらしい。" +cantReRenote: "Renote自体はRenoteできへんで。" +quote: "引用" +pinnedNote: "ピン留めされとるノート" +pinned: "ピン留めしとく" +you: "あんた" +clickToShow: "押したら見えるで" +sensitive: "ちょっとアカンやつやで" +add: "増やす" +reaction: "リアクション" +reactionSetting: "Reaction that will be displayed in Picker. " +reactionSettingDescription2: "ドラッグで並び替え、クリックで削除、+を押して追加やで。" +rememberNoteVisibility: "公開範囲覚えといて" +attachCancel: "のっけるのやめる" +markAsSensitive: "ちょっとこれはアカン" +unmarkAsSensitive: "そこまでアカンことないやろ" +enterFileName: "ファイル名を入れてや" +mute: "ミュート" +unmute: "ミュートやめたる" +block: "ブロック" +unblock: "ブロックやめたる" +suspend: "凍結" +unsuspend: "溶かす" +blockConfirm: "ブロックしてもええんか?" +unblockConfirm: "ブロックやめたるってほんまか?" +suspendConfirm: "凍結してしもうてええか?" +unsuspendConfirm: "解凍するけどええか?" +selectList: "リストを選ぶ" +selectAntenna: "アンテナを選ぶ" +selectWidget: "ウィジェットを選ぶ" +editWidgets: "ウィジェットをいじる" +editWidgetsExit: "編集終ったで" +customEmojis: "カスタム絵文字" +emoji: "絵文字" +emojis: "絵文字" +emojiName: "絵文字名" +emojiUrl: "絵文字画像URL" +addEmoji: "絵文字を追加" +settingGuide: "ええ感じの設定" +cacheRemoteFiles: "リモートのファイルをキャッシュする" +cacheRemoteFilesDescription: "この設定を切っとくと、リモートファイルをキャッシュせず直リンクするようになるで。サーバーの容量は節約できるけど、サムネイルが作られんくなるから通信量が増えるで。" +flagAsBot: "Botやで" +flagAsBotDescription: "もしこのアカウントがプログラムによって運用されるんやったら、このフラグをオンにしてたのむで。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、Iceshrimpのシステム上での扱いがBotに合ったもんになるんやで。" +flagAsCat: "Catやで" +flagAsCatDescription: "ワレ、猫ちゃんならこのフラグをつけてみ?" +flagShowTimelineReplies: "It will display the reply to the note in the timeline. " +flagShowTimelineRepliesDescription: "It will display the reply to notes other than the user notes in the timeline when you turn it on. " +autoAcceptFollowed: "フォローしとるユーザーからのフォローリクエストを勝手に許可しとく" +addAccount: "アカウントを追加" +loginFailed: "ログインに失敗してしもうた…" +showOnRemote: "リモートで見る" +general: "全般" +wallpaper: "壁紙" +setWallpaper: "壁紙を設定" +removeWallpaper: "壁紙を削除" +searchWith: "検索: {q}" +youHaveNoLists: "リストがあらへんで?" +followConfirm: "{name}をフォローしてええか?" +proxyAccount: "プロキシアカウント" +proxyAccountDescription: "プロキシアカウントは、代わりにフォローしてくれるアカウントや。例えば、551に豚まんが無いときやったり、ユーザーがリモートユーザーをアカウントに入れたとき、リストに入れられたユーザーが誰からもフォローされてないと寂しいやん。寂しいし、アクティビティも配達されへんから、プロキシアカウントがフォローしてくれるで。ええやつやん…" +host: "ホスト" +selectUser: "ユーザーを選ぶ" +recipient: "宛先" +annotation: "注釈" +federation: "連合" +instances: "インスタンス" +registeredAt: "初観測" +latestRequestSentAt: "ちょっと前のリクエスト送信" +latestRequestReceivedAt: "ちょっと前のリクエスト受信" +latestStatus: "ちょっと前のステータス" +storageUsage: "ストレージ使うた量" +charts: "チャート" +perHour: "1時間ごと" +perDay: "1日ごと" +stopActivityDelivery: "アクティビティの配送をやめる" +blockThisInstance: "このインスタンスをブロック" +operations: "操作" +software: "ソフトウェア" +version: "バージョン" +metadata: "メタデータ" +monitor: "モニター" +jobQueue: "ジョブキュー" +cpuAndMemory: "CPUとメモリ" +network: "ネットワーク" +disk: "ディスク" +instanceInfo: "インスタンス情報" +statistics: "統計" +clearQueue: "キューにさいなら" +clearQueueConfirmTitle: "キューをクリアしまっか?" +clearQueueConfirmText: "未配達の投稿は配送されなくなるで。通常この操作を行う必要はあらへんや。" +clearCachedFiles: "キャッシュにさいなら" +clearCachedFilesConfirm: "キャッシュされとるリモートファイルをみんなほかしてええか?" +blockedInstances: "インスタンスブロック" +blockedInstancesDescription: "ブロックしたいインスタンスのホストを改行で区切って設定してな。ブロックされてもうたインスタンスとはもう金輪際やり取りできひんくなるで。" +muteAndBlock: "ミュートとブロック" +mutedUsers: "ミュートしたユーザー" +blockedUsers: "ブロックしたユーザー" +noUsers: "ユーザーはおらへん" +editProfile: "プロフィールをいじる" +noteDeleteConfirm: "このノートを削除しまっか?" +pinLimitExceeded: "これ以上ピン留めできひん" +intro: "Iceshrimpのインストールが完了してん!管理者アカウントを作ってや。" +done: "でけた" +processing: "処理しとる" +preview: "プレビュー" +default: "デフォルト" +defaultValueIs: "デフォルト" +noCustomEmojis: "絵文字はあらへん" +noJobs: "ジョブはあらへん" +federating: "連合しとる" +blocked: "ブロックしとる" +suspended: "配信せぇへん" +all: "みんな" +subscribing: "購読しとる" +publishing: "配信しとる" +notResponding: "応答してへんで" +instanceFollowing: "インスタンスのフォロー" +instanceFollowers: "インスタンスのフォロワー\n" +instanceUsers: "インスタンスのユーザー" +changePassword: "パスワード変える" +security: "セキュリティ" +retypedNotMatch: "そやないねん。" +currentPassword: "今のパスワード" +newPassword: "今度のパスワード" +newPasswordRetype: "今度のパスワード(もっぺん入れて)" +attachFile: "ファイルのっける" +more: "他のやつ!" +featured: "ハイライト" +usernameOrUserId: "ユーザー名かユーザーID" +noSuchUser: "ユーザーが見つからへんで" +lookup: "見てきて" +announcements: "お知らせ" +imageUrl: "画像URL" +remove: "ほかす" +removed: "削除したで!" +removeAreYouSure: "「{x}」はほかしてええか?" +deleteAreYouSure: "「{x}」はほかしてええか?" +resetAreYouSure: "リセットしてええん?" +saved: "保存したで!" +messaging: "チャット" +upload: "アップロード" +keepOriginalUploading: "Retain the original image. " +keepOriginalUploadingDescription: "When uploading the clip, the original version will be retained. Turning it of then uploading will produce images for public use. " +fromDrive: "ドライブから" +fromUrl: "URLから" +uploadFromUrl: "URLアップロード" +uploadFromUrlDescription: "このURLのファイルをアップロードしたいねん" +uploadFromUrlRequested: "アップロードしたい言うといたで" +uploadFromUrlMayTakeTime: "アップロード終わるんにちょい時間かかるかもしれへんわ。" +explore: "みつける" +messageRead: "もう読んだ" +noMoreHistory: "これより過去の履歴はあらへんで" +startMessaging: "チャットやるで" +nUsersRead: "{n}人が読んでもうた" +agreeTo: "{0}に同意したで" +tos: "利用規約" +start: "始める" +home: "ホーム" +remoteUserCaution: "リモートユーザーやから、足りひん情報あるかもしれへん。" +activity: "アクティビティ" +images: "画像" +birthday: "生まれた日" +yearsOld: "{age}歳" +registeredDate: "始めた日" +location: "場所" +theme: "テーマ" +themeForLightMode: "ライトモードではこのテーマつこて" +themeForDarkMode: "ダークモードではこのテーマつこて" +light: "ライト" +dark: "ダーク" +lightThemes: "デイゲーム" +darkThemes: "ナイトゲーム" +syncDeviceDarkMode: "デバイスのダークモードと一緒にする" +drive: "ドライブ" +fileName: "ファイル名" +selectFile: "ファイル選んでや" +selectFiles: "ファイル選んでや" +selectFolder: "フォルダ選んでや" +selectFolders: "フォルダ選んでや" +renameFile: "ファイル名をいらう" +folderName: "フォルダー名" +createFolder: "フォルダー作る" +renameFolder: "フォルダー名を変える" +deleteFolder: "フォルダーを消してまう" +addFile: "ファイルを追加" +emptyDrive: "ドライブにはなんも残っとらん" +emptyFolder: "ふぉろだーにはなんも残っとらん" +unableToDelete: "消そうおもってんけどな、あかんかったわ" +inputNewFileName: "今度のファイル名は何にするん?" +inputNewDescription: "新しいキャプションを入力しましょ" +inputNewFolderName: "今度のフォルダ名は何にするん?" +circularReferenceFolder: "移動先のフォルダーは、移動するフォルダーのサブフォルダーや。" +hasChildFilesOrFolders: "このフォルダ、まだなんか入っとるから消されへん" +copyUrl: "URLをコピー" +rename: "名前を変えるで" +avatar: "アイコン" +banner: "バナー" +nsfw: "閲覧注意" +whenServerDisconnected: "サーバーとの接続が切れたとき" +disconnectedFromServer: "サーバーとの通信が切れたで" +reload: "リロード" +doNothing: "何もせんとく" +reloadConfirm: "リロードしてええか?" +watch: "ウォッチ" +unwatch: "ウォッチやめる" +accept: "ええで" +reject: "あかん" +normal: "ええ感じ" +instanceName: "インスタンス名" +instanceDescription: "インスタンスの紹介" +maintainerName: "管理者の名前" +maintainerEmail: "管理者のメールアドレス" +tosUrl: "利用規約のURL" +thisYear: "今年" +thisMonth: "今月" +today: "今日" +dayX: "{day}日" +monthX: "{month}月" +yearX: "{year}年" +pages: "ページ" +integration: "連携" +connectService: "つなげるで" +disconnectService: "切るで" +enableLocalTimeline: "ローカルタイムラインを使えるようにする" +enableGlobalTimeline: "グローバルタイムラインを使えるようにする" +disablingTimelinesInfo: "ここらへんのタイムラインを使えんようにしてしもても、管理者とモデレーターは使えるままになってるで、そうやなかったら不便やからな。" +registration: "登録" +enableRegistration: "一見さんでも誰でもいらっしゃ~い" +invite: "来てや" +driveCapacityPerLocalAccount: "ローカルユーザーひとりあたりのドライブ容量" +driveCapacityPerRemoteAccount: "リモートユーザーひとりあたりのドライブ容量" +inMb: "メガバイト単位" +iconUrl: "アイコン画像のURL" +bannerUrl: "バナー画像のURL" +backgroundImageUrl: "背景画像のURL" +basicInfo: "基本情報" +pinnedUsers: "ピン留めしたユーザー" +pinnedUsersDescription: "「みつける」ページとかにピン留めしたいユーザーをここに書けばええんやで。他ん人との名前は改行で区切ればええんやで。" +pinnedPages: "ピン留めページ" +pinnedPagesDescription: "インスタンスのいっちゃん上にピン留めしたいページのパスを改行で区切って記述してな" +pinnedClipId: "ピン留めするクリップのID" +pinnedNotes: "ピン留めされとるノート" +hcaptcha: "hCaptcha(キャプチャ)" +enableHcaptcha: "hCaptcha(キャプチャ)をつけとく" +hcaptchaSiteKey: "サイトキー" +hcaptchaSecretKey: "シークレットキー" +recaptcha: "reCAPTCHA" +enableRecaptcha: "reCAPTCHA(リキャプチャ)を有効にする" +recaptchaSiteKey: "サイトキー" +recaptchaSecretKey: "シークレットキー" +avoidMultiCaptchaConfirm: "ぎょうさんのCaptchaをつこてしまうと、仲良うせんことがあるんや。他のCaptchaをなおしとこか?別にキャンセルしてもろうたらCaptchaは消されへんで済むけど知らんで。" +antennas: "アンテナ" +manageAntennas: "アンテナいじる" +name: "名前" +antennaSource: "受信ソース(このソースは食われへん)" +antennaKeywords: "受信キーワード" +antennaExcludeKeywords: "除外キーワード" +antennaKeywordsDescription: "スペースで区切ったるとAND指定で、改行で区切ったるとOR指定や" +notifyAntenna: "新しいノートを通知すんで" +withFileAntenna: "なんか添付されたノートだけ" +enableServiceworker: "ServiceWorkerをつこて" +antennaUsersDescription: "ユーザー名を改行で区切ったってな" +caseSensitive: "大文字と小文字は別もんや" +withReplies: "返信も入れたって" +connectedTo: "次のアカウントに繋がっとるで" +notesAndReplies: "投稿と返信" +withFiles: "ファイル付いとる" +silence: "サイレンス" +silenceConfirm: "サイレンスしよか?" +unsilence: "サイレンスやめるで" +unsilenceConfirm: "サイレンスやめよか?" +popularUsers: "人気のユーザー" +recentlyUpdatedUsers: "ちょっと前に投稿したばっかりのユーザー" +recentlyRegisteredUsers: "ちょっと前に始めたばっかりのユーザー" +recentlyDiscoveredUsers: "最近見っけたユーザー" +exploreUsersCount: "{count}もユーザーおるで" +exploreFediverse: "Fediverseを探ってみる" +popularTags: "人気のタグ" +userList: "リスト" +about: "情報" +aboutIceshrimp: "Iceshrimpってなんや?" +administrator: "管理者" +token: "トークン" +twoStepAuthentication: "二段階認証" +moderator: "モデレーター" +moderation: "モデレーション" +nUsersMentioned: "{n}人が投稿" +securityKey: "セキュリティキー" +securityKeyName: "キーの名前" +registerSecurityKey: "セキュリティキーを登録するで" +lastUsed: "最後につこうた日" +unregister: "登録やめる" +passwordLessLogin: "パスワード無くてもログインできるようにする" +resetPassword: "パスワードをリセット" +newPasswordIs: "今度のパスワードは「{password}」や" +reduceUiAnimation: "UIの動きやアニメーションを減らす" +share: "わけわけ" +notFound: "見つからへんね" +notFoundDescription: "指定されたURLに該当するページはあらへんやった。" +uploadFolder: "とりあえずアップロードしたやつ置いとく所" +cacheClear: "キャッシュをほかす" +markAsReadAllNotifications: "通知はもう全て読んだわっ" +markAsReadAllUnreadNotes: "投稿は全て読んだわっ" +markAsReadAllTalkMessages: "チャットはもうぜんぶ読んだわっ" +help: "ヘルプ" +inputMessageHere: "ここにメッセージ書いてや" +close: "閉じる" +group: "グループ" +groups: "グループ" +createGroup: "グループを作るで" +ownedGroups: "所有しとるグループ" +joinedGroups: "参加しとるグループ" +invites: "来てや" +groupName: "グループ名" +members: "メンバー" +transfer: "譲渡" +messagingWithUser: "ユーザーとチャット" +messagingWithGroup: "グループでチャット" +title: "タイトル" +text: "テキスト" +enable: "有効にするで" +next: "次" +retype: "もっかい入力" +noteOf: "{user}のノート" +inviteToGroup: "グループに招く" +quoteAttached: "引用付いとるで" +quoteQuestion: "引用として添付してもええか?" +noMessagesYet: "まだチャットはあらへんで" +newMessageExists: "新しいメッセージがきたで" +onlyOneFileCanBeAttached: "すまん、メッセージに添付できるファイルはひとつだけなんや。" +signinRequired: "ログインしてくれへん?" +invitations: "来てや" +invitationCode: "招待コード" +checking: "確認しとるで" +available: "利用できる\n" +unavailable: "利用できん" +usernameInvalidFormat: "a~z、A~Z、0~9、_が使えるで" +tooShort: "短すぎやろ!" +tooLong: "長すぎやろ!" +weakPassword: "へぼいパスワード" +normalPassword: "普通のパスワード" +strongPassword: "ええ感じのパスワード" +passwordMatched: "よし!一致や!" +passwordNotMatched: "一致しとらんで?" +signinWith: "{x}でログイン" +signinFailed: "ログインできんかったで。もっかいユーザー名とパスワードを確認してみてな。" +tapSecurityKey: "セキュリティキーにタッチしてな" +or: "それか" +language: "言語" +uiLanguage: "UIの表示言語" +groupInvited: "グループに招待されとるで" +aboutX: "{x}について" +useOsNativeEmojis: "OSネイティブの絵文字を使う" +disableDrawer: "メニューをドロワーで表示せぇへん" +youHaveNoGroups: "グループがあらへんねぇ。" +joinOrCreateGroup: "既存のグループに招待してもらうか、新しくグループ作ってからやってな" +noHistory: "履歴はあらへんねぇ。" +signinHistory: "ログイン履歴" +disableAnimatedMfm: "動きがやかましいMFMを止める" +doing: "やっとるがな" +category: "カテゴリ" +tags: "タグ" +docSource: "このドキュメントのソース" +createAccount: "アカウントを作成" +existingAccount: "既存のアカウント" +regenerate: "再生成" +fontSize: "フォントサイズ" +noFollowRequests: "フォロー申請はあらへんで" +openImageInNewTab: "画像を新しいタブで開く" +dashboard: "ダッシュボード" +local: "ローカル" +remote: "リモート" +total: "合計" +weekOverWeekChanges: "前週比" +dayOverDayChanges: "前日比" +appearance: "見た目" +clientSettings: "クライアントの設定" +accountSettings: "アカウントの設定" +promotion: "宣伝" +promote: "宣伝" +numberOfDays: "日数" +hideThisNote: "このノートは表示せんでいい" +showFeaturedNotesInTimeline: "タイムラインにおすすめのノートを表示してや" +objectStorage: "オブジェクトストレージ" +useObjectStorage: "オブジェクトストレージを使う" +objectStorageBaseUrl: "Base URL" +objectStorageBaseUrlDesc: "参照に使うにURLやで。CDNやProxyを使用してるんならそのURL、S3: 'https://.s3.amazonaws.com'、GCSとかなら: 'https://storage.googleapis.com/'。" +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "使ってるサービスのbucket名を選んでな" +objectStoragePrefix: "Prefix" +objectStoragePrefixDesc: "このprefixのディレクトリ下に格納されるで" +objectStorageEndpoint: "Endpoint" +objectStorageEndpointDesc: "S3のときは空、それ以外は各サービスのendpointを指定してなー。''ってやるか':'みたいに指定するんやで。" +objectStorageRegion: "Region" +objectStorageRegionDesc: "'xx-east-1'みたいなregionを指定したってやー。使ってるサービスにregionの概念がないときは、空か'us-east-1'にするんやで。" +objectStorageUseSSL: "SSLを使う" +objectStorageUseSSLDesc: "API接続にhttpsを使わん場合はオフにするんやで" +objectStorageUseProxy: "Proxyを使う" +objectStorageUseProxyDesc: "API接続にproxy使わんのやったら切ってくれへん?" +objectStorageSetPublicRead: "アップロードした時に'public-read'を設定してや" +serverLogs: "サーバーログ" +deleteAll: "全て削除してや" +showFixedPostForm: "タイムラインの上の方で投稿できるようにやってくれへん?" +newNoteRecived: "新しいノートがあるで" +sounds: "サウンド" +listen: "聴く" +none: "なし" +showInPage: "ページで表示" +popout: "ポップアウト" +volume: "音量" +masterVolume: "全体の音量" +details: "もっと" +chooseEmoji: "絵文字を選ぶ" +unableToProcess: "なんか作業が止まってしまったようやね" +recentUsed: "最近使ったやつ" +install: "インストール" +uninstall: "アンインストール" +installedApps: "インストールされとるアプリ" +nothing: "あらへん" +installedDate: "インストールした日時" +lastUsedDate: "最後に使った日時" +state: "状態" +sort: "仕分ける" +ascendingOrder: "小さい順" +descendingOrder: "大きい順" +scratchpad: "スクラッチパッド" +scratchpadDescription: "スクラッチパッドではAiScriptを色々試すことができるんや。Iceshrimpに対して色々できるコードを書いて動かしてみたり、結果を見たりできるで。" +output: "出力" +script: "スクリプト" +disablePagesScript: "Pagesのスクリプトを無効にしてや" +updateRemoteUser: "リモートユーザー情報の更新してくれん?" +deleteAllFiles: "すべてのファイルを削除" +deleteAllFilesConfirm: "ホンマにすべてのファイルを削除するん?消したもんはもう戻ってこんのやで?" +removeAllFollowing: "フォローを全解除" +removeAllFollowingDescription: "{host}からのフォローをすべて解除するで。そのインスタンスが消えて無くなった時とかには便利な機能やで。" +userSuspended: "このユーザーは...凍結されとる。" +userSilenced: "このユーザーは...サイレンスされとる。" +yourAccountSuspendedTitle: "あんたのアカウント凍結されとるで" +yourAccountSuspendedDescription: "あんたのアカウントは、サーバーの利用規約に違反したとかの理由で、凍結されとるで。細かいことは管理者までお問い合わせたってなー。絶対に新しいアカウント作ったらあかんで。絶対やで。" +menu: "メニュー" +divider: "分割線" +addItem: "項目を追加" +relays: "リレー" +addRelay: "リレーの追加" +inboxUrl: "inboxのURL" +addedRelays: "追加済みのリレー" +serviceworkerInfo: "プッシュ通知をするんなら有効にせなあかんで。" +deletedNote: "消された投稿" +invisibleNote: "非公開の投稿" +enableInfiniteScroll: "自動でもっと見る" +visibility: "公開範囲" +poll: "アンケート" +useCw: "内容を隠す" +enablePlayer: "プレイヤーを開く" +disablePlayer: "プレイヤーを閉じる" +expandTweet: "ツイートを展開する" +themeEditor: "テーマエディター" +description: "説明" +describeFile: "キャプションを付ける" +enterFileDescription: "キャプションを入力" +author: "作者" +leaveConfirm: "未保存の変更があるで!ほかしてええか?" +manage: "管理" +plugins: "プラグイン" +deck: "デッキ" +undeck: "デッキ解除" +useBlurEffectForModal: "モーダルにぼかし効果を使用" +useFullReactionPicker: "フル機能にリアクションピッカーを使用" +width: "幅" +height: "高さ" +large: "大" +medium: "中" +small: "小" +generateAccessToken: "アクセストークンの発行" +permission: "権限" +enableAll: "全部使えるようにする" +disableAll: "全部使えへんようにする" +tokenRequested: "アカウントへのアクセス許可" +pluginTokenRequestedDescription: "このプラグインはここで設定した権限を使えるようになるで。" +notificationType: "通知の種類" +edit: "編集" +emailServer: "メールサーバー" +enableEmail: "メール配信を受け取る" +emailConfigInfo: "メールアドレスの確認とかパスワードリセットの時に使うで" +email: "メール" +emailAddress: "メールアドレス" +smtpConfig: "SMTP サーバーの設定" +smtpHost: "ホスト" +smtpPort: "ポート" +smtpUser: "ユーザー名" +smtpPass: "パスワード" +emptyToDisableSmtpAuth: "ユーザー名とパスワードになんも入れんかったら、SMTP認証を無効化するで" +smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する" +smtpSecureInfo: "STARTTLS使っとる時はオフにするで。" +testEmail: "配信テスト" +wordMute: "ワードミュート" +regexpError: "正規表現エラー" +regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが出てきたで:" +instanceMute: "インスタンスミュート" +userSaysSomething: "{name}が何か言ったようやで" +makeActive: "使うで" +display: "表示" +copy: "コピー" +metrics: "メトリクス" +overview: "概要" +logs: "ログ" +delayed: "遅延" +database: "データベース" +channel: "チャンネル" +create: "作成" +notificationSetting: "通知設定" +notificationSettingDesc: "表示する通知の種類えらんでや。" +useGlobalSetting: "グローバル設定を使ってや" +useGlobalSettingDesc: "オンにすると、アカウントの通知設定が使われるで。オフにすると、別々に設定できるようになるで。" +other: "その他" +regenerateLoginToken: "ログイントークンを再生成" +regenerateLoginTokenDescription: "ログインに使われる内部トークンをもっかい作るで。いつもならこれをやる必要はないで。もっかい作ると、全部のデバイスでログアウトされるで気ぃつけてなー。" +setMultipleBySeparatingWithSpace: "スペースで区切って複数設定できるで。" +fileIdOrUrl: "ファイルIDかURL" +behavior: "動作" +sample: "サンプル" +abuseReports: "通報" +reportAbuse: "通報" +reportAbuseOf: "{name}を通報する" +fillAbuseReportDescription: "細かい通報理由を書いてなー。対象ノートがある時はそのURLも書いといてなー。" +abuseReported: "無事内容が送信されたみたいやで。おおきに〜。" +reporter: "通報者" +reporteeOrigin: "通報先" +reporterOrigin: "通報元" +forwardReport: "リモートインスタンスに通報を転送するで" +forwardReportIsAnonymous: "リモートインスタンスからはあんたの情報は見れへんくって、匿名のシステムアカウントとして表示されるで。" +send: "送信" +abuseMarkAsResolved: "対応したで" +openInNewTab: "新しいタブで開く" +openInSideView: "サイドビューで開く" +defaultNavigationBehaviour: "デフォルトのナビゲーション" +editTheseSettingsMayBreakAccount: "このへんの設定をようわからんままイジるとアカウントが壊れて使えんくなるかも知れへんで?" +instanceTicker: "ノートのインスタンス情報" +waitingFor: "{x}を待っとるで" +random: "ランダム" +system: "システム" +switchUi: "UI切り替え" +desktop: "デスクトップ" +clip: "クリップ" +createNew: "新しく作るで" +optional: "任意" +createNewClip: "新しいクリップを作るで" +unclip: "クリップ解除するで" +confirmToUnclipAlreadyClippedNote: "このノートはすでにクリップ「{name}」に含まれとるで。ノートをこのクリップから除外したる?" +public: "パブリック" +i18nInfo: "Iceshrimpは有志によっていろんな言語に翻訳されとるで。{link}で翻訳に協力したってやー。" +manageAccessTokens: "アクセストークンの管理" +accountInfo: "アカウント情報" +notesCount: "ノートの数やで" +repliesCount: "返信した数やで" +renotesCount: "Renoteした数やで" +repliedCount: "返信された数やで" +renotedCount: "Renoteされた数やで" +followingCount: "フォロー数やで" +followersCount: "フォロワー数やで" +sentReactionsCount: "リアクションした数やで" +receivedReactionsCount: "リアクションされた数" +pollVotesCount: "アンケートに投票した数" +pollVotedCount: "アンケートに投票された数" +yes: "はい" +no: "いいえ" +driveFilesCount: "ドライブのファイル数" +driveUsage: "ドライブ使用量やで" +noCrawle: "クローラーによるインデックスを拒否するで" +noCrawleDescription: "検索エンジンにあんたのユーザーページ、ノート、Pagesとかのコンテンツを登録(インデックス)せぇへんように頼むで。" +lockedAccountInfo: "フォローを承認制にしとっても、ノートの公開範囲を「フォロワー」にせぇへん限り、誰でもあんたのノートを見れるで。" +alwaysMarkSensitive: "デフォルトでメディアを閲覧注意にするで" +loadRawImages: "添付画像のサムネイルをオリジナル画質にするで" +disableShowingAnimatedImages: "アニメーション画像を再生しやへんで" +verificationEmailSent: "無事確認のメールを送れたで。メールに書いてあるリンクにアクセスして、設定を完了してなー。" +notSet: "未設定" +emailVerified: "メールアドレスは確認されたで" +noteFavoritesCount: "お気に入りノートの数やで" +pageLikesCount: "Pageにええやんと思った数" +pageLikedCount: "Pageにええやんと思ってくれた数" +contact: "連絡先" +useSystemFont: "システムのデフォルトのフォントを使うで" +clips: "クリップ" +experimentalFeatures: "実験的機能やで" +developer: "開発者やで" +makeExplorable: "アカウントを見つけやすくするで" +makeExplorableDescription: "オフにすると、「みつける」にアカウントが載らんくなるで。" +showGapBetweenNotesInTimeline: "タイムラインのノートを放して表示するで" +duplicate: "複製" +left: "左" +center: "中央" +wide: "広い" +narrow: "狭い" +reloadToApplySetting: "設定はページリロード後に反映されるで。今リロードしとくか?" +needReloadToApply: "反映には再起動せなあかんで" +showTitlebar: "タイトルバーを見せる" +clearCache: "キャッシュをほかす" +onlineUsersCount: "{n}人が起きとるで" +nUsers: "{n}ユーザー" +nNotes: "{n}ノート" +sendErrorReports: "エラーリポートを送る" +sendErrorReportsDescription: "オンにしたら、なんか変なことが起きたときにエラーの詳細がIceshrimpに共有されて、ソフトウェアの品質向上に役立てられるんや。エラー情報には、OSのバージョン、ブラウザの種類、行動履歴などが含まれるで。" +myTheme: "マイテーマ" +backgroundColor: "背景" +accentColor: "アクセント" +textColor: "文字" +saveAs: "名前を付けて保存" +advanced: "高度" +value: "値" +createdAt: "作成した日" +updatedAt: "更新日時" +saveConfirm: "保存するで?" +deleteConfirm: "ホンマに削除するで?" +invalidValue: "有効な値じゃないみたいやで。" +registry: "レジストリ" +closeAccount: "アカウントを閉鎖する" +currentVersion: "現在のバージョン" +latestVersion: "最新のバージョン" +youAreRunningUpToDateClient: "今使ってるクライアントが最新やで!" +newVersionOfClientAvailable: "新しいバージョンのクライアントが使えるで。" +usageAmount: "使用量" +capacity: "容量" +inUse: "使用中" +editCode: "コードを編集" +apply: "適用" +receiveAnnouncementFromInstance: "インスタンスからのお知らせを受け取る" +emailNotification: "メール通知" +publish: "公開" +inChannelSearch: "チャンネル内検索" +useReactionPickerForContextMenu: "右クリックでリアクションピッカーを開くようにする" +typingUsers: "{users}が今書きよるで" +jumpToSpecifiedDate: "特定の日付にジャンプ" +showingPastTimeline: "過去のタイムラインを表示してるで" +clear: "クリア" +markAllAsRead: "もうみな読んでもうたわ" +goBack: "戻る" +unlikeConfirm: "いいね解除するんか?" +fullView: "フルビュー" +quitFullView: "フルビュー解除" +addDescription: "説明を追加するで" +userPagePinTip: "個々のノートのメニューから「ピン留め」を選んどくと、ここにノートを表示しておけるで。" +notSpecifiedMentionWarning: "宛先に含まれてへんメンションがあるで" +info: "情報" +userInfo: "ユーザー情報やで" +unknown: "不明" +onlineStatus: "オンライン状態" +hideOnlineStatus: "オンライン状態を隠すで" +hideOnlineStatusDescription: "オンライン状態を隠すと、検索とかの一部の機能で使いにくくなるかもしれんよ。" +online: "オンライン" +active: "アクティブ" +offline: "オフライン" +notRecommended: "あんま推奨しやんで" +botProtection: "Botプロテクション" +instanceBlocking: "インスタンスブロック" +selectAccount: "アカウントを選んでなー" +switchAccount: "アカウントを変えるで" +enabled: "有効" +disabled: "無効" +quickAction: "クイックアクション" +user: "ユーザー" +administration: "管理" +accounts: "アカウント" +switch: "切り替え" +noMaintainerInformationWarning: "管理者情報が設定されてへんで" +noBotProtectionWarning: "Botプロテクションが設定されてへんで。" +configure: "設定する" +postToGallery: "ギャラリーへ投稿" +gallery: "ギャラリー" +recentPosts: "最近の投稿" +popularPosts: "人気の投稿" +shareWithNote: "ノートで共有" +ads: "広告" +expiration: "期限" +memo: "メモ" +priority: "優先度" +high: "高い" +middle: "中" +low: "低い" +emailNotConfiguredWarning: "メアドの設定がされてへんで。" +ratio: "比率" +previewNoteText: "本文を下見するで" +customCss: "カスタムCSS" +customCssWarn: "この設定は必ず知識のある人がやらなあかんで。あんま良くない設定をしたるとクライアントがちゃんと使えへんくなってくで。" +global: "グローバル" +squareAvatars: "アイコンを四角形で表示するで" +sent: "送信" +received: "受信" +searchResult: "検索結果やで" +hashtags: "ハッシュタグ" +troubleshooting: "トラブルシューティング" +useBlurEffect: "UIにぼかし効果を使うで" +learnMore: "詳しく" +iceshrimpUpdated: "Iceshrimpが更新されたで!\nモデレーターの人らに感謝せなあかんで" +whatIsNew: "更新情報を見るで" +translate: "翻訳" +translatedFrom: "{x}から翻訳するで" +accountDeletionInProgress: "アカウント削除しとるで待っとってなー" +usernameInfo: "サーバー上であんたのアカウントをあんたやと分かるようにするための名前やで。アルファベット(a~z, A~Z)、数字(0~9)、それとアンダーバー(_)が使って考えてな。この名前は後から変更することはできへんからちゃんと考えるんやで。" +aiChanMode: "藍モードやで" +keepCw: "CWを維持するで" +pubSub: "Pub/Subのアカウント" +lastCommunication: "直近の通信" +resolved: "解決したで" +unresolved: "まだ解決してないで" +breakFollow: "フォロワーを解除するで" +itsOn: "オンになっとるよ" +hide: "隠す" +searchByGoogle: "探す" +indefinitely: "無期限" +file: "ファイル" +requireAdminForView: "これを見るには管理者アカウントでログインしとらなあかんで。" +isSystemAccount: "システムが自動で作成・管理しとるアカウントやで。" +typeToConfirm: "この操作をやるんなら {x} と入力してなー" +deleteAccount: "アカウント削除するで" +document: "ドキュメント" +numberOfPageCache: "ページキャッシュ数やで" +numberOfPageCacheDescription: "増やすと使いやすくなる、負荷とメモリ使用量が増えてくで。一長一短やな。" +logoutConfirm: "ログアウトしまっか?" +lastActiveDate: "最後に使った日時" +statusbar: "ステータスバー" +pleaseSelect: "選択したってやー" +reverse: "反転" +colored: "色付き" +refreshInterval: "更新間隔" +label: "ラベル" +type: "タイプ" +speed: "速度" +slow: "遅い" +fast: "速い" +sensitiveMediaDetection: "センシティブなメディアの検出" +localOnly: "ローカルのみ" +remoteOnly: "リモートのみ" +failedToUpload: "アップロードに失敗したで" +cannotUploadBecauseInappropriate: "不適切な内容を含むかもしれへんって判定されたでアップロードできまへん。" +cannotUploadBecauseNoFreeSpace: "ドライブの空き容量が無いでアップロードできまへん。" +beta: "ベータ" +enableAutoSensitive: "自動NSFW判定" +enableAutoSensitiveDescription: "使える時は、機械学習を使って自動でメディアにNSFWフラグを設定するで。この機能をオフにしても、インスタンスによっては自動で設定されることがあるで。" +activeEmailValidationDescription: "ユーザーのメールアドレスのバリデーションを、捨てアドかどうかや実際に通信可能かどうかとかを判定して積極的に行うで。オフにすると単に文字列として正しいかどうかだけチェックするで。" +navbar: "ナビゲーションバー" +shuffle: "シャッフルするで" +account: "アカウント" +move: "移動するで" +_sensitiveMediaDetection: + description: "機械学習を使って自動でセンシティブなメディアを検出して、モデレーションに役立てることができるで。サーバーの負荷が少し増えてまうなあ。" + sensitivity: "検出感度やで" + sensitivityDescription: "感度を低くすると、誤検知(偽陽性)が減るで。感度を高くすると、検知漏れ(偽陰性)が減るで。" + setSensitiveFlagAutomatically: "NSFWフラグを設定するで" + setSensitiveFlagAutomaticallyDescription: "この設定をオフにしても内部的に判定結果は保持されるで。" +_ffVisibility: + public: "公開" +_ad: + back: "戻る" +_gallery: + unlike: "良くないわ" +_email: + _follow: + title: "フォローされたで" + _receiveFollowRequest: + title: "フォローリクエストを受け取ったで" +_plugin: + install: "プラグインのインストール" + installWarn: "信頼できへんプラグインはインストールせんとってな" + manage: "プラグインの管理" +_registry: + scope: "スコープ" + key: "キー" + keys: "キー" + domain: "ドメイン" + createKey: "キーを作る" +_aboutIceshrimp: + about: "Iceshrimpはsyuiloが2014年からずっと作ってはる、オープンソースなソフトウェアや。" + contributors: "主な貢献者" + allContributors: "全ての貢献者" + source: "ソースコード" + translation: "Iceshrimpを翻訳" + donate: "Iceshrimpに寄付" + morePatrons: "他にもぎょうさんの人からサポートしてもろてんねん。ほんまおおきに🥰" + patrons: "支援者" +_mfm: + cheatSheet: "MFMチートシート" + mention: "メンション" + hashtag: "ハッシュタグ" + url: "URL" + link: "リンク" + bold: "太字" + center: "中央寄せ" + inlineCode: "コード(インライン)" + blockCode: "コード(ブロック)" + inlineMath: "数式(インライン)" + quote: "引用" + emoji: "カスタム絵文字" + search: "探す" + shake: "アニメーション(ぶるぶる)" + twitch: "アニメーション(ブレ)" + spin: "アニメーション(回転)" + blur: "ぼかし" + font: "フォント" + rotate: "回転" +_instanceTicker: + none: "表示せん" + remote: "リモートユーザーに表示" + always: "常に表示" +_serverDisconnectedBehavior: + reload: "自動でリロード" + dialog: "ダイアログで警告" +_channel: + create: "チャンネルを作る" + edit: "チャンネルを編集" + setBanner: "バナーを設定" + removeBanner: "バナーを削除" + featured: "トレンド" + notesCount: "{n}こ投稿があるで" +_menuDisplay: + hide: "隠す" +_wordMute: + soft: "ソフト" + hard: "ハード" +_theme: + explore: "テーマを探す" + install: "テーマのインストール" + manage: "テーマの管理" + code: "テーマコード" + description: "説明" + installed: "{name}をインストールしたで。" + installedThemes: "インストールされとるテーマ" + builtinThemes: "標準のテーマ" + alreadyInstalled: "そのテーマはもうインストールされとるで?" + make: "テーマを作る" + base: "ベース" + addConstant: "定数を追加" + defaultValue: "デフォルト値" + color: "色" + refProp: "プロパティを参照" + refConst: "定数を参照" + key: "キー" + func: "関数" + funcKind: "関数の種類" + argument: "引数" + basedProp: "元にするプロパティの名前" + alpha: "不透明度" + darken: "暗さ" + lighten: "明るさ" + keys: + accent: "アクセント" + bg: "背景" + fg: "文字" + focus: "フォーカス" + indicator: "インジケーター" + panel: "パネル" + shadow: "影" + header: "ヘッダー" + navBg: "サイドバーの背景" + navFg: "サイドバーの文字" + navHoverFg: "サイドバー文字(ホバー)" + navActive: "サイドバー文字(アクティブ)" + navIndicator: "サイドバーのインジケーター" + link: "リンク" + hashtag: "ハッシュタグ" + mention: "メンション" + mentionMe: "うち宛てのメンション" + renote: "Renote" + modalBg: "モーダルの背景" + divider: "分割線" + scrollbarHandle: "スクロールバーの取っ手" + scrollbarHandleHover: "スクロールバーの取っ手(ホバー)" + dateLabelFg: "日付ラベルの文字" + infoBg: "情報の背景" + infoFg: "情報の文字" + infoWarnBg: "警告の背景" + infoWarnFg: "警告の文字" + cwBg: "CW ボタンの背景" + cwFg: "CW ボタンの文字" + cwHoverBg: "CW ボタンの背景 (ホバー)" + toastBg: "通知トーストの背景" + toastFg: "通知トーストの文字" + buttonBg: "ボタンの背景" + buttonHoverBg: "ボタンの背景 (ホバー)" + inputBorder: "入力ボックスの縁取り" + listItemHoverBg: "リスト項目の背景 (ホバー)" + driveFolderBg: "ドライブフォルダーの背景" + wallpaperOverlay: "壁紙のオーバーレイ" + badge: "バッジ" + messageBg: "チャットの背景" + accentDarken: "アクセント (暗め)" + accentLighten: "アクセント (明るめ)" + fgHighlighted: "強調されとる文字" +_sfx: + note: "ノート" + noteMy: "ノート(自分)" + notification: "通知" + chat: "チャット" +_ago: + future: "未来" + justNow: "たった今" + secondsAgo: "{n}秒前" + minutesAgo: "{n}分{n2}秒前" + hoursAgo: "{n}時間{n2}分前" + daysAgo: "{n}日{n2}時間前" + weeksAgo: "{n}週間{n2}日前" + monthsAgo: "{n}ヶ月{n2}週間前" + yearsAgo: "{n}年{n2}ヶ月前" +_time: + second: "秒" + minute: "分" + hour: "時間" + day: "日" +_2fa: + alreadyRegistered: "もう設定終わっとるわ。" +_permissions: + "read:reactions": "リアクションを見る" + "write:votes": "投票する" + "read:pages": "ページを見る" + "read:page-likes": "ページのええやんを見る" + "write:page-likes": "ページのええやんを操作する" + "read:user-groups": "ユーザーグループを見る" + "read:channels": "チャンネルを見る" +_auth: + permissionAsk: "このアプリは次の権限を要求しとるで" +_antennaSources: + all: "みんなのノート" + homeTimeline: "フォローしとるユーザーのノート" +_weekday: + sunday: "日曜日" + monday: "月曜日" + tuesday: "火曜日" + wednesday: "水曜日" + thursday: "木曜日" + friday: "金曜日" + saturday: "土曜日" +_widgets: + memo: "付箋" + notifications: "通知" + timeline: "タイムライン" + calendar: "カレンダー" + trends: "トレンド" + clock: "時計" + rss: "RSSリーダー" + activity: "アクティビティ" + photos: "フォト" + digitalClock: "デジタル時計" + federation: "連合" + postForm: "投稿フォーム" + slideshow: "スライドショー" + button: "ボタン" + onlineUsers: "オンラインユーザー" + jobQueue: "ジョブキュー" + serverMetric: "サーバーメトリクス" + aiscript: "AiScriptコンソール" +_cw: + hide: "隠す" + show: "続き見して!" + chars: "{count}文字" + files: "{count}ファイル" +_poll: + choiceN: "選択肢{n}" + noMore: "これ以上追加でけへん" + canMultipleVote: "複数回答可" + expiration: "期限" + infinite: "無期限" + at: "日時指定" + after: "経過指定" + deadlineDate: "期日" + deadlineTime: "時間" + duration: "期間" + votesCount: "{n}票" + vote: "投票する" +_visibility: + publicDescription: "みんなに公開" + home: "ホーム" + followers: "フォロワー" +_profile: + name: "名前" + username: "ユーザー名" +_exportOrImport: + allNotes: "全てのノート" + followingList: "フォロー" + muteList: "ミュート" + blockingList: "ブロック" + userLists: "リスト" +_charts: + federation: "連合" + apRequest: "リクエスト" + usersTotal: "ユーザーの合計" + activeUsers: "アクティブユーザー数" + notesIncDec: "ノートの増減" + localNotesIncDec: "ローカルのノートの増減" + remoteNotesIncDec: "リモートのノートの増減" + notesTotal: "ノートの合計" + filesIncDec: "ファイルの増減" + filesTotal: "ファイルの合計" + storageUsageIncDec: "ストレージ使用量の増減" + storageUsageTotal: "ストレージ使用量の合計" +_instanceCharts: + requests: "リクエスト" + users: "ユーザーの増減" + usersTotal: "ユーザーの累積" + notes: "ノートの増減" + notesTotal: "ノートの累積" + ff: "フォロー/フォロワーの増減" + ffTotal: "フォロー/フォロワーの累積" + cacheSize: "キャッシュサイズの増減" + cacheSizeTotal: "キャッシュサイズの累積" + files: "ファイル数の増減" + filesTotal: "ファイル数の累積" +_timelines: + home: "ホーム" + local: "ローカル" + social: "ソーシャル" + global: "グローバル" +_pages: + newPage: "ページを作る" + editPage: "ページの編集" + readPage: "ソースを表示中" + created: "ページを作成したで" + updated: "ページを更新したで" + deleted: "ページを削除したで" + pageSetting: "ページ設定" + viewPage: "ページを見る" + like: "ええやん" + unlike: "良くないわ" + liked: "ええと思ったページ" + contents: "コンテンツ" + summary: "ページの要約" + alignCenter: "中央寄せ" + font: "フォント" + fontSerif: "セリフ" + fontSansSerif: "サンセリフ" + eyeCatchingImageSet: "アイキャッチ画像を設定" + eyeCatchingImageRemove: "アイキャッチ画像を削除" + chooseBlock: "ブロックを追加" + selectType: "種類を選択" + contentBlocks: "コンテンツ" + inputBlocks: "入力" + specialBlocks: "特殊" + blocks: + text: "テキスト" + textarea: "テキストエリア" + section: "セクション" + image: "画像" + button: "ボタン" + if: "もし" + _if: + variable: "変数" + post: "投稿フォーム" + _post: + text: "内容" + canvasId: "キャンバスID" + textInput: "テキスト入力" + _textInput: + name: "変数名" + text: "タイトル" + default: "デフォルト値" + textareaInput: "複数行テキスト入力" + _textareaInput: + name: "変数名" + text: "タイトル" + default: "デフォルト値" + numberInput: "数値入力" + _numberInput: + name: "変数名" + text: "タイトル" + default: "デフォルト値" + canvas: "キャンバス" + _canvas: + id: "キャンバスID" + width: "幅" + height: "高さ" + note: "ノート埋め込み" + _note: + id: "ノートID" + detailed: "詳細な表示" + switch: "スイッチ" + _switch: + name: "変数名" + text: "タイトル" + default: "デフォルト値" + counter: "カウンター" + _counter: + name: "変数名" + text: "タイトル" + inc: "増加値" + _button: + text: "タイトル" + colored: "色付き" + action: "ボタンを押したときの動作" + _action: + dialog: "ダイアログを表示する" + _dialog: + content: "内容" + resetRandom: "乱数をリセット" + pushEvent: "イベントを送信させる" + _pushEvent: + event: "イベント名" + no-variable: "なし" + callAiScript: "AiScript呼び出し" + _callAiScript: + functionName: "関数名" + radioButton: "選択肢" + _radioButton: + name: "変数名" + title: "タイトル" + values: "改行で区切った選択肢" + default: "デフォルト値" + script: + categories: + flow: "制御" + logical: "論理演算" + operation: "計算" + comparison: "比較" + random: "ランダム" + value: "値" + fn: "関数" + text: "関数" + convert: "変換" + list: "リスト" + blocks: + text: "テキスト" + multiLineText: "テキスト(複数行)" + textList: "テキストのリスト" + strLen: "テキストの長さ" + _strLen: + arg1: "テキスト" + strPick: "文字取り出し" + _strPick: + arg1: "テキスト" + arg2: "文字の位置" + strReplace: "テキスト置き換え" + _strReplace: + arg1: "テキスト" + arg2: "置き換え前" + arg3: "置き換え後" + strReverse: "テキストを反転" + _strReverse: + arg1: "テキスト" + join: "テキストを連結" + _join: + arg1: "リスト" + arg2: "区切り" + add: "足す" + _add: + arg1: "A" + arg2: "B" + subtract: "引く" + _subtract: + arg1: "A" + arg2: "A" + multiply: "掛ける" + _multiply: + arg1: "A" + arg2: "B" + divide: "割る" + _divide: + arg1: "A" + arg2: "B" + mod: "割った余り" + _mod: + arg1: "A" + arg2: "B" + round: "小数を丸める" + _round: + arg1: "数値" + eq: "AとBが同じ" + _eq: + arg1: "A" + arg2: "B" + notEq: "AとBが異なる" + _notEq: + arg1: "A" + arg2: "B" + and: "AかつB" + _and: + arg1: "A" + arg2: "B" + or: "AまたはB" + _or: + arg1: "A" + arg2: "B" + lt: "< AがBより小さい" + _lt: + arg1: "A" + arg2: "B" + gt: "> AがBより大きい" + _gt: + arg1: "A" + arg2: "B" + ltEq: "<= AがBと同じか小さい" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: ">= AがBと同じか大きい" + _gtEq: + arg1: "A" + arg2: "B" + if: "分岐" + _if: + arg1: "もし" + arg2: "なら" + arg3: "そうでなければ" + not: "否定" + _not: + arg1: "否定" + random: "ランダム" + _random: + arg1: "確率" + rannum: "乱数" + _rannum: + arg1: "最小" + arg2: "最大" + randomPick: "リストからランダムに選ぶ" + _randomPick: + arg1: "リスト" + dailyRandom: "ランダム (ユーザーごとに日替わり)" + _dailyRandom: + arg1: "確率" + dailyRannum: "乱数 (ユーザーごとに日替わり)" + _dailyRannum: + arg1: "最小" + arg2: "最大" + dailyRandomPick: "リストからランダムに選ぶ (ユーザーごとに日替わり)" + _dailyRandomPick: + arg1: "リスト" + seedRandom: "ランダム (シード)" + _seedRandom: + arg1: "シード" + arg2: "確率" + seedRannum: "乱数 (シード)" + _seedRannum: + arg1: "シード" + arg2: "最小" + arg3: "最大" + seedRandomPick: "リストからランダムに選択 (シード)" + _seedRandomPick: + arg1: "シード" + arg2: "リスト" + DRPWPM: "確率付きリストからランダムに選ぶ (ユーザーごとに日替わり)" + _DRPWPM: + arg1: "テキストのリスト" + pick: "リストから選ぶ" + _pick: + arg1: "リスト" + arg2: "位置" + listLen: "リストの長さを取得" + _listLen: + arg1: "リスト" + number: "数値" + stringToNumber: "テキストを数値に" + _stringToNumber: + arg1: "テキスト" + numberToString: "数値をテキストに" + _numberToString: + arg1: "数値" + splitStrByLine: "テキストを行で分割" + _splitStrByLine: + arg1: "テキスト" + ref: "変数" + aiScriptVar: "AiScript変数" + fn: "関数" + _fn: + slots: "スロット" + arg1: "出力" + for: "繰り返し" + _for: + arg1: "回数" + arg2: "処理" + thereIsEmptySlot: "スロット{slot}が空っぽやで!" + types: + string: "テキスト" + number: "数値" + boolean: "フラグ" + array: "リスト" + stringArray: "テキストのリスト" + emptySlot: "空のスロット" + enviromentVariables: "環境変数" + pageVariables: "ページ要素" + argVariables: "入力スロット" +_notification: + fileUploaded: "ファイルが無事アップロードされたで。" + youGotMention: "{name}からのメンション" + youGotReply: "{name}からのリプライ" + youWereFollowed: "フォローされたで" + youReceivedFollowRequest: "フォロー許可してほしいみたいやな" + yourFollowRequestAccepted: "フォローさせてもろたで" + youWereInvitedToGroup: "グループに招待されとるで" + _types: + all: "すべて" + follow: "フォロー" + mention: "メンション" + renote: "Renote" + quote: "引用" + reaction: "リアクション" + receiveFollowRequest: "フォロー許可してほしいみたいやで" + followRequestAccepted: "フォローが受理されたで" + _actions: + reply: "返事" + renote: "Renote" +_deck: + alwaysShowMainColumn: "いつもメインカラムを表示" + columnAlign: "カラムの寄せ" + addColumn: "カラムを追加" + swapLeft: "左に移動" + swapRight: "右に移動" + swapUp: "上に移動" + swapDown: "下に移動" + stackLeft: "左に重ねる" + popRight: "右に出す" + profile: "プロファイル" + _columns: + main: "メイン" + widgets: "ウィジェット" + notifications: "通知" + tl: "タイムライン" + antenna: "アンテナ" + list: "リスト" + mentions: "あんた宛て" + direct: "ダイレクト" diff --git a/locales/kab-KAB.yml b/locales/kab-KAB.yml new file mode 100644 index 0000000..29eca64 --- /dev/null +++ b/locales/kab-KAB.yml @@ -0,0 +1,126 @@ +--- +_lang_: "Taqbaylit" +monthAndDay: "{day}/{month}" +search: "Nadi" +notifications: "Ilɣuyen" +username: "Isem n umseqdac" +password: "Awal uffir" +ok: "IH" +settings: "Iɣewwaṛen" +otherSettings: "Iɣewwaren nniḍen" +profile: "Amaɣnu" +signup: "Jerred" +save: "Sekles" +delete: "Kkes" +addToList: "Rnu ɣer tebdart" +reply: "Err" +loadMore: "Wali ugar" +showMore: "Wali ugar" +youGotNewFollower: "Yeṭṭafaṛ-ik·em-id" +mention: "Bder" +import: "Kter" +export: "Sifeḍ" +files: "Ifuyla" +download: "Sider" +lists: "Tibdarin" +noLists: "Ulac ɣur-k·m ula d yiwet n tabdart" +following: "Ig ṭṭafaṛ" +followers: "Imeḍfaṛen" +followsYou: "Yeṭṭafaṛ-ik·em-id" +createList: "Snulfu-d tabdart" +enterListName: "Isem n tebdart" +privacy: "Tabaḍnit" +follow: "Ḍfeṛ" +you: "Kečči·mmi" +selectList: "Fren tabdart" +youHaveNoLists: "Ulac ɣur-k·m ula d yiwet n tabdart" +security: "Taɣellist" +remove: "Kkes" +connectService: "Qqen" +userList: "Tibdarin" +securityKey: "Tasarutt n tɣellist" +securityKeyName: "Isem n tsarutt" +signinRequired: "Ttxil jerred" +signinWith: "Tuqqna s {x}" +tapSecurityKey: "Sekcem tasarutt-ik·im n tɣellist" +uiLanguage: "Tutlayt n wegrudem" +accountSettings: "Iɣewwaṛen n umiḍan" +plugins: "Izegrar" +email: "Imayl" +emailAddress: "Tansa imayl" +smtpUser: "Isem n umseqdac" +smtpPass: "Awal uffir" +other: "Wiyyaḍ" +accountInfo: "Talɣut n umiḍan" +emailNotification: "Ilɣa imayl" +selectAccount: "Fren amiḍan" +accounts: "Imiḍan" +searchByGoogle: "Nadi" +file: "Ifuyla" +account: "Imiḍan" +_email: + _follow: + title: "Yeṭṭafaṛ-ik·em-id" +_mfm: + mention: "Bder" + search: "Nadi" + font: "Tasefsit" +_theme: + keys: + mention: "Bder" +_sfx: + notification: "Ilɣuyen" +_permissions: + "write:account": "Ẓreg talɣut n umiḍan-ik·im" +_widgets: + notifications: "Ilɣuyen" +_cw: + show: "Wali ugar" +_visibility: + followers: "Imeḍfaṛen" +_profile: + username: "Isem n umseqdac" +_exportOrImport: + followingList: "Ig ṭṭafaṛ" + muteList: "Sgugem" + blockingList: "Seḥbes" + userLists: "Tibdarin" +_pages: + contents: "Agbur" + font: "Tasefsit" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + eyeCatchingImageRemove: "Kkes tugna i d-ijebden" + selectType: "Fren anaw" + contentBlocks: "Agbur" + inputBlocks: "Anekcum" + specialBlocks: "Uzzig" + script: + categories: + list: "Tibdarin" + blocks: + _join: + arg1: "Tibdarin" + _randomPick: + arg1: "Tibdarin" + _dailyRandomPick: + arg1: "Tibdarin" + _seedRandomPick: + arg2: "Tibdarin" + _pick: + arg1: "Tibdarin" + _listLen: + arg1: "Tibdarin" + types: + array: "Tibdarin" +_notification: + youWereFollowed: "Yeṭṭafaṛ-ik·em-id" + _types: + follow: "Ig ṭṭafaṛ" + mention: "Bder" + _actions: + reply: "Err" +_deck: + _columns: + notifications: "Ilɣuyen" + list: "Tibdarin" diff --git a/locales/kn-IN.yml b/locales/kn-IN.yml new file mode 100644 index 0000000..3a23bed --- /dev/null +++ b/locales/kn-IN.yml @@ -0,0 +1,86 @@ +--- +_lang_: "ಕನ್ನಡ" +introIceshrimp: "ಸ್ವಾಗತ! Iceshrimp ಓಪನ್ ಸೋರ್ಸ್ ಒಕ್ಕೂಟ ಮೈಕ್ರೋಬ್ಲಾಗಿಂಗ್ ಸೇವೆಯಾಗಿದೆ.\n ಏನಾಗುತ್ತಿದೆ ಎಂಬುದನ್ನು ಹಂಚಿಕೊಳ್ಳಲು ಅಥವಾ ನಿಮ್ಮ ಬಗ್ಗೆ ಎಲ್ಲರಿಗೂ ಹೇಳಲು \"ಟಿಪ್ಪಣಿ\"ಗಳನ್ನು ರಚಿಸಿ📡\n \"ಸ್ಪಂದನೆ\" ಕ್ರಿಯೆಯೊಂದಿಗೆ, ನೀವು ಎಲ್ಲರ ಟಿಪ್ಪಣಿಗಳಿಗೆ ತ್ವರಿತವಾಗಿ ಸ್ಪಂದನೆಗಳನ್ನು ಕೂಡ ಸೇರಿಸಬಹುದು.👍\n ಹೊಸ ಜಗತ್ತನ್ನು ಅನ್ವೇಷಿಸಿ🚀" +monthAndDay: "{month}ನೇ ತಿಂಗಳ {day}ನೇ ದಿನ" +search: "ಹುಡುಕು" +notifications: "ಅಧಿಸೂಚನೆಗಳು" +username: "ಬಳಕೆಹೆಸರು" +password: "ಗುಪ್ತಪದ" +fetchingAsApObject: "ಒಕ್ಕೂಟದಿಂದ ಪಡೆಯಲಾಗುತ್ತಿದೆ" +ok: "ಸರಿ" +gotIt: "ಅರ್ಥವಾಯಿತು!" +cancel: "ರದ್ದು" +enterUsername: "ಬಳಕೆಹೆಸರನ್ನು ಭರ್ತಿ ಮಾಡಿ" +renotedBy: "{user} ಪುನರಾವರ್ತಿಸಿದರು" +noNotes: "ಟಿಪ್ಪಣಿಗಳಿಲ್ಲ" +noNotifications: "ಅಧಿಸೂಚನೆಗಳಿಲ್ಲ" +instance: "ನಿದರ್ಶನ" +settings: "ಸಿದ್ಧತೆಗಳು" +profile: "ಪ್ರೊಫೈಲು" +timeline: "ಸಮಯಸಾಲು" +noAccountDescription: "ಇವರು ಸ್ವಯಂ ಪರಿಚಯ ರಚಿಸಿಲ್ಲ" +login: "ಪ್ರವೇಶ" +loggingIn: "ಪ್ರವೇಶಿಸುತ್ತಾ..." +logout: "ಆಚೆಗೆ" +signup: "ನೋಂದಣಿ" +uploading: "ಅಪ್‌ಲೋಡಾಗುತ್ತಿದೆ" +save: "ಉಳಿಸಿ" +users: "ಬಳಕೆದಾರ" +addUser: "ಬಳಕೆದಾರರನ್ನು ಸೇರಿಸಿ" +favorite: "ಮೆಚ್ಚಿನ" +favorites: "ಮೆಚ್ಚಿನವುಗಳು" +unfavorite: "ಮೆಚ್ಚುಗೆ ಅಳಿಸು" +pin: "ಪ್ರೊಫ಼ೈಲಿಗೆ ಅಂಟಿಸು" +unpin: "ಪ್ರೊಫ಼ೈಲಿಂದ ಅಂಟುತೆಗೆ" +copyContent: "ವಿಷಯವನ್ನು ನಕಲಿಸು" +copyLink: "ಲಿಂಕನ್ನು ನಕಲಿಸು" +delete: "ಅಳಿಸು" +addToList: "ಪಟ್ಟಿಗೆ ಸೇರಿಸು" +sendMessage: "ಸಂದೇಶ ಕಳುಹಿಸು" +copyUsername: "ಬಳಕೆಹೆಸರು ನಕಲಿಸು" +reply: "ಉತ್ತರಿಸು" +loadMore: "ಇನ್ನಷ್ಟು ನೋಡು" +showMore: "ಇನ್ನಷ್ಟು ನೋಡು" +youGotNewFollower: "ಹಿಂಬಾಲಿಸಿದರು" +receiveFollowRequest: "ಹಿಂಬಾಲನೆ ವಿನಂತಿ ಬಂದಿದೆ" +followRequestAccepted: "ಹಿಂಬಾಲನೆ ವಿನಂತಿ ಸ್ವೀಕರಿಸಲಾಯಿತು" +mentions: "ಹೆಸರಿಸಿದ" +directNotes: "ನೇರ ಟಿಪ್ಪಣಿಗಳು" +importAndExport: "ಆಮದು/ರಫ್ತು" +import: "ಆಮದು" +export: "ರಫ್ತು" +files: "ಕಡತಗಳು" +download: "ಜಾಲದಿಂದಿಳಿಸು" +driveFileDeleteConfirm: "\"{name}\" ಕಡತವನ್ನು ಅಳಿಸಲು ನೀವು ಬಯಸುವಿರಾ? ಈ ನೋಡಿರಿ ಲಗತ್ತಿಸಲಾದ ಟಿಪ್ಪಣಿ ಸಹ ಕಣ್ಮರೆಯಾಗುತ್ತದೆ." +unfollowConfirm: "{name}ಅನ್ನು ಹಿಂಬಾಲಿಸದಿರುವುದೇ?" +pinned: "ಪ್ರೊಫ಼ೈಲಿಗೆ ಅಂಟಿಸು" +instances: "ನಿದರ್ಶನ" +remove: "ಅಳಿಸು" +smtpUser: "ಬಳಕೆಹೆಸರು" +smtpPass: "ಗುಪ್ತಪದ" +user: "ಬಳಕೆದಾರ" +searchByGoogle: "ಹುಡುಕು" +file: "ಕಡತಗಳು" +_email: + _follow: + title: "ಹಿಂಬಾಲಿಸಿದರು" +_mfm: + search: "ಹುಡುಕು" +_sfx: + notification: "ಅಧಿಸೂಚನೆಗಳು" +_widgets: + notifications: "ಅಧಿಸೂಚನೆಗಳು" + timeline: "ಸಮಯಸಾಲು" +_cw: + show: "ಇನ್ನಷ್ಟು ನೋಡು" +_profile: + username: "ಬಳಕೆಹೆಸರು" +_notification: + youWereFollowed: "ಹಿಂಬಾಲಿಸಿದರು" + _actions: + reply: "ಉತ್ತರಿಸು" +_deck: + _columns: + notifications: "ಅಧಿಸೂಚನೆಗಳು" + tl: "ಸಮಯಸಾಲು" + mentions: "ಹೆಸರಿಸಿದ" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml new file mode 100644 index 0000000..9c967a7 --- /dev/null +++ b/locales/ko-KR.yml @@ -0,0 +1,1900 @@ +_lang_: "한국어" +headlineIceshrimp: "노트로 연결되는 네트워크" +introIceshrimp: "환영합니다! Iceshrimp 는 오픈 소스 분산형 마이크로 블로그 서비스입니다.\n\"노트\" 를 작성해서, 지금 + 일어나고 있는 일을 공유하거나, 당신만의 이야기를 모두에게 발신하세요📡\n\"리액션\" 기능으로, 친구의 노트에 총알같이 반응을 추가할 수도 있습니다👍\n + 새로운 세계를 탐험해 보세요🚀" +monthAndDay: "{month}월 {day}일" +search: "검색" +notifications: "알림" +username: "유저명" +password: "비밀번호" +forgotPassword: "비밀번호 재설정" +fetchingAsApObject: "연합에서 조회 중" +ok: "OK" +gotIt: "알겠어요" +cancel: "취소" +enterUsername: "유저명 입력" +renotedBy: "{user}님이 Renote" +noNotes: "노트가 없습니다" +noNotifications: "표시할 알림이 없습니다" +instance: "인스턴스" +settings: "설정" +basicSettings: "기본 설정" +otherSettings: "기타 설정" +openInWindow: "창으로 열기" +profile: "프로필" +timeline: "타임라인" +noAccountDescription: "자기소개가 없습니다" +login: "로그인" +loggingIn: "로그인 중" +logout: "로그아웃" +signup: "회원 가입" +uploading: "업로드 중" +save: "저장" +users: "유저" +addUser: "유저 추가" +favorite: "즐겨찾기" +favorites: "즐겨찾기" +unfavorite: "즐겨찾기에서 제거" +favorited: "즐겨찾기에 등록했습니다" +alreadyFavorited: "이미 즐겨찾기에 등록되어 있습니다" +cantFavorite: "즐겨찾기에 등록하지 못했습니다" +pin: "프로필에 고정" +unpin: "프로필에서 고정 해제" +copyContent: "내용 복사" +copyLink: "링크 복사" +delete: "삭제" +deleteAndEdit: "삭제 후 편집" +deleteAndEditConfirm: "이 노트를 삭제한 뒤 다시 편집하시겠습니까? 이 노트에 대한 리액션, 리노트, 답글 또한 모두 삭제됩니다." +addToList: "리스트에 추가" +sendMessage: "메시지 보내기" +copyUsername: "유저명 복사" +searchUser: "사용자 검색" +reply: "답글" +loadMore: "더 보기" +showMore: "더 보기" +showLess: "닫기" +youGotNewFollower: "새로운 팔로워가 있습니다" +receiveFollowRequest: "새로운 팔로우 요청이 있습니다" +followRequestAccepted: "팔로우가 수락되었습니다" +mention: "멘션" +mentions: "받은 멘션" +directNotes: "다이렉트 노트" +importAndExport: "가져오기와 내보내기" +import: "가져오기" +export: "내보내기" +files: "파일" +download: "다운로드" +driveFileDeleteConfirm: "파일 \"{name}\" 을 삭제하시겠습니까? 이 파일이 첨부된 노트도 함께 삭제됩니다." +unfollowConfirm: "{name}님을 언팔로우하시겠습니까?" +exportRequested: "내보내기를 요청하였습니다. 이 작업은 시간이 걸릴 수 있습니다. 내보내기가 완료되면 \"드라이브\"에 추가됩니다." +importRequested: "가져오기를 요청하였습니다. 이 작업에는 시간이 걸릴 수 있습니다." +lists: "리스트" +noLists: "리스트가 없습니다" +note: "노트" +notes: "노트" +following: "팔로잉" +followers: "팔로워" +followsYou: "당신을 팔로우합니다" +createList: "리스트 만들기" +manageLists: "리스트 관리" +error: "오류" +somethingHappened: "오류가 발생했습니다" +retry: "다시 시도" +pageLoadError: "페이지를 불러오지 못했습니다." +pageLoadErrorDescription: "네트워크 연결 또는 브라우저 캐시로 인해 발생했을 가능성이 높습니다. 캐시를 삭제하거나, 잠시 후 + 다시 시도해 주세요." +serverIsDead: "서버로부터 응답이 없습니다. 잠시 후 다시 시도해주세요." +youShouldUpgradeClient: "이 페이지를 표시하려면 새로고침하여 새로운 버전의 클라이언트를 이용해 주십시오." +enterListName: "리스트 이름을 입력" +privacy: "프라이버시" +makeFollowManuallyApprove: "팔로우를 수동으로 승인" +defaultNoteVisibility: "기본 공개 범위" +follow: "팔로우" +followRequest: "팔로우 요청" +followRequests: "팔로우 요청" +unfollow: "팔로우 해제" +followRequestPending: "팔로우 허가 대기중" +enterEmoji: "이모지 입력" +renote: "Renote" +unrenote: "Renote 취소" +renoted: "Renote 하였습니다" +cantRenote: "이 게시물은 Renote할 수 없습니다." +cantReRenote: "Renote를 Renote할 수 없습니다." +quote: "인용" +pinnedNote: "고정해놓은 노트" +pinned: "프로필에 고정" +you: "당신" +clickToShow: "클릭하여 보기" +sensitive: "열람주의" +add: "추가" +reaction: "리액션" +reactionSetting: "선택기에 표시할 리액션" +reactionSettingDescription2: "끌어서 순서 변경, 클릭해서 삭제, +를 눌러서 추가할 수 있습니다." +rememberNoteVisibility: "공개 범위를 기억하기" +attachCancel: "첨부 취소" +markAsSensitive: "열람주의로 설정" +unmarkAsSensitive: "열람주의 해제" +enterFileName: "파일명을 입력" +mute: "뮤트" +unmute: "뮤트 해제" +block: "차단" +unblock: "차단 해제" +suspend: "정지" +unsuspend: "정지 해제" +blockConfirm: "이 계정을 차단하시겠습니까?" +unblockConfirm: "이 계정의 차단을 해제하시겠습니까?" +suspendConfirm: "이 계정을 정지하시겠습니까?" +unsuspendConfirm: "이 계정의 정지를 해제하시겠습니까?" +selectList: "리스트 선택" +selectAntenna: "안테나 선택" +selectWidget: "위젯 선택" +editWidgets: "위젯 편집" +editWidgetsExit: "편집 종료" +customEmojis: "커스텀 이모지" +emoji: "이모지" +emojis: "이모지" +emojiName: "이모지 이름" +emojiUrl: "이모지 URL" +addEmoji: "이모지 추가" +settingGuide: "추천 설정" +cacheRemoteFiles: "리모트 파일을 캐시" +cacheRemoteFilesDescription: "이 설정을 해지하면 리모트 파일을 캐시하지 않고 해당 파일을 직접 링크하게 됩니다. 그에 따라 + 서버의 저장 공간을 절약할 수 있지만, 썸네일이 생성되지 않기 때문에 통신량이 증가합니다." +flagAsBot: "나는 봇입니다" +flagAsBotDescription: "이 계정을 자동화된 수단으로 운용할 경우에 활성화해 주세요. 이 플래그를 활성화하면, 다른 봇이 이를 참고하여 + 봇 끼리의 무한 연쇄 반응을 회피하거나, 이 계정의 시스템 상에서의 취급이 Bot 운영에 최적화되는 등의 변화가 생깁니다." +flagAsCat: "나는 고양이다냥" +flagAsCatDescription: "이 계정이 고양이라면 활성화 해주세요." +flagShowTimelineReplies: "타임라인에 노트의 답글을 표시하기" +flagShowTimelineRepliesDescription: "이 설정을 활성화하면 타임라인에 다른 유저 간의 답글을 표시합니다." +autoAcceptFollowed: "팔로우 중인 유저로부터의 팔로우 요청을 자동 수락" +addAccount: "계정 추가" +loginFailed: "로그인에 실패했습니다" +showOnRemote: "리모트에서 보기" +general: "일반" +wallpaper: "배경" +setWallpaper: "배경화면 설정" +removeWallpaper: "배경 제거" +searchWith: "검색: {q}" +youHaveNoLists: "리스트가 없습니다" +followConfirm: "{name}님을 팔로우 하시겠습니까?" +proxyAccount: "프록시 계정" +proxyAccountDescription: "프록시 계정은 특정 조건 하에서 유저의 리모트 팔로우를 대행하는 계정입니다. 예를 들면, 유저가 리모트 + 유저를 리스트에 넣었을 때, 리스트에 들어간 유저를 아무도 팔로우한 적이 없다면 액티비티가 인스턴스로 배달되지 않기 때문에, 대신 프록시 계정이 + 해당 유저를 팔로우하도록 합니다." +host: "호스트" +selectUser: "유저 선택" +recipient: "수신인" +annotation: "내용에 대한 주석" +federation: "연합" +instances: "인스턴스" +registeredAt: "등록 날짜" +latestRequestSentAt: "마지막으로 요청을 보낸 시간" +latestRequestReceivedAt: "마지막으로 요청을 받은 시간" +latestStatus: "마지막 상태" +storageUsage: "스토리지 사용량" +charts: "차트" +perHour: "1시간마다" +perDay: "1일마다" +stopActivityDelivery: "액티비티 보내지 않기" +blockThisInstance: "이 인스턴스를 차단" +operations: "작업" +software: "소프트웨어" +version: "버전" +metadata: "메타데이터" +monitor: "모니터" +jobQueue: "작업 대기열" +cpuAndMemory: "CPU와 메모리" +network: "네트워크" +disk: "디스크" +instanceInfo: "인스턴스 정보" +statistics: "통계" +clearQueue: "대기열 비우기" +clearQueueConfirmTitle: "대기열을 비우시겠습니까?" +clearQueueConfirmText: "대기열에 남아 있는 노트는 더이상 연합되지 않습니다. 보통의 경우 이 작업은 필요하지 않습니다." +clearCachedFiles: "캐시 비우기" +clearCachedFilesConfirm: "캐시된 리모트 파일을 모두 삭제하시겠습니까?" +blockedInstances: "차단된 인스턴스" +blockedInstancesDescription: "차단하려는 인스턴스의 호스트 이름을 줄바꿈으로 구분하여 설정합니다. 차단된 인스턴스는 이 인스턴스와 + 통신할 수 없게 됩니다." +muteAndBlock: "뮤트 및 차단" +mutedUsers: "뮤트한 유저" +blockedUsers: "차단한 유저" +noUsers: "아무도 없습니다" +editProfile: "프로필 수정" +noteDeleteConfirm: "이 노트를 삭제하시겠습니까?" +pinLimitExceeded: "더 이상 고정할 수 없습니다." +intro: "Iceshrimp의 설치가 완료되었습니다! 관리자 계정을 생성해주세요." +done: "완료" +processing: "처리중" +preview: "미리보기" +default: "기본값" +defaultValueIs: "기본값: {value}" +noCustomEmojis: "이모지가 없습니다" +noJobs: "작업이 없습니다" +federating: "연합 중" +blocked: "차단됨" +suspended: "정지됨" +all: "전체" +subscribing: "구독 중" +publishing: "배포 중" +notResponding: "응답 없음" +instanceFollowing: "인스턴스의 팔로잉" +instanceFollowers: "인스턴스의 팔로워" +instanceUsers: "인스턴스의 유저" +changePassword: "비밀번호 변경" +security: "보안" +retypedNotMatch: "입력이 일치하지 않습니다." +currentPassword: "현재 비밀번호" +newPassword: "새 비밀번호" +newPasswordRetype: "새 비밀번호 (재입력)" +attachFile: "파일 첨부" +more: "더보기!" +featured: "하이라이트" +usernameOrUserId: "유저명이나 ID" +noSuchUser: "유저를 찾을 수 없습니다" +lookup: "조회" +announcements: "공지사항" +imageUrl: "이미지 URL" +remove: "삭제" +removed: "삭제하였습니다" +removeAreYouSure: "\"{x}\" 을(를) 삭제하시겠습니까?" +deleteAreYouSure: "\"{x}\" 을(를) 삭제하시겠습니까?" +resetAreYouSure: "초기화 하시겠습니까?" +saved: "저장하였습니다" +messaging: "대화" +upload: "업로드" +keepOriginalUploading: "원본 이미지를 유지" +keepOriginalUploadingDescription: "이미지를 업로드할 때에 원본을 그대로 유지합니다. 비활성화하면 업로드할 때 브라우저에서 + 웹 공개용 이미지를 생성합니다." +fromDrive: "드라이브에서" +fromUrl: "URL로부터" +uploadFromUrl: "URL 업로드" +uploadFromUrlDescription: "업로드하려는 파일의 URL" +uploadFromUrlRequested: "업로드를 요청했습니다" +uploadFromUrlMayTakeTime: "업로드가 완료될 때까지 시간이 소요될 수 있습니다." +explore: "발견하기" +messageRead: "읽음" +noMoreHistory: "이것보다 과거의 기록이 없습니다" +startMessaging: "대화 시작하기" +nUsersRead: "{n}명이 읽음" +agreeTo: "{0}에 동의" +tos: "이용 약관" +start: "시작하기" +home: "홈" +remoteUserCaution: "리모트 유저이기 때문에, 정보가 정확하지 않을 수 있습니다." +activity: "활동" +images: "이미지" +birthday: "생일" +yearsOld: "{age}세" +registeredDate: "등록일" +location: "장소" +theme: "테마" +themeForLightMode: "라이트 모드에서 사용할 테마" +themeForDarkMode: "다크 모드에서 사용할 테마" +light: "라이트" +dark: "다크" +lightThemes: "밝은 테마" +darkThemes: "어두운 테마" +syncDeviceDarkMode: "디바이스의 다크 모드 설정과 동기화" +drive: "드라이브" +fileName: "파일명" +selectFile: "파일 선택" +selectFiles: "파일 선택" +selectFolder: "폴더 선택" +selectFolders: "폴더 선택" +renameFile: "파일 이름 변경" +folderName: "폴더명" +createFolder: "폴더 만들기" +renameFolder: "폴더 이름 바꾸기" +deleteFolder: "폴더 삭제" +addFile: "파일 추가" +emptyDrive: "드라이브가 비어 있습니다" +emptyFolder: "폴더가 비어 있습니다" +unableToDelete: "삭제할 수 없습니다" +inputNewFileName: "바꿀 파일명을 입력해 주세요" +inputNewDescription: "새 캡션을 입력해 주세요" +inputNewFolderName: "바꿀 폴더명을 입력해 주세요" +circularReferenceFolder: "지정한 폴더가 이동할 폴더의 하위 폴더입니다." +hasChildFilesOrFolders: "이 폴더는 비어있지 않기 때문에 삭제할 수 없습니다." +copyUrl: "URL 복사" +rename: "이름 변경" +avatar: "아바타" +banner: "배너" +nsfw: "열람주의" +whenServerDisconnected: "서버와의 접속이 끊겼을 때" +disconnectedFromServer: "서버와의 연결이 끊어졌습니다" +reload: "새로고침" +doNothing: "무시하기" +reloadConfirm: "새로고침 하시겠습니까?" +watch: "지켜보기" +unwatch: "지켜보기 해제" +accept: "허가" +reject: "거부" +normal: "정상" +instanceName: "인스턴스 이름" +instanceDescription: "인스턴스 소개" +maintainerName: "관리자 이름" +maintainerEmail: "관리자 이메일" +tosUrl: "이용약관 URL" +thisYear: "올해" +thisMonth: "이번 달" +today: "오늘" +dayX: "{day}일" +monthX: "{month}월" +yearX: "{year}년" +pages: "페이지" +integration: "연동" +connectService: "계정 연동" +disconnectService: "계정 연동 해제" +enableLocalTimeline: "로컬 타임라인 활성화" +enableGlobalTimeline: "글로벌 타임라인 활성화" +disablingTimelinesInfo: "특정 타임라인을 비활성화하더라도 관리자 및 모더레이터는 계속 사용할 수 있습니다." +registration: "등록" +enableRegistration: "신규 회원가입을 활성화" +invite: "초대" +driveCapacityPerLocalAccount: "로컬 유저 한 명당 드라이브 용량" +driveCapacityPerRemoteAccount: "리모트 유저 한 명당 드라이브 용량" +inMb: "메가바이트 단위" +iconUrl: "아이콘 URL" +bannerUrl: "배너 이미지 URL" +backgroundImageUrl: "배경 이미지 URL" +basicInfo: "기본 정보" +pinnedUsers: "고정된 유저" +pinnedUsersDescription: "\"발견하기\" 페이지 등에 고정하고 싶은 유저를 한 줄에 한 명씩 적습니다." +pinnedPages: "고정한 페이지" +pinnedPagesDescription: "인스턴스의 대문에 고정하고 싶은 페이지의 경로를 한 줄에 하나씩 적습니다." +pinnedClipId: "고정할 클립의 ID" +pinnedNotes: "고정해놓은 노트" +hcaptcha: "hCaptcha" +enableHcaptcha: "hCaptcha 활성화" +hcaptchaSiteKey: "사이트 키" +hcaptchaSecretKey: "시크릿 키" +recaptcha: "reCAPTCHA" +enableRecaptcha: "reCAPTCHA 활성화" +recaptchaSiteKey: "사이트 키" +recaptchaSecretKey: "시크릿 키" +avoidMultiCaptchaConfirm: "여러 Captcha를 사용하는 경우 간섭이 발생할 가능성이 있습니다. 다른 Captcha를 비활성화하시겠습니까? + 취소를 눌러 여러 Captcha를 활성화한 상태로 두는 것도 가능합니다." +antennas: "안테나" +manageAntennas: "안테나 관리" +name: "이름" +antennaSource: "받을 소스" +antennaKeywords: "받을 키워드" +antennaExcludeKeywords: "제외할 키워드" +antennaKeywordsDescription: "공백으로 구분하는 경우 AND, 줄바꿈으로 구분하는 경우 OR로 지정됩니다" +notifyAntenna: "새로운 노트를 알림" +withFileAntenna: "파일이 첨부된 노트만" +enableServiceworker: "ServiceWorker 사용" +antennaUsersDescription: "유저명을 한 줄에 한 명씩 적습니다" +caseSensitive: "대소문자를 구분" +withReplies: "답글 포함" +connectedTo: "다음 계정에 연결되어 있습니다" +notesAndReplies: "글과 답글" +withFiles: "미디어" +silence: "사일런스" +silenceConfirm: "이 계정을 사일런스로 설정하시겠습니까?" +unsilence: "사일런스 해제" +unsilenceConfirm: "이 계정의 사일런스를 해제하시겠습니까?" +popularUsers: "인기 유저" +recentlyUpdatedUsers: "최근 활동한 유저" +recentlyRegisteredUsers: "최근 가입한 유저" +recentlyDiscoveredUsers: "최근 발견한 유저" +exploreUsersCount: "{count}명의 유저가 있습니다" +exploreFediverse: "연합우주를 탐색" +popularTags: "인기 태그" +userList: "리스트" +about: "정보" +aboutIceshrimp: "Iceshrimp에 대하여" +administrator: "관리자" +token: "토큰" +twoStepAuthentication: "2단계 인증" +moderator: "모더레이터" +moderation: "모더레이션" +nUsersMentioned: "{n}명이 언급함" +securityKey: "보안 키" +securityKeyName: "키 이름" +registerSecurityKey: "보안 키를 등록" +lastUsed: "마지막 사용" +unregister: "등록 해제" +passwordLessLogin: "비밀번호 없이 로그인" +resetPassword: "비밀번호 재설정" +newPasswordIs: "새로운 비밀번호는 \"{password}\" 입니다" +reduceUiAnimation: "UI의 애니메이션을 줄이기" +share: "공유" +notFound: "찾을 수 없습니다" +notFoundDescription: "지정한 URL에 해당하는 페이지가 존재하지 않습니다." +uploadFolder: "기본 업로드 위치" +cacheClear: "캐시 지우기" +markAsReadAllNotifications: "모든 알림을 읽은 상태로 표시" +markAsReadAllUnreadNotes: "모든 글을 읽은 상태로 표시" +markAsReadAllTalkMessages: "모든 대화를 읽은 상태로 표시" +help: "도움말" +inputMessageHere: "여기에 메시지를 입력하세요" +close: "닫기" +group: "그룹" +groups: "그룹" +createGroup: "그룹 만들기" +ownedGroups: "소유 그룹" +joinedGroups: "참여중인 그룹" +invites: "초대" +groupName: "그룹명" +members: "멤버" +transfer: "양도" +messagingWithUser: "유저와 대화하기" +messagingWithGroup: "그룹끼리 대화하기" +title: "제목" +text: "텍스트" +enable: "사용" +next: "다음" +retype: "다시 입력" +noteOf: "{user}의 노트" +inviteToGroup: "그룹에 초대하기" +quoteAttached: "인용함" +quoteQuestion: "인용해서 작성하시겠습니까?" +noMessagesYet: "아직 대화가 없습니다" +newMessageExists: "새 메시지가 있습니다" +onlyOneFileCanBeAttached: "메시지에 첨부할 수 있는 파일은 하나까지입니다" +signinRequired: "로그인 해주세요" +invitations: "초대" +invitationCode: "초대 코드" +checking: "확인하는 중입니다" +available: "사용 가능합니다" +unavailable: "사용할 수 없습니다" +usernameInvalidFormat: "a~z, A~Z, 0-9, _를 사용할 수 있습니다" +tooShort: "너무 짧습니다" +tooLong: "너무 깁니다" +weakPassword: "약한 비밀번호" +normalPassword: "좋은 비밀번호" +strongPassword: "강한 비밀번호" +passwordMatched: "일치합니다" +passwordNotMatched: "일치하지 않습니다" +signinWith: "{x}로 로그인" +signinFailed: "로그인할 수 없습니다. 사용자명과 비밀번호를 확인하여 주십시오." +tapSecurityKey: "보안 키를 터치" +or: "혹은" +language: "언어" +uiLanguage: "UI 표시 언어" +groupInvited: "그룹에 초대되었습니다" +aboutX: "{x}에 대하여" +useOsNativeEmojis: "OS 기본 이모지를 사용" +disableDrawer: "드로어 메뉴를 사용하지 않기" +youHaveNoGroups: "그룹이 없습니다" +joinOrCreateGroup: "다른 그룹의 초대를 받거나, 직접 새 그룹을 만들어 보세요." +noHistory: "기록이 없습니다" +signinHistory: "로그인 기록" +disableAnimatedMfm: "움직임이 있는 MFM을 비활성화" +doing: "잠시만요" +category: "카테고리" +tags: "태그" +docSource: "이 문서의 소스" +createAccount: "계정 만들기" +existingAccount: "기존 계정" +regenerate: "재생성" +fontSize: "글자 크기" +noFollowRequests: "처리되지 않은 팔로우 요청이 없습니다" +openImageInNewTab: "새 탭에서 이미지 열기" +dashboard: "대시보드" +local: "로컬" +remote: "리모트" +total: "합계" +weekOverWeekChanges: "지난주보다" +dayOverDayChanges: "어제보다" +appearance: "모양" +clientSettings: "클라이언트 설정" +accountSettings: "계정 설정" +promotion: "프로모션" +promote: "프로모션하기" +numberOfDays: "며칠동안" +hideThisNote: "이 노트를 숨기기" +showFeaturedNotesInTimeline: "타임라인에 추천 노트를 표시" +objectStorage: "오브젝트 스토리지" +useObjectStorage: "오브젝트 스토리지를 사용" +objectStorageBaseUrl: "Base URL" +objectStorageBaseUrlDesc: "오브젝트 (미디어) 참조 URL 을 만들 때 사용되는 URL입니다. CDN 또는 프록시를 사용하는 + 경우 그 URL을 지정하고, 그 외의 경우 사용할 서비스의 가이드에 따라 공개적으로 액세스 할 수 있는 주소를 지정해 주세요. 예를 들어, AWS + S3의 경우 'https://.s3.amazonaws.com', GCS등의 경우 'https://storage.googleapis.com/' + 와 같이 지정합니다." +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "사용 서비스의 bucket명을 지정해주세요." +objectStoragePrefix: "Prefix" +objectStoragePrefixDesc: "이 Prefix 의 디렉토리 아래에 파일이 저장됩니다." +objectStorageEndpoint: "Endpoint" +objectStorageEndpointDesc: "AWS S3의 경우 공란, 다른 서비스의 경우 각 서비스의 가이드에 맞게 endpoint를 설정해주세요. + '' 혹은 ':' 와 같이 지정합니다." +objectStorageRegion: "Region" +objectStorageRegionDesc: "'xx-east-1'와 같이 region을 지정해주세요. 사용하는 서비스에 region 개념이 없는 + 경우, 비워 두거나 'us-east-1'으로 설정해 주세요." +objectStorageUseSSL: "SSL 사용" +objectStorageUseSSLDesc: "API 호출시 HTTPS 를 사용하지 않는 경우 OFF 로 설정해 주세요" +objectStorageUseProxy: "연결에 프록시를 사용" +objectStorageUseProxyDesc: "오브젝트 스토리지 API 호출시 프록시를 사용하지 않는 경우 OFF 로 설정해 주세요" +objectStorageSetPublicRead: "업로드할 때 'public-read'를 설정하기" +serverLogs: "서버 로그" +deleteAll: "모두 삭제" +showFixedPostForm: "타임라인 상단에 글 작성란을 표시" +newNoteRecived: "새 노트가 있습니다" +sounds: "소리" +listen: "듣기" +none: "없음" +showInPage: "페이지로 보기" +popout: "새 창으로 열기" +volume: "음량" +masterVolume: "마스터 볼륨" +details: "자세히" +chooseEmoji: "이모지 선택" +unableToProcess: "작업을 완료할 수 없습니다" +recentUsed: "최근 사용" +install: "설치" +uninstall: "삭제" +installedApps: "인증된 애플리케이션" +nothing: "아무것도 없습니다" +installedDate: "승인한 날짜" +lastUsedDate: "마지막 사용" +state: "상태" +sort: "정렬" +ascendingOrder: "오름차순" +descendingOrder: "내림차순" +scratchpad: "스크래치 패드" +scratchpadDescription: "스크래치 패드는 AiScript 의 테스트 환경을 제공합니다. Iceshrimp 와 상호 작용하는 코드를 + 작성, 실행 및 결과를 확인할 수 있습니다." +output: "출력" +script: "스크립트" +disablePagesScript: "Pages 에서 AiScript 를 사용하지 않음" +updateRemoteUser: "리모트 유저 정보 갱신" +deleteAllFiles: "모든 파일 삭제" +deleteAllFilesConfirm: "모든 파일을 삭제하시겠습니까?" +removeAllFollowing: "모든 팔로잉 해제" +removeAllFollowingDescription: "{host}(으)로부터 모든 팔로잉을 해제합니다. 해당 인스턴스가 더 이상 존재하지 않게 + 된 경우 등에 실행해 주세요." +userSuspended: "이 계정은 정지된 상태입니다." +userSilenced: "이 계정은 사일런스된 상태입니다." +yourAccountSuspendedTitle: "계정이 정지되었습니다" +yourAccountSuspendedDescription: "이 계정은 서버의 이용 약관을 위반하거나, 기타 다른 이유로 인해 정지되었습니다. 자세한 + 사항은 관리자에게 문의해 주십시오. 계정을 새로 생성하지 마십시오." +menu: "메뉴" +divider: "구분선" +addItem: "항목 추가" +relays: "릴레이" +addRelay: "릴레이 추가" +inboxUrl: "Inbox 주소" +addedRelays: "추가된 릴레이" +serviceworkerInfo: "푸시 알림을 수행하려면 활성화해야 합니다." +deletedNote: "삭제된 노트" +invisibleNote: "비공개 노트" +enableInfiniteScroll: "자동으로 좀 더 보기" +visibility: "공개 범위" +poll: "투표" +useCw: "내용 숨기기" +enablePlayer: "플레이어 열기" +disablePlayer: "플레이어 닫기" +expandTweet: "트윗 확장하기" +themeEditor: "테마 에디터" +description: "설명" +describeFile: "캡션 추가" +enterFileDescription: "캡션 입력" +author: "작성자" +leaveConfirm: "저장하지 않은 변경사항이 있습니다. 취소하시겠습니까?" +manage: "관리" +plugins: "플러그인" +preferencesBackups: "환경설정 백업" +deck: "덱" +undeck: "덱 해제" +useBlurEffectForModal: "모달에 흐림 효과 사용" +useFullReactionPicker: "모든 기능이 포함된 리액션 선택기 사용" +width: "폭" +height: "높이" +large: "크게" +medium: "보통" +small: "작게" +generateAccessToken: "액세스 토큰 생성" +permission: "권한" +enableAll: "전체 선택" +disableAll: "전체 해제" +tokenRequested: "계정 접근 허용" +pluginTokenRequestedDescription: "이 플러그인은 여기서 설정한 권한을 사용할 수 있게 됩니다." +notificationType: "알림 유형" +edit: "편집" +emailServer: "메일 서버" +enableEmail: "이메일 송신 기능 활성화" +emailConfigInfo: "가입 시 메일 주소 확인이나 비밀번호 초기화 시에 사용합니다." +email: "이메일" +emailAddress: "메일 주소" +smtpConfig: "SMTP 서버 설정" +smtpHost: "호스트" +smtpPort: "포트" +smtpUser: "유저명" +smtpPass: "비밀번호" +emptyToDisableSmtpAuth: "SMTP 인증을 사용하지 않으려면 공란으로 비워둡니다." +smtpSecure: "SMTP 연결에 Implicit SSL/TTS 사용" +smtpSecureInfo: "STARTTLS 사용 시에는 해제합니다." +testEmail: "이메일 전송 테스트" +wordMute: "단어 뮤트" +regexpError: "정규 표현식 오류" +regexpErrorDescription: "{tab}단어 뮤트 {line}행의 정규 표현식에 오류가 발생했습니다:" +instanceMute: "인스턴스 뮤트" +userSaysSomething: "{name}님이 무언가를 말했습니다" +makeActive: "활성화" +display: "표시" +copy: "복사" +metrics: "통계" +overview: "요약" +logs: "로그" +delayed: "지연" +database: "데이터베이스" +channel: "채널" +create: "생성" +notificationSetting: "알림 설정" +notificationSettingDesc: "표시할 알림의 종류를 선택해 주세요." +useGlobalSetting: "글로벌 설정을 사용하기" +useGlobalSettingDesc: "활성화하면 계정의 알림 설정이 적용됩니다. 비활성화하면 개별적으로 설정할 수 있게 됩니다." +other: "기타" +regenerateLoginToken: "로그인 토큰을 재생성" +regenerateLoginTokenDescription: "로그인할 때 사용되는 내부 토큰을 재생성합니다. 일반적으로 이 작업을 실행할 필요는 없습니다. + 이 기능을 사용하면 이 계정으로 로그인한 모든 기기에서 로그아웃됩니다." +setMultipleBySeparatingWithSpace: "공백으로 구분하여 여러 개 설정할 수 있습니다." +fileIdOrUrl: "파일 ID 또는 URL" +behavior: "동작" +sample: "예시" +abuseReports: "신고" +reportAbuse: "신고" +reportAbuseOf: "{name}을 신고하기" +fillAbuseReportDescription: "신고하려는 이유를 자세히 알려주세요. 특정 게시물을 신고할 때에는 게시물의 URL도 포함해 주세요." +abuseReported: "신고를 보냈습니다. 신고해 주셔서 감사합니다." +reporter: "신고자" +reporteeOrigin: "피신고자" +reporterOrigin: "신고자" +forwardReport: "리모트 인스턴스에도 신고 내용 보내기" +forwardReportIsAnonymous: "리모트 인스턴스에서는 나의 정보를 볼 수 없으며, 익명의 시스템 계정으로 표시됩니다." +send: "전송" +abuseMarkAsResolved: "해결됨으로 표시" +openInNewTab: "새 탭에서 열기" +openInSideView: "사이드뷰로 열기" +defaultNavigationBehaviour: "기본 탐색 동작" +editTheseSettingsMayBreakAccount: "이 설정을 변경하면 계정이 손상될 수 있습니다." +instanceTicker: "노트의 인스턴스 정보" +waitingFor: "{x}을(를) 기다리고 있습니다" +random: "랜덤" +system: "시스템" +switchUi: "UI 전환" +desktop: "데스크탑" +clip: "클립" +createNew: "새로 만들기" +optional: "옵션" +createNewClip: "새 클립 만들기" +unclip: "클립 해제" +confirmToUnclipAlreadyClippedNote: "이 노트는 이미 \"{name}\" 클립에 포함되어 있습니다. 클립을 해제하시겠습니까?" +public: "공개" +i18nInfo: "Iceshrimp는 자원봉사자들에 의해 다양한 언어로 번역되고 있습니다. {link}에서 번역에 참가할 수 있습니다." +manageAccessTokens: "액세스 토큰 관리" +accountInfo: "계정 정보" +notesCount: "노트 수" +repliesCount: "답글 수" +renotesCount: "Renote 수" +repliedCount: "받은 답글 수" +renotedCount: "받은 Renote 수" +followingCount: "팔로우 수" +followersCount: "팔로워 수" +sentReactionsCount: "보낸 리액션 수" +receivedReactionsCount: "받은 리액션 수" +pollVotesCount: "투표한 횟수" +pollVotedCount: "투표받은 횟수" +yes: "예" +no: "아니오" +driveFilesCount: "드라이브 파일 개수" +driveUsage: "드라이브 사용량" +noCrawle: "검색엔진의 인덱싱 거부" +noCrawleDescription: "검색엔진에 사용자 페이지, 노트, 페이지 등의 콘텐츠를 인덱싱되지 않게 합니다." +lockedAccountInfo: "팔로우를 승인으로 승인받더라도 노트의 공개 범위를 '팔로워'로 하지 않는 한 누구나 당신의 노트를 볼 수 있습니다." +alwaysMarkSensitive: "미디어를 항상 열람 주의로 설정" +loadRawImages: "첨부한 이미지의 썸네일을 원본화질로 표시" +disableShowingAnimatedImages: "움직이는 이미지를 자동으로 재생하지 않음" +verificationEmailSent: "확인 메일을 발송하였습니다. 설정을 완료하려면 메일에 첨부된 링크를 확인해 주세요." +notSet: "설정되지 않음" +emailVerified: "메일 주소가 확인되었습니다." +noteFavoritesCount: "즐겨찾기한 노트 수" +pageLikesCount: "좋아요 한 Page 수" +pageLikedCount: "Page에 받은 좋아요 수" +contact: "연락처" +useSystemFont: "시스템 기본 글꼴을 사용" +clips: "클립" +experimentalFeatures: "실험실" +developer: "개발자" +makeExplorable: "\"발견하기\"에 내 계정 보이기" +makeExplorableDescription: "비활성화하면 \"발견하기\"에 나의 계정을 표시하지 않습니다." +showGapBetweenNotesInTimeline: "타임라인의 노트 사이를 띄워서 표시" +duplicate: "복제" +left: "왼쪽" +center: "가운데" +wide: "넓게" +narrow: "좁게" +reloadToApplySetting: "이 설정을 적용하려면 페이지를 새로고침해야 합니다. 바로 새로고침하시겠습니까?" +needReloadToApply: "변경 사항은 새로고침하면 적용됩니다." +showTitlebar: "타이틀 바를 표시하기" +clearCache: "캐시 비우기" +onlineUsersCount: "{n}명이 접속 중" +nUsers: "{n} 유저" +nNotes: "{n} 노트" +sendErrorReports: "오류 보고서 보내기" +sendErrorReportsDescription: "이 설정을 활성화하면, 문제가 발생했을 때 오류에 대한 상세 정보를 Iceshrimp에 보내어 + 더 나은 소프트웨어를 만드는 데에 도움을 줄 수 있습니다." +myTheme: "내 테마" +backgroundColor: "배경 색" +accentColor: "강조 색상" +textColor: "문자 색" +saveAs: "다른 이름으로 저장" +advanced: "고급" +value: "값" +createdAt: "생성된 날짜" +updatedAt: "수정한 날짜" +saveConfirm: "저장하시겠습니까?" +deleteConfirm: "삭제하시겠습니까?" +invalidValue: "올바른 값이 아닙니다." +registry: "레지스트리" +closeAccount: "계정 폐쇄" +currentVersion: "현재 버전" +latestVersion: "최신 버전" +youAreRunningUpToDateClient: "사용 중인 클라이언트는 최신입니다." +newVersionOfClientAvailable: "새로운 버전의 클라이언트를 이용할 수 있습니다." +usageAmount: "사용량" +capacity: "용량" +inUse: "사용중" +editCode: "코드 수정" +apply: "적용" +receiveAnnouncementFromInstance: "이 인스턴스의 알림을 이메일로 수신할게요" +emailNotification: "메일 알림" +publish: "게시" +inChannelSearch: "채널에서 검색" +useReactionPickerForContextMenu: "우클릭하여 리액션 선택기 열기" +typingUsers: "{users} 님이 입력하고 있어요" +jumpToSpecifiedDate: "특정 날짜로 이동" +showingPastTimeline: "과거의 타임라인을 표시하고 있어요" +clear: "지우기" +markAllAsRead: "모두 읽은 상태로 표시" +goBack: "뒤로" +unlikeConfirm: "좋아요를 취소할까요?" +fullView: "전체 화면" +quitFullView: "전체 화면 해제" +addDescription: "설명 추가" +userPagePinTip: "각 노트의 메뉴에서 「프로필에 고정」을 선택하는 것으로, 여기에 노트를 표시해 둘 수 있어요." +notSpecifiedMentionWarning: "수신자가 선택되지 않은 멘션이 있어요" +info: "정보" +userInfo: "유저 정보" +unknown: "알 수 없음" +onlineStatus: "온라인 상태" +hideOnlineStatus: "온라인 상태 숨기기" +hideOnlineStatusDescription: "온라인 상태를 숨기면, 검색과 같은 일부 기능에 영향을 미칠 수 있습니다." +online: "온라인" +active: "최근에 활동함" +offline: "오프라인" +notRecommended: "추천하지 않음" +botProtection: "Bot 방어" +instanceBlocking: "인스턴스 차단" +selectAccount: "계정 선택" +switchAccount: "계정 바꾸기" +enabled: "활성화" +disabled: "비활성화" +quickAction: "빠른 동작" +user: "유저" +administration: "관리" +accounts: "계정" +switch: "전환" +noMaintainerInformationWarning: "관리자 정보가 설정되어 있지 않습니다." +noBotProtectionWarning: "Bot 방어가 설정되어 있지 않습니다." +configure: "설정하기" +postToGallery: "갤러리에 업로드" +gallery: "갤러리" +recentPosts: "최근 포스트" +popularPosts: "인기 포스트" +shareWithNote: "노트로 공유" +ads: "광고" +expiration: "기한" +memo: "메모" +priority: "우선순위" +high: "높음" +middle: "보통" +low: "낮음" +emailNotConfiguredWarning: "메일 주소가 설정되어 있지 않습니다." +ratio: "비율" +previewNoteText: "본문 미리보기" +customCss: "CSS 사용자화" +customCssWarn: "이 설정은 기능을 알고 있는 경우에만 사용해야 합니다. 잘못된 값을 입력하면 클라이언트가 정상적으로 작동하지 않을 수 + 있습니다." +global: "글로벌" +squareAvatars: "프로필 아이콘을 사각형으로 표시" +sent: "전송" +received: "수신" +searchResult: "검색 결과" +hashtags: "해시태그" +troubleshooting: "문제 해결" +useBlurEffect: "UI에 흐림 효과 사용" +learnMore: "자세히" +iceshrimpUpdated: "Iceshrimp가 업데이트 되었습니다!" +whatIsNew: "패치 정보 보기" +translate: "번역" +translatedFrom: "{x}에서 번역" +accountDeletionInProgress: "계정 삭제 작업을 진행하고 있습니다" +usernameInfo: "서버상에서 계정을 식별하기 위한 이름. 알파벳(a~z, A~Z), 숫자(0~9) 및 언더바(_)를 사용할 수 있습니다. + 사용자명은 나중에 변경할 수 없습니다." +aiChanMode: "아이 모드" +keepCw: "CW 유지하기" +pubSub: "Pub/Sub 계정" +lastCommunication: "마지막 통신" +resolved: "해결됨" +unresolved: "해결되지 않음" +breakFollow: "팔로워 해제" +itsOn: "켜짐" +itsOff: "꺼짐" +emailRequiredForSignup: "가입할 때 이메일 주소 입력을 필수로 하기" +unread: "읽지 않음" +filter: "필터" +controlPanel: "제어판" +manageAccounts: "계정 관리" +makeReactionsPublic: "리액션 목록을 공개하기" +makeReactionsPublicDescription: "나의 리액션을 누구나 볼 수 있게 합니다." +classic: "클래식" +muteThread: "이 글타래를 뮤트" +unmuteThread: "글타래 뮤트 해제" +ffVisibility: "내 인맥의 공개 범위" +ffVisibilityDescription: "나의 팔로우와 팔로워 정보에 대한 공개 범위를 설정할 수 있습니다." +continueThread: "이 글타래 이어서 보기" +deleteAccountConfirm: "계정이 삭제되고 되돌릴 수 없게 됩니다. 계속하시겠습니까? " +incorrectPassword: "비밀번호가 올바르지 않습니다." +voteConfirm: "\"{choice}\"에 투표하시겠습니까?" +hide: "숨기기" +leaveGroup: "그룹 나가기" +leaveGroupConfirm: "\"{name}\"에서 나갈까요?" +useDrawerReactionPickerForMobile: "모바일에서 드로어 메뉴로 표시" +clickToFinishEmailVerification: "[{ok}]를 눌러 이메일 인증을 완료하세요." +overridedDeviceKind: "장치 유형" +smartphone: "스마트폰" +tablet: "태블릿" +auto: "자동" +themeColor: "테마 컬러" +size: "크기" +numberOfColumn: "한 줄에 보일 리액션의 수" +searchByGoogle: "검색" +instanceDefaultLightTheme: "인스턴스 기본 라이트 테마" +instanceDefaultDarkTheme: "인스턴스 기본 다크 테마" +instanceDefaultThemeDescription: "객체 형식의 테마 코드를 입력해 주세요." +mutePeriod: "뮤트할 기간" +indefinitely: "무기한" +tenMinutes: "10분" +oneHour: "1시간" +oneDay: "1일" +oneWeek: "일주일" +reflectMayTakeTime: "반영되기까지 시간이 걸릴 수 있습니다." +failedToFetchAccountInformation: "계정 정보를 가져오지 못했습니다" +rateLimitExceeded: "요청 제한 횟수를 초과하였습니다" +cropImage: "이미지 자르기" +cropImageAsk: "이미지를 자르시겠습니까?" +file: "파일" +recentNHours: "최근 {n}시간" +recentNDays: "최근 {n}일" +noEmailServerWarning: "메일 서버가 설정되어 있지 않습니다." +thereIsUnresolvedAbuseReportWarning: "해결되지 않은 신고가 있습니다." +recommended: "추천" +check: "체크" +driveCapOverrideLabel: "이 유저의 드라이브 용량을 변경" +driveCapOverrideCaption: "0 이하를 지정하면 해제됩니다." +requireAdminForView: "열람하려면 관리자 계정으로 로그인해야 합니다." +isSystemAccount: "시스템에 의해 자동으로 생성되어 관리되는 계정입니다." +typeToConfirm: "계속하시려면 {x} 을 입력하세요" +deleteAccount: "계정 삭제" +document: "문서" +numberOfPageCache: "페이지 캐시 수" +numberOfPageCacheDescription: "숫자가 클 수록 편리성이 높아지지만, 시스템 자원과 메모리를 더 많이 사용합니다." +logoutConfirm: "로그아웃 하시겠습니까?" +lastActiveDate: "마지막 이용" +pleaseSelect: "선택해 주세요" +reverse: "플립" +colored: "색 입히기" +refreshInterval: "업데이트 주기" +label: "라벨" +type: "종류" +speed: "속도" +slow: "느리게" +fast: "빠르게" +sensitiveMediaDetection: "민감한 미디어 탐지" +localOnly: "로컬에만" +remoteOnly: "리모트만" +failedToUpload: "업로드 실패" +cannotUploadBecauseInappropriate: "이 파일은 부적절한 내용을 포함한다고 판단되어 업로드할 수 없습니다." +cannotUploadBecauseNoFreeSpace: "드라이브 용량이 부족하여 업로드할 수 없습니다." +beta: "베타" +enableAutoSensitive: "자동 NSFW 탐지" +enableAutoSensitiveDescription: "이용 가능할 경우 기계학습을 통해 자동으로 미디어 NSFW를 설정합니다. 이 기능을 해제하더라도, + 인스턴스 정책에 따라 자동으로 설정될 수 있습니다." +activeEmailValidationDescription: "유저가 입력한 메일 주소가 일회용 메일인지, 실제로 통신할 수 있는 지 엄격하게 검사합니다. + 해제할 경우 이메일 형식에 대해서만 검사합니다." +navbar: "네비게이션 바" +shuffle: "셔플" +account: "계정" +move: "이동" +_sensitiveMediaDetection: + description: "기계학습을 통해 자동으로 민감한 미디어를 탐지하여, 모더레이션에 참고할 수 있도록 합니다. 서버의 부하를 약간 증가시킵니다." + sensitivity: "탐지 민감도" + sensitivityDescription: "민감도가 낮을수록 안전한 미디어가 잘못 탐지될 확률이 줄어들며, 높을수록 민감한 미디어가 탐지되지 + 않을 확률이 줄어듭니다." + setSensitiveFlagAutomatically: "자동으로 NSFW로 설정하기" + setSensitiveFlagAutomaticallyDescription: "이 설정을 해제해도 탐지 결과는 유지됩니다." + analyzeVideos: "동영상도 같이 확인하기" + analyzeVideosDescription: "사진 뿐만 아니라 동영상의 NSFW 여부도 탐지합니다. 서버의 부하를 약간 증가시킵니다." +_emailUnavailable: + used: "이 메일 주소는 사용중입니다" + format: "형식이 올바르지 않습니다" + disposable: "임시 이메일 주소는 사용할 수 없습니다" + mx: "메일 서버가 올바르지 않습니다" + smtp: "메일 서버가 응답하지 않습니다" +_ffVisibility: + public: "공개" + followers: "팔로워에게만 공개" + private: "비공개" +_signup: + almostThere: "거의 다 끝났습니다" + emailAddressInfo: "당신이 사용하고 있는 이메일 주소를 입력해 주세요. 이메일 주소는 다른 유저에게 공개되지 않습니다." + emailSent: "입력하신 메일 주소({email})로 확인 메일을 보내드렸습니다. 가입을 완료하시려면 보내드린 메일에 있는 링크로 접속해 + 주세요." +_accountDelete: + accountDelete: "계정 삭제" + mayTakeTime: "계정 삭제는 서버에 부하를 가하기 때문에, 작성한 콘텐츠나 업로드한 파일의 수가 많으면 완료까지 시간이 걸릴 수 있습니다." + sendEmail: "계정 삭제가 완료되면 등록된 이메일 주소로 알림을 보냅니다." + requestAccountDelete: "계정 삭제 요청" + started: "삭제 작업이 시작되었습니다." + inProgress: "삭제 진행 중" +_ad: + back: "뒤로" + reduceFrequencyOfThisAd: "이 광고의 표시 빈도 낮추기" +_forgotPassword: + enterEmail: "여기에 계정에 등록한 메일 주소를 입력해 주세요. 입력한 메일 주소로 비밀번호 재설정 링크를 발송합니다." + ifNoEmail: "메일 주소를 등록하지 않은 경우, 관리자에 문의해 주십시오." + contactAdmin: "이 인스턴스에서는 메일 기능이 지원되지 않습니다. 비밀번호를 재설정하려면 관리자에게 문의해 주십시오." +_gallery: + my: "내 갤러리" + liked: "좋아요 한 갤러리" + like: "좋아요!" + unlike: "좋아요 취소" +_email: + _follow: + title: "새로운 팔로워가 있습니다" + _receiveFollowRequest: + title: "팔로우 요청을 받았습니다" +_plugin: + install: "플러그인 설치" + installWarn: "신뢰할 수 없는 플러그인은 설치하지 않는 것이 좋습니다." + manage: "플러그인 관리" +_preferencesBackups: + list: "생성한 백업" + saveNew: "새 백업 만들기" + loadFile: "파일 가져오기" + apply: "이 기기에 적용" + save: "현재 설정으로 덮어쓰기" + inputName: "백업 이름을 입력하세요" + cannotSave: "저장하지 못했습니다" + nameAlreadyExists: "\"{name}\" 백업이 이미 존재합니다. 다른 이름을 설정하여 주십시오." + applyConfirm: "\"{name}\" 백업을 현재 기기에 적용하시겠습니까? 현재 설정은 덮어 씌워집니다." + saveConfirm: "{name} 을 덮어쓰시겠습니까?" + deleteConfirm: "{name} 을(를) 삭제하시겠습니까?" + renameConfirm: "\"{old}\" 백업을 \"{new}\"(으)로 바꾸시겠습니까?" + noBackups: "저장된 백업이 없습니다. \"새 백업 만들기\"를 눌러 현재 클라이언트 설정을 서버에 백업할 수 있습니다." + createdAt: "생성 날짜: {date} {time}" + updatedAt: "갱신 날짜: {date} {time}" + cannotLoad: "가져오기에 실패했습니다" + invalidFile: "파일 형식이 올바르지 않습니다." + delete: 백업 삭제하기 +_registry: + scope: "범위" + key: "키" + keys: "키" + domain: "도메인" + createKey: "키 생성" +_aboutIceshrimp: + about: "Iceshrimp는 syuilo에 의해서 2014년부터 개발되어 온 오픈소스 소프트웨어 입니다." + contributors: "주요 기여자" + allContributors: "모든 기여자" + source: "소스 코드" + translation: "Iceshrimp를 번역하기" + donate: "Iceshrimp에 기부하기" + morePatrons: "이 외에도 다른 많은 분들이 도움을 주시고 계십니다. 감사합니다🥰" + patrons: "후원자" + donateTitle: Iceshrimp를 즐기고 계신가요? + pleaseDonateToHost: 또한, 당신이 있는 서버의 운영 비용을 후원하는 것도 고려해보세요. + patronsList: 후원 금액이 아닌 글자 순서대로 배치되었습니다. 위의 링크로 후원해서 여기에 이름을 남겨보세요! + chatroom: 채팅방 + documentation: 문서 + roadmap: 로드맵 + changelog: 변경 사항 + pleaseDonateToIceshrimp: 개발을 지원하기 위해, Iceshrimp에 후원하는 것을 고려해보세요. + donateHost: '{host}에 후원하기' + sponsors: Iceshrimp 스폰서 +_nsfw: + respect: "열람주의로 설정된 미디어 숨기기" + ignore: "열람 주의 미디어 항상 표시" + force: "미디어 항상 숨기기" +_mfm: + cheatSheet: "MFM 도움말" + intro: "MFM는 Iceshrimp의 다양한 곳에서 사용할 수 있는 전용 마크업 언어입니다. 여기에서는 MFM에서 사용할 수 있는 구문을 + 확인할 수 있습니다." + dummy: "Iceshrimp로 연합우주의 세계가 펼쳐집니다" + mention: "멘션" + mentionDescription: "골뱅이표(@) 뒤에 사용자명을 넣어 특정 유저를 나타낼 수 있습니다." + hashtag: "해시태그" + hashtagDescription: "샵 또는 우물정자(#)를 앞에 붙여서 해시태그를 나타낼 수 있습니다." + url: "URL" + urlDescription: "URL을 나타낼 수 있습니다." + link: "링크" + linkDescription: "문장의 특정 범위를 URL로 표시합니다." + bold: "굵음/볼드체" + boldDescription: "문자를 굵게 강조합니다." + small: "눈에 띄지 않음" + smallDescription: "내용을 작고 연하게 보이게 합니다." + center: "가운데 정렬" + centerDescription: "내용을 가운데 정렬로 보이게 합니다." + inlineCode: "코드(인라인)" + inlineCodeDescription: "여러 행의 코드를 문법 강조를 적용하여 인라인으로 표시합니다." + blockCode: "코드(블록)" + blockCodeDescription: "여러 행의 코드를 문법 강조를 적용하여 블록으로 표시합니다." + inlineMath: "수식(인라인)" + inlineMathDescription: "수식(KaTeX)를 인라인으로 보이게 합니다." + blockMath: "수식(블록)" + blockMathDescription: "여러 줄의 수식(KaTeX)를 블록으로 보이게 합니다." + quote: "인용" + quoteDescription: "내용을 인용문으로 표시합니다." + emoji: "커스텀 이모지" + emojiDescription: "커스텀 이모지의 이름을 쌍점(:)으로 감싸서 커스텀 이모지를 사용합니다." + search: "검색" + searchDescription: "주어진 키워드가 입력된 검색창을 보이게 합니다." + flip: "플립" + flipDescription: "내용을 상하 또는 좌우로 반전시킵니다." + jelly: "애니메이션 (젤리)" + jellyDescription: "젤리처럼 탱글탱글한 느낌의 효과를 줍니다." + tada: "애니메이션 (짠!)" + tadaDescription: "짠! 하는 느낌의 효과를 줍니다." + jump: "애니메이션(점프)" + jumpDescription: "펄쩍 뛸 듯한 느낌의 효과를 줍니다." + bounce: "애니메이션 (바운스)" + bounceDescription: "통통 튀는 느낌의 효과를 줍니다." + shake: "애니메이션 (부들부들)" + shakeDescription: "부들부들 떠는 느낌의 효과를 줍니다." + twitch: "애니메이션 (경련)" + twitchDescription: "격하게 흔들리는 느낌의 효과를 줍니다." + spin: "애니메이션 (회전)" + spinDescription: "회전 효과를 줍니다." + x2: "크게" + x2Description: "내용을 크게 표시합니다." + x3: "더 크게" + x3Description: "내용을 더 크게 표시합니다." + x4: "매우 크게" + x4Description: "내용을 매우 크게 표시합니다." + blur: "흐림" + blurDescription: "내용이 흐리게 보입니다. 마우스를 위에 올려두면 내용이 보입니다." + font: "폰트" + fontDescription: "내용의 글꼴을 지정할 수 있습니다." + rainbow: "무지개" + rainbowDescription: "내용을 무지개로 표시합니다." + sparkle: "반짝반짝" + sparkleDescription: "반짝이는 파티클 효과를 추가합니다." + rotate: "회전" + rotateDescription: "지정한 각도로 회전시킵니다." + plain: "평문" + plainDescription: "안에 있는 MFM 구문을 모두 무시하고 평문으로 표시합니다." + warn: MFM이 빠른 움직임이나 반짝이는 애니메이션을 포함할 수 있음 + play: MFM 재생하기 + stop: MFM 멈추기 + alwaysPlay: 언제나 움직이는 MFM을 재생하기 + advanced: 고급 MFM + advancedDescription: 비활성화하면 움직이는 MFM이 없는 한 기본적인 마크업만 표시합니다. +_instanceTicker: + none: "보이지 않음" + remote: "리모트 유저에게만 보이기" + always: "항상 보이기" +_serverDisconnectedBehavior: + reload: "자동으로 새로고침" + dialog: "경고창 표시" + quiet: "조용히 경고" +_channel: + create: "채널 생성" + edit: "채널 편집" + setBanner: "배너 설정" + removeBanner: "배너 삭제" + featured: "트렌드" + owned: "관리중" + following: "팔로잉" + usersCount: "{n}명 참여 중" + notesCount: "{n}노트" +_menuDisplay: + sideFull: "가로" + sideIcon: "가로(아이콘)" + top: "상단" + hide: "숨기기" +_wordMute: + muteWords: "뮤트할 단어" + muteWordsDescription: "공백으로 구분하는 경우 AND, 줄바꿈으로 구분하는 경우 OR로 지정됩니다。" + muteWordsDescription2: "정규 표현식을 사용하려면 키워드를 빗금표(/)로 감싸 주세요." + softDescription: "지정한 조건의 노트를 타임라인에서 숨깁니다." + hardDescription: "지정한 조건의 노트를 타임라인에 추가하지 않습니다. 타임라인에 추가되지 않은 노트는 조건을 변경해도 표시되지 않습니다." + soft: "보통" + hard: "보다 높은 수준" + mutedNotes: "뮤트된 노트" +_instanceMute: + instanceMuteDescription: "뮤트한 인스턴스에서 오는 답글을 포함한 모든 노트와 Renote를 뮤트합니다." + instanceMuteDescription2: "한 줄에 하나씩 입력해 주세요" + title: "지정한 인스턴스의 노트를 숨깁니다." + heading: "뮤트할 인스턴스" +_theme: + explore: "테마 찾아보기" + install: "테마 설치" + manage: "테마 관리" + code: "테마 코드" + description: "설명" + installed: "{name} 테마가 설치되었습니다" + installedThemes: "설치된 테마" + builtinThemes: "표준 테마" + alreadyInstalled: "이미 설치된 테마입니다" + invalid: "테마 형식이 올바르지 않습니다" + make: "테마 만들기" + base: "베이스" + addConstant: "상수 추가" + constant: "상수" + defaultValue: "기본값" + color: "색" + refProp: "프로퍼티를 참조" + refConst: "상수를 참조" + key: "키" + func: "함수" + funcKind: "함수 종류" + argument: "매개변수" + basedProp: "기준으로 할 속성 이름" + alpha: "불투명도" + darken: "어두움" + lighten: "밝음" + inputConstantName: "상수 이름을 입력하세요" + importInfo: "여기에 테마 코드를 붙여 넣어 에디터로 불러올 수 있습니다." + deleteConstantConfirm: "상수 {const}를 삭제하시겠습니까?" + keys: + accent: "강조 색상" + bg: "배경" + fg: "텍스트" + focus: "포커스" + indicator: "인디케이터" + panel: "패널" + shadow: "그림자" + header: "헤더" + navBg: "사이드바 배경" + navFg: "사이드바 텍스트" + navHoverFg: "사이드바 텍스트 (호버)" + navActive: "사이드바 텍스트 (활성)" + navIndicator: "사이드바 인디케이터" + link: "링크" + hashtag: "해시태그" + mention: "멘션" + mentionMe: "나에게 보낸 멘션" + renote: "Renote" + modalBg: "모달 배경" + divider: "구분선" + scrollbarHandle: "스크롤바 핸들" + scrollbarHandleHover: "스크롤바 핸들 (호버)" + dateLabelFg: "날짜 레이블 텍스트" + infoBg: "정보창 배경" + infoFg: "정보창 텍스트" + infoWarnBg: "경고창 배경" + infoWarnFg: "경고창 텍스트" + cwBg: "CW 버튼 배경" + cwFg: "CW 버튼 텍스트" + cwHoverBg: "CW 버튼 배경 (호버)" + toastBg: "알림창 배경" + toastFg: "알림창 텍스트" + buttonBg: "버튼 배경" + buttonHoverBg: "버튼 배경 (호버)" + inputBorder: "입력 필드 테두리" + listItemHoverBg: "리스트 항목 배경 (호버)" + driveFolderBg: "드라이브 폴더 배경" + wallpaperOverlay: "배경화면 오버레이" + badge: "배지" + messageBg: "채팅 배경" + accentDarken: "강조 색상 (어두움)" + accentLighten: "강조 색상 (밝음)" + fgHighlighted: "강조된 텍스트" +_sfx: + note: "새 노트" + noteMy: "내 노트" + notification: "알림" + chat: "대화" + chatBg: "대화 (백그라운드)" + antenna: "안테나 수신" + channel: "채널 알림" +_ago: + future: "미래" + justNow: "방금 전" + secondsAgo: "{n}초 전" + minutesAgo: "{n}분{n2}초 전" + hoursAgo: "{n}시간{n2}분 전" + daysAgo: "{n}일{n2}시간 전" + weeksAgo: "{n}주{n2}일 전" + monthsAgo: "{n}개월{n2}주 전" + yearsAgo: "{n}년{n2}개월 전" +_time: + second: "초" + minute: "분" + hour: "시간" + day: "일" +_2fa: + alreadyRegistered: "이미 설정이 완료되었습니다." + registerTOTP: "디바이스 등록" + registerSecurityKey: "키를 등록" + step1: "먼저, {a}나 {b}등의 인증 앱을 사용 중인 디바이스에 설치합니다." + step2: "그 후, 표시되어 있는 QR코드를 앱으로 스캔합니다." + step2Url: "데스크톱 앱에서는 다음 URL을 입력하세요:" + step3: "앱에 표시된 토큰을 입력하시면 완료됩니다." + step4: "다음 로그인부터는 토큰을 입력해야 합니다." + securityKeyInfo: "FIDO2를 지원하는 하드웨어 보안 키 혹은 디바이스의 지문인식이나 화면잠금 PIN을 이용해서 로그인하도록 설정할 + 수 있습니다." +_permissions: + "read:account": "계정의 정보를 봅니다" + "write:account": "계정의 정보를 변경합니다" + "read:blocks": "차단 여부를 확인합니다" + "write:blocks": "차단을 하거나 해제합니다" + "read:drive": "드라이브를 조회합니다" + "write:drive": "드라이브에 파일을 올리거나, 이름을 변경하거나, 삭제합니다" + "read:favorites": "즐겨찾기를 조회합니다" + "write:favorites": "즐겨찾기에 추가하거나 삭제합니다" + "read:following": "팔로우 상태를 봅니다" + "write:following": "팔로우하거나 팔로우를 해제합니다" + "read:messaging": "대화를 읽습니다" + "write:messaging": "대화를 시작하거나 메시지를 보냅니다" + "read:mutes": "뮤트 여부를 확인합니다" + "write:mutes": "뮤트를 하거나 해제합니다" + "write:notes": "노트를 작성하거나 삭제합니다" + "read:notifications": "알림을 확인합니다" + "write:notifications": "알림을 모두 읽음 처리합니다" + "read:reactions": "리액션을 확인합니다" + "write:reactions": "리액션을 추가하거나 취소합니다" + "write:votes": "투표를 합니다" + "read:pages": "페이지를 봅니다" + "write:pages": "페이지를 수정합니다" + "read:page-likes": "페이지의 좋아요를 확인합니다" + "write:page-likes": "페이지에 좋아요를 추가하거나 취소합니다" + "read:user-groups": "유저 그룹을 조회합니다" + "write:user-groups": "유저 그룹을 만들거나, 초대하거나, 이름을 변경하거나, 양도하거나, 삭제합니다" + "read:channels": "채널을 보기" + "write:channels": "채널을 추가하거나 삭제합니다" + "read:gallery": "갤러리를 봅니다" + "write:gallery": "갤러리를 추가하거나 삭제합니다" + "read:gallery-likes": "갤러리의 좋아요를 확인합니다" + "write:gallery-likes": "갤러리에 좋아요를 추가하거나 취소합니다" +_auth: + shareAccess: "\"{name}\" 이 계정에 접근하는 것을 허용하시겠습니까?" + shareAccessAsk: "이 애플리케이션이 계정에 접근하는 것을 허용하시겠습니까?" + permissionAsk: "이 앱은 다음의 권한을 요청합니다" + pleaseGoBack: "앱으로 돌아가서 시도해 주세요" + callback: "앱으로 돌아갑니다" + denied: "접근이 거부되었습니다" +_antennaSources: + all: "모든 노트" + homeTimeline: "팔로우중인 유저의 노트" + users: "지정한 한 명 혹은 여러 명의 유저의 노트" + userList: "지정한 리스트에 속한 유저의 노트" + userGroup: "지정한 그룹에 속한 유저의 노트" +_weekday: + sunday: "일요일" + monday: "월요일" + tuesday: "화요일" + wednesday: "수요일" + thursday: "목요일" + friday: "금요일" + saturday: "토요일" +_widgets: + memo: "스티커 메모" + notifications: "알림" + timeline: "타임라인" + calendar: "달력" + trends: "트렌드" + clock: "시계" + rss: "RSS 리더" + activity: "활동" + photos: "사진" + digitalClock: "디지털 시계" + unixClock: "UNIX 시계" + federation: "연합" + postForm: "글 입력란" + slideshow: "슬라이드 쇼" + button: "버튼" + onlineUsers: "온라인 유저" + jobQueue: "작업 대기열" + serverMetric: "서버 통계" + aiscript: "AiScript 콘솔" + aichan: "아이" +_cw: + hide: "숨기기" + show: "더 보기" + chars: "{count} 문자" + files: "{count} 파일" +_poll: + noOnlyOneChoice: "투표 항목이 최소 2개 필요합니다" + choiceN: "선택지 {n}" + noMore: "더 이상 추가할 수 없습니다" + canMultipleVote: "복수 응답 허용" + expiration: "투표 기한" + infinite: "무기한" + at: "일시 지정" + after: "기간 지정" + deadlineDate: "기한" + deadlineTime: "시간" + duration: "기간" + votesCount: "{n}표" + totalVotes: "총 {n}표" + vote: "투표하기" + showResult: "결과 보기" + voted: "투표함" + closed: "종료됨" + remainingDays: "종료까지 앞으로 {d}일 {h}시간" + remainingHours: "종료까지 앞으로 {h}시간 {m}분" + remainingMinutes: "종료까지 앞으로 {m}분 {s}초" + remainingSeconds: "종료까지 앞으로 {s}초" +_visibility: + public: "공개" + publicDescription: "모든 유저에게 공개" + home: "홈" + homeDescription: "홈 타임라인에만 공개" + followers: "팔로워" + followersDescription: "팔로워에게만 공개" + specified: "다이렉트" + specifiedDescription: "지정한 유저에게만 공개" + localOnly: "로컬에만" + localOnlyDescription: "리모트 유저에게 보이지 않기" +_postForm: + replyPlaceholder: "이 노트에 답글..." + quotePlaceholder: "이 노트를 인용..." + channelPlaceholder: "채널에 게시하기..." + _placeholders: + a: "지금 무엇을 하고 있나요?" + b: "무슨 일이 일어나고 있나요?" + c: "무엇을 생각하고 있나요?" + d: "말하고 싶은 게 있나요?" + e: "여기에 적어주세요" + f: "작성해주시길 기다리고 있어요..." +_profile: + name: "이름" + username: "유저명" + description: "자기소개" + youCanIncludeHashtags: "해시 태그를 포함할 수 있습니다." + metadata: "추가 정보" + metadataEdit: "추가 정보 편집" + metadataDescription: "프로필에 추가 정보를 표시할 수 있어요. {rel}과 함께 {a} 태그 또는 {l} 태그를 추가하여 프로필의 + 링크를 확인할 수 있습니다!" + metadataLabel: "라벨" + metadataContent: "내용" + changeAvatar: "아바타 이미지 변경" + changeBanner: "배너 이미지 변경" +_exportOrImport: + allNotes: "모든 노트" + followingList: "팔로잉" + muteList: "뮤트" + blockingList: "차단" + userLists: "리스트" + excludeMutingUsers: "뮤트한 유저 제외하기" + excludeInactiveUsers: "휴면 중인 계정 제외하기" +_charts: + federation: "연합" + apRequest: "요청" + usersIncDec: "유저 수 증감" + usersTotal: "유저 수 합계" + activeUsers: "활성 유저 수" + notesIncDec: "노트 수 증감" + localNotesIncDec: "로컬 노트 수 증감" + remoteNotesIncDec: "리모트 노트 수 증감" + notesTotal: "노트 수 합계" + filesIncDec: "파일 수 증감" + filesTotal: "파일 수 합계" + storageUsageIncDec: "스토리지 사용량 증감" + storageUsageTotal: "스토리지 사용량 합계" +_instanceCharts: + requests: "요청" + users: "유저 수 증감" + usersTotal: "누적 유저 수" + notes: "노트 수 증감" + notesTotal: "누적 노트 수" + ff: "팔로잉/팔로워 증감" + ffTotal: "누적 팔로잉/팔로워 수" + cacheSize: "캐시 용량 증감" + cacheSizeTotal: "누적 캐시 용량" + files: "파일 수 증감" + filesTotal: "누적 파일 수" +_timelines: + home: "홈" + local: "로컬" + social: "소셜" + global: "글로벌" +_pages: + newPage: "페이지 만들기" + editPage: "페이지 수정" + readPage: "소스 표시 중" + created: "페이지를 만들었습니다" + updated: "페이지를 수정했습니다" + deleted: "페이지가 삭제되었습니다" + pageSetting: "페이지 설정" + nameAlreadyExists: "지정한 페이지 URL이 이미 존재합니다" + invalidNameTitle: "유효하지 않은 페이지 URL입니다" + invalidNameText: "비어있지 않은지 확인해주세요" + editThisPage: "이 페이지를 편집" + viewSource: "소스 보기" + viewPage: "페이지 보기" + like: "좋아요" + unlike: "좋아요 해제" + my: "내 페이지" + liked: "좋아요한 페이지" + featured: "인기" + inspector: "인스펙터" + contents: "콘텐츠" + content: "페이지 블록" + variables: "변수" + title: "제목" + url: "페이지 URL" + summary: "페이지 요약" + alignCenter: "가운데 정렬" + hideTitleWhenPinned: "프로필에 고정해놓은 경우 타이틀을 표시하지 않음" + font: "폰트" + fontSerif: "명조체" + fontSansSerif: "고딕체" + eyeCatchingImageSet: "아이캐치 이미지를 설정" + eyeCatchingImageRemove: "아이캐치 이미지를 삭제" + chooseBlock: "블록 추가" + selectType: "종류 선택" + enterVariableName: "변수명을 지정해주세요" + variableNameIsAlreadyUsed: "해당 변수명은 이미 사용중입니다" + contentBlocks: "콘텐츠" + inputBlocks: "입력" + specialBlocks: "특수" + blocks: + text: "텍스트" + textarea: "텍스트 영역" + section: "섹션" + image: "이미지" + button: "버튼" + if: "조건문" + _if: + variable: "변수" + post: "글 입력란" + _post: + text: "내용" + attachCanvasImage: "캔버스의 이미지와 함께 게시하기" + canvasId: "캔버스 ID" + textInput: "텍스트 입력" + _textInput: + name: "변수명" + text: "제목" + default: "기본값" + textareaInput: "여러 줄 텍스트 입력" + _textareaInput: + name: "변수명" + text: "제목" + default: "기본값" + numberInput: "수치 입력" + _numberInput: + name: "변수명" + text: "제목" + default: "기본값" + canvas: "캔버스" + _canvas: + id: "캔버스 ID" + width: "폭" + height: "높이" + note: "노트필기" + _note: + id: "노트 ID" + idDescription: "노트 URL을 붙여넣어 설정할 수도 있습니다." + detailed: "세부 정보 보기" + switch: "스위치" + _switch: + name: "변수명" + text: "제목" + default: "기본값" + counter: "카운터" + _counter: + name: "변수명" + text: "제목" + inc: "증가치" + _button: + text: "제목" + colored: "색 입히기" + action: "버튼을 눌렀을 때의 동작" + _action: + dialog: "대화상자를 표시" + _dialog: + content: "내용" + resetRandom: "난수를 초기화" + pushEvent: "이벤트 보내기" + _pushEvent: + event: "이벤트 이름" + message: "눌렀을 때 표시할 페이지" + variable: "보낼 변수" + no-variable: "없음" + callAiScript: "AiScript 호출" + _callAiScript: + functionName: "함수명" + radioButton: "선택지" + _radioButton: + name: "변수명" + title: "제목" + values: "줄바꿈으로 구분된 선택지" + default: "기본값" + script: + categories: + flow: "흐름 제어" + logical: "논리 연산" + operation: "계산" + comparison: "비교" + random: "랜덤" + value: "값" + fn: "함수" + text: "텍스트 조작" + convert: "변환" + list: "리스트" + blocks: + text: "텍스트" + multiLineText: "텍스트 (여러 줄)" + textList: "텍스트 목록" + _textList: + info: "각각을 줄바꿈으로 구분해주세요" + strLen: "텍스트의 길이" + _strLen: + arg1: "텍스트" + strPick: "문자 추출" + _strPick: + arg1: "텍스트" + arg2: "문자 위치" + strReplace: "텍스트 대체" + _strReplace: + arg1: "텍스트" + arg2: "대체될 텍스트" + arg3: "대체할 텍스트" + strReverse: "텍스트 뒤집기" + _strReverse: + arg1: "텍스트" + join: "텍스트 합치기" + _join: + arg1: "리스트" + arg2: "구분자" + add: "더하기" + _add: + arg1: "A" + arg2: "B" + subtract: "빼기" + _subtract: + arg1: "A" + arg2: "B" + multiply: "곱하기" + _multiply: + arg1: "A" + arg2: "B" + divide: "나누기" + _divide: + arg1: "A" + arg2: "B" + mod: "나눈 나머지" + _mod: + arg1: "A" + arg2: "B" + round: "소수점을 반올림" + _round: + arg1: "수치" + eq: "A와 B가 동일" + _eq: + arg1: "A" + arg2: "B" + notEq: "A와 B가 다름" + _notEq: + arg1: "A" + arg2: "B" + and: "A와 B가 둘 다 참" + _and: + arg1: "A" + arg2: "B" + or: "A, B중 하나 이상이 참" + _or: + arg1: "A" + arg2: "B" + lt: "< A가 B보다 작음" + _lt: + arg1: "A" + arg2: "B" + gt: "> A가 B보다 큼" + _gt: + arg1: "A" + arg2: "B" + ltEq: "<= A가 B보다 작거나 같음" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: ">= A가 B보다 크거나 같음" + _gtEq: + arg1: "A" + arg2: "B" + if: "분기" + _if: + arg1: "조건문" + arg2: "참일 경우" + arg3: "거짓일 경우" + not: "부정" + _not: + arg1: "부정" + random: "랜덤" + _random: + arg1: "확률" + rannum: "난수" + _rannum: + arg1: "최솟값" + arg2: "최댓값" + randomPick: "목록에서 임의로 선택" + _randomPick: + arg1: "리스트" + dailyRandom: "랜덤 (하루동안 결과 유지)" + _dailyRandom: + arg1: "확률" + dailyRannum: "난수 (하루동안 결과 유지)" + _dailyRannum: + arg1: "최솟값" + arg2: "최댓값" + dailyRandomPick: "목록에서 임의로 선택 (하루동안 결과 유지)" + _dailyRandomPick: + arg1: "리스트" + seedRandom: "무작위 (시드)" + _seedRandom: + arg1: "시드" + arg2: "확률" + seedRannum: "난수 (시드)" + _seedRannum: + arg1: "시드" + arg2: "최솟값" + arg3: "최댓값" + seedRandomPick: "목록에서 무작위로 선택 (시드)" + _seedRandomPick: + arg1: "시드" + arg2: "리스트" + DRPWPM: "확률형 목록에서 임의로 선택 (하루동안 결과 유지)" + _DRPWPM: + arg1: "텍스트 목록" + pick: "목록에서 선택" + _pick: + arg1: "리스트" + arg2: "위치" + listLen: "리스트의 길이 가져오기" + _listLen: + arg1: "리스트" + number: "수치" + stringToNumber: "텍스트를 수치로" + _stringToNumber: + arg1: "텍스트" + numberToString: "수치를 텍스트로" + _numberToString: + arg1: "수치" + splitStrByLine: "텍스트를 행 단위로 분할" + _splitStrByLine: + arg1: "텍스트" + ref: "변수" + aiScriptVar: "AiScript 변수" + fn: "함수" + _fn: + slots: "슬롯" + slots-info: "각 슬롯을 줄바꿈으로 구분하여 주세요" + arg1: "출력" + for: "반복" + _for: + arg1: "횟수" + arg2: "처리" + typeError: "슬롯 {slot}은 \"{expect}\"를 사용할 수 있지만 \"{actual}이 들어있습니다!" + thereIsEmptySlot: "슬롯 {slot}이(가) 비었습니다!" + types: + string: "텍스트" + number: "수치" + boolean: "플래그" + array: "리스트" + stringArray: "텍스트 목록" + emptySlot: "빈 슬롯" + enviromentVariables: "환경 변수" + pageVariables: "페이지 요소" + argVariables: "입력 슬롯" +_relayStatus: + requesting: "대기 중" + accepted: "승인됨" + rejected: "거절됨" +_notification: + fileUploaded: "파일이 업로드되었습니다" + youGotMention: "{name}님이 멘션함" + youGotReply: "{name}님이 답글함" + youGotQuote: "{name}님이 인용함" + youRenoted: "{name}님이 Renote" + youGotPoll: "{name}님이 투표함" + youGotMessagingMessageFromUser: "{name} 님이 보낸 채팅이 있어요" + youGotMessagingMessageFromGroup: "{name}에서 보낸 채팅이 있어요" + youWereFollowed: "새로운 팔로워가 있습니다" + youReceivedFollowRequest: "새로운 팔로우 요청이 있습니다" + yourFollowRequestAccepted: "팔로우 요청이 수락되었습니다" + youWereInvitedToGroup: "그룹에 초대되었습니다" + pollEnded: "투표 결과가 발표되었습니다" + emptyPushNotificationMessage: "푸시 알림이 갱신되었습니다" + _types: + all: "전부" + follow: "팔로잉" + mention: "멘션" + reply: "답글" + renote: "Renote" + quote: "인용" + reaction: "리액션" + pollVote: "투표 참여" + pollEnded: "투표가 종료됨" + receiveFollowRequest: "팔로우 요청을 받았을 때" + followRequestAccepted: "팔로우 요청이 승인되었을 때" + groupInvited: "그룹에 초대되었을 때" + app: "연동된 앱을 통한 알림" + _actions: + followBack: "팔로우" + reply: "답글" + renote: "Renote" +_deck: + alwaysShowMainColumn: "메인 칼럼 항상 표시" + columnAlign: "칼럼 정렬" + addColumn: "칼럼 추가" + configureColumn: "칼럼 설정" + swapLeft: "왼쪽으로 이동" + swapRight: "오른쪽으로 이동" + swapUp: "위로 이동" + swapDown: "아래로 이동" + stackLeft: "왼쪽에 쌓기" + popRight: "오른쪽으로 빼기" + profile: "프로파일" + newProfile: "새 프로파일" + deleteProfile: "프로파일 삭제" + introduction: "칼럼을 조합해서 나만의 인터페이스를 구성해 보아요!" + introduction2: "나중에라도 화면 우측의 + 버튼을 눌러 새 칼럼을 추가할 수 있습니다." + widgetsIntroduction: "칼럼 메뉴의 \"위젯 편집\"에서 위젯을 추가해 주세요" + _columns: + main: "메인" + widgets: "위젯" + notifications: "알림" + tl: "타임라인" + antenna: "안테나" + list: "리스트" + mentions: "받은 멘션" + direct: "다이렉트" +flagSpeakAsCat: 냥체로 대화하기 +older: 이전 노트 +listsDesc: 리스트를 이용하여 지정한 유저를 하나의 타임라인으로 묶을 수 있습니다. 리스트는 타임라인 페이지에서 접근할 수 있습니다. +removeReaction: 리액션 취소 +enableEmojiReactions: 이모지 리액션 활성화 +flagSpeakAsCatDescription: "'나는 고양이다냥'이 활성화된 상태일 때에 켜면 게시물의 일부 글자가 치환되어 냥냥하게 됩니다" +expandOnNoteClick: 클릭하여 게시물 자세히 보기 +expandOnNoteClickDesc: 비활성화시에도 우클릭 메뉴 혹은 타임스탬프를 클릭하여 접근할 수 있습니다. +userSaysSomethingReason: '{name} 님이 {reason}에 대해 말했습니다' +silencedInstances: 사일런스된 서버 +privateModeInfo: 활성화되면 지정한 서버에만 연합을 허용합니다. 모든 게시물은 공개 타임라인에서 숨겨집니다. +allowedInstances: 허용된 서버 +silencedInstancesDescription: 사일런스하려는 서버의 호스트 이름을 줄바꿈으로 구분하여 설정합니다. 사일런스 서버의 계정은 모두 + '사일런스' 상태로 취급되며, 이 서버에서의 팔로우가 모두 승인제로 변경되고, 팔로워가 아닌 유저를 멘션할 수 없게 됩니다. 차단된 서버에는 적용되지 + 않습니다. +enableRecommendedTimeline: 추천 타임라인 활성화 +antennasDesc: "안테나에서는 지정한 조건에 맞는 게시물을 모아서 볼 수 있습니다.\n 안테나에는 타임라인 페이지에서 접근할 수 있습니다." +cannotChangeScopeWhenEditing: 노트 편집에서는 공개 범위를 조정할 수 없습니다 +xl: 매우 크게 +userSaysSomethingReasonRenote: '{name} 님이 {reason}에 대한 글을 부스트했습니다' +clipsDesc: 클립은 분류 및 공유가 가능한 북마크입니다. 각 게시물의 메뉴에서 클립을 생성할 수 있습니다. +allowedInstancesDescription: 연합을 허용할 서버를 한 줄에 하나씩 적습니다. (비공개 모드에서만 적용) +seperateRenoteQuote: 부스트와 인용 버튼을 나누기 +pushNotification: 푸시 알림 +subscribePushNotification: 푸시 알림 켜기 +moveTo: 이 계정을 새로운 계정으로 이사 +cannotUploadBecauseExceedsFileSizeLimit: 파일 크기 제한을 초과하여 업로드할 수 없습니다. +pushNotificationAlreadySubscribed: 푸시 알림이 켜져 있습니다 +sendPushNotificationReadMessageCaption: 짧은 시간동안 "{emptyPushNotificationMessage}" 알림이 + 표시됩니다. 기기의 전력 소모량이 증가할 수 있습니다. +adminCustomCssWarn: 이 설정에 따른 영향을 이해하고 있는 경우에만 사용하십시오. 올바르지 않은 값을 입력하면 모든 유저의 클라이언트가 + 정상적으로 동작하지 않게 될 수 있습니다. 사용자 CSS 등을 통해 올바르게 작동하는 것을 확인하고 난 후에 적용하십시오. +customSplashIconsDescription: 유저가 페이지를 불러올 때/새로고침할 때 마다 무작위로 표시할 아이콘 URL을 한 줄에 하나씩 + 적습니다. 이미지는 정적 URL만 허용되며, 되도록 192x192 크기의 이미지를 사용하십시오. +moveAccountDescription: 이 조작은 취소할 수 없습니다. 우선 이사갈 계정에서 이 계정에 대한 별칭을 생성하였는 지 확인하여 주십시오. + 그 다음 이사갈 계정을 @person@server.com 형식으로 입력하십시오. +searchPlaceholder: 연합우주에서 검색 +noThankYou: 괜찮습니다 +addInstance: 서버 추가 +deleted: 삭제됨 +editNote: 노트 편집 +edited: '편집됨: {date} {time}' +jumpToPrevious: 이전으로 돌아가기 +newer: 새로운 노트 +expandAllCws: 모든 답글의 내용 숨기기를 펼치기 +collapseAllCws: 모든 답글의 내용 숨기기를 접기 +cw: 열람 주의 +showEmojisInReactionNotifications: 리액션 알림에서 이모지 보이기 +renoteMute: 부스트 뮤트 +renoteUnmute: 부스트 뮤트 해제 +selectChannel: 채널 선택 +accountMoved: '이 사용자는 다른 계정으로 이사했습니다:' +selectInstance: 서버 선택 +silenceThisInstance: 이 서버를 사일런스 +hiddenTags: 숨길 해시태그 +hiddenTagsDescription: 트렌드 및 발견하기에서 숨기려는 해시태그를 (#를 제외하여) 줄바꿈으로 구분하여 설정합니다. 트렌드 및 발견하기 + 외의 방법으로는 계속해서 탐색할 수 있습니다. +noInstances: 서버가 없습니다 +silenced: 사일런스됨 +manageGroups: 그룹 관리 +antennaInstancesDescription: 서버를 한 줄에 하나씩 적습니다 +unsubscribePushNotification: 푸시 알림 끄기 +pushNotificationNotSupported: 브라우저 혹은 서버가 푸시 알림을 지원하지 않습니다 +sendPushNotificationReadMessage: 알림 및 메시지를 읽었을 때 푸시 알림을 삭제 +showAds: 광고 보이기 +enterSendsMessage: 대화에서 Return 키를 눌러 메시지를 보냅니다 (비활성화 시 Ctrl + Return 으로 보냅니다) +customMOTD: 커스텀 MOTD (스플래시 화면 메시지) +customMOTDDescription: 유저가 페이지를 불러올 때/새로고침할 때 마다 무작위로 표시할 메시지를 한 줄에 하나씩 적습니다. +customSplashIcons: 스플래시 화면 사용자 지정 아이콘 URL +showUpdates: Iceshrimp 업데이트마다 팝업을 표시하기 +recommendedInstances: 추천 서버 +recommendedInstancesDescription: 추천 타임라인에 표시할 서버를 한 줄에 하나씩 입력하십시오. +caption: 자동 미디어 설명 +splash: 스플래시 화면 +updateAvailable: 새로운 업데이트가 있습니다! +swipeOnMobile: 페이지 간 스와이프를 활성화 +swipeOnDesktop: 데스크톱 환경에서 모바일 스타일의 스와이프 활성화 +logoImageUrl: 로고 URL +showAdminUpdates: 새로운 Iceshrimp 버전이 있을 때 알리기 (관리자 전용) +replayTutorial: 튜토리얼 다시 보기 +moveToLabel: '이사갈 계정:' +moveAccount: 이사하기! +migration: 계정 이동 +image: 이미지 +video: 비디오 +audio: 오디오 +statusbar: 스테이터스 바 +antennaTimelineHint: 노트는 안테나의 조건을 검사하는 시점에 추가됩니다. 따라서 반드시 시간 순서로 표시되지 않을 수 있습니다. +accessibility: 접근성 +userSaysSomethingReasonReply: '{name} 님이 {reason}에 대한 글에 답글을 보냈습니다' +userSaysSomethingReasonQuote: '{name} 님이 {reason}에 대한 글을 인용했습니다' +channelFederationWarn: 채널은 현재 다른 서버와 연합되지 않습니다 +secureMode: 보안 모드 (Authorized Fetch) +instanceSecurity: 서버 보안 +secureModeInfo: 인증 정보가 없는 리모트 서버로부터의 요청에 응답하지 않습니다. +privateMode: 비공개 모드 +alt: 설명 +breakFollowConfirm: 팔로워를 해제하시겠습니까? +showPopup: 다이얼로그를 통해 유저에게 알리기 +moveFromDescription: 당신의 이전 계정에 대한 별칭을 설정해 다른 계정에서 이 계정으로 이사할 수 있도록 합니다. 이 작업은 다른 + 계정에서 이사하기 전에 진행되어야 합니다. @person@example.com 형식의 전체 계정 핸들을 입력해주십시오. +migrationConfirm: "이 계정에서 {account} 계정으로 이사하시겠습니까? 이사는 한 번 진행하면 되돌리기 어려우며, 계정을 정상적으로 + 사용할 수 없게 됩니다.\n또한, 이사할 계정으로 로그인 한 것이 맞는지 다시 확인해주십시오." +youHaveUnreadAnnouncements: 읽지 않은 공지사항이 있습니다. +signupsDisabled: 이 서버의 신규 가입은 비활성화되어 있습니다. 하지만 언제나 다른 서버에 가입할 수 있습니다! 만약 이 서버에 대한 + 초대 코드가 있다면, 아래에 입력해주세요. +noteId: 게시물 ID +enableCustomKaTeXMacro: KaTeX 매크로 활성화 +moveFrom: 다른 계정에서 이 계정으로 이사하기 +moveFromLabel: '이사해 올 이전 계정:' +defaultReaction: 들어오고 나가는 게시물의 기본 리액션 +license: 라이선스 +customKaTeXMacro: KaTeX 매크로 사용자화 +verifiedLink: 인증된 링크 +openInMainColumn: 메인 컬럼에서 열기 +findOtherInstance: 다른 서버 찾아보기 +apps: 앱 +sendModMail: 중재 알림 보내기 +preventAiLearning: 내 콘텐츠의 AI 학습을 방지 +preventAiLearningDescription: 제3자 AI 언어 모델에게 당신의 게시물이나 사진 같은 컨텐츠를 학습하지 않도록 요구합니다. +noGraze: Iceshrimp의 원활한 동작을 방해할 수 있으니, "Graze for Mastodon" 확장 프로그램을 비활성화해주십시오. +silencedWarning: 이 사용자는 서버 관리자가 사일런스한 서버의 사용자입니다. 스팸일 가능성이 있으니 주의해주십시오. +isBot: 이 계정은 봇입니다 +isLocked: 이 계정은 팔로우를 승인제로 받습니다 +isModerator: 중재자 +isAdmin: 관리자 +isPatron: Iceshrimp 후원자 +reactionPickerSkinTone: 선호하는 이모지 피부톤 +enableServerMachineStats: 서버 하드웨어 통계 활성화 +enableIdenticonGeneration: 프로필 사진이 없는 사용자의 Identicon 생성 +showWithSparkles: 반짝거리는 효과와 함께 표시 +donationLink: 후원 페이지로 향하는 링크 +neverShow: 다시 보지 않기 +remindMeLater: 나중에 하기 +removeQuote: 인용 삭제하기 +removeRecipient: 받는 사람 삭제하기 +removeMember: 멤버 삭제하기 +searchEmptyQuery: 검색할 내용을 입력해주세요. +searchNotLoggedIn_1: 전체 텍스트 검색을 하려면 먼저 로그인해야 합니다. +searchNotLoggedIn_2: 대신, 해시태그나 유저를 검색할 수 있습니다. diff --git a/locales/nl-NL.yml b/locales/nl-NL.yml new file mode 100644 index 0000000..9ae6990 --- /dev/null +++ b/locales/nl-NL.yml @@ -0,0 +1,1672 @@ +_lang_: "Nederlands" +headlineIceshrimp: "Een open source, gedecentraliseerd, social media platform dat + voor altijd gratis is! 🚀" +introIceshrimp: "Welkom! Iceshrimp is een open source, gedecentraliseerde microblogdienst.\n + Maak \"notities\" om je gedachten te delen met iedereen om je heen. 📡\nMet \"reacties\"\ + \ kun je ook snel je mening geven over berichten van anderen. 👍\nLaten we een nieuwe + wereld verkennen! 🚀" +monthAndDay: "{day} {month}" +search: "Zoeken" +notifications: "Meldingen" +username: "Gebruikersnaam" +password: "Wachtwoord" +forgotPassword: "Wachtwoord vergeten" +fetchingAsApObject: "Ophalen vanuit de Fediverse" +ok: "Ok" +gotIt: "Begrepen!" +cancel: "Annuleren" +enterUsername: "Voer een gebruikersnaam in" +renotedBy: "Hergedeeld door {user}" +noNotes: "Geen notities" +noNotifications: "Geen meldingen" +instance: "Server" +settings: "Instellingen" +basicSettings: "Basisinstellingen" +otherSettings: "Overige instellingen" +openInWindow: "In een venster openen" +profile: "Profiel" +timeline: "Tijdlijn" +noAccountDescription: "Deze gebruiker heeft nog geen bio geschreven" +login: "Inloggen" +loggingIn: "Aan het inloggen" +logout: "Afmelden" +signup: "Registreren" +uploading: "Bezig met uploaden…" +save: "Opslaan" +users: "Gebruikers" +addUser: "Toevoegen gebruiker" +favorite: "Favorieten" +favorites: "Toevoegen aan favorieten" +unfavorite: "Verwijderen uit favorieten" +favorited: "Toegevoegd aan favorieten." +alreadyFavorited: "Al toegevoegd aan favorieten" +cantFavorite: "Kon niet toevoegen aan favorieten" +pin: "Vastmaken aan profielpagina" +unpin: "Losmaken van profielpagina" +copyContent: "Kopiëren inhoud" +copyLink: "Kopiëren link" +delete: "Verwijderen" +deleteAndEdit: "Verwijderen en bewerken" +deleteAndEditConfirm: "Weet je zeker dat je deze post wilt verwijderen en dan bewerken? + Je verliest alle reacties, boosts en antwoorden erop." +addToList: "Aan lijst toevoegen" +sendMessage: "Verstuur bericht" +copyUsername: "Gebruikersnaam kopiëren" +searchUser: "Zoek een gebruiker" +reply: "Antwoord" +loadMore: "Laad meer" +showMore: "Toon meer" +youGotNewFollower: "volgt jou" +receiveFollowRequest: "Volgverzoek ontvangen" +followRequestAccepted: "Volgverzoek geaccepteerd" +mention: "Vermelding" +mentions: "Vermeldingen" +directNotes: "Directe notities" +importAndExport: "Import / export" +import: "Import" +export: "Export" +files: "Bestanden" +download: "Downloaden" +driveFileDeleteConfirm: "Weet je zeker dat je het bestand \"{name}\" wilt verwijderen? + Posts met dit bestand als bijlage worden ook verwijderd." +unfollowConfirm: "Weet je zeker dat je {name} wilt ontvolgen?" +exportRequested: "Je hebt een export aangevraagd. Dit kan een tijdje duren. Het wordt + toegevoegd aan je Drive zodra het is voltooid." +importRequested: "Je hebt een import aangevraagd. Dit kan even duren." +lists: "Lijsten" +noLists: "Je hebt geen lijsten" +note: "Notitie" +notes: "Notities" +following: "Volgend" +followers: "Volgers" +followsYou: "Volgt jou" +createList: "Creëer lijst" +manageLists: "Lijsten beheren" +error: "Fout" +somethingHappened: "Er is iets misgegaan." +retry: "Probeer opnieuw" +pageLoadError: "Pagina laden mislukt" +pageLoadErrorDescription: "Dit wordt normaal gesproken veroorzaakt door netwerkfouten + of door de cache van de browser. Probeer de cache te wissen of probeer het na een + tijdje wachten, en herladen, opnieuw." +serverIsDead: "De server reageert niet. Wacht even en probeer het opnieuw." +youShouldUpgradeClient: "Werk je client bij om deze pagina te zien." +enterListName: "Voer de naam van de lijst in" +privacy: "Privacy" +makeFollowManuallyApprove: "Volgverzoeken vergen een goedkeuring" +defaultNoteVisibility: "Standaard zichtbaarheid" +follow: "Volgen" +followRequest: "Verzoek om te mogen volgen" +followRequests: "Volgverzoeken" +unfollow: "Ontvolgen" +followRequestPending: "Wachten op goedkeuring volgverzoek" +enterEmoji: "Voer een emoji in" +renote: "Boost" +unrenote: "Boost intrekken" +renoted: "Boosted." +cantRenote: "Dit bericht kan niet worden geboost." +cantReRenote: "Een boost kan niet worden geboost." +quote: "Quote" +pinnedNote: "Vastgemaakte post" +pinned: "Vastmaken aan profielpagina" +you: "Jij" +clickToShow: "Klik om te bekijken" +sensitive: "Gevoelig" +add: "Toevoegen" +reaction: "Reacties" +reactionSettingDescription2: "Sleep om opnieuw te ordenen, Klik om te verwijderen, + Druk op \"+\" om toe te voegen" +rememberNoteVisibility: "Onthoud post zichtbaarheidsinstellingen" +attachCancel: "Verwijder bijlage" +markAsSensitive: "Markeren als gevoelig" +unmarkAsSensitive: "Niet gevoelig" +enterFileName: "Bestandsnaam invoeren" +mute: "Dempen" +unmute: "Stop dempen" +block: "Blokkeren" +unblock: "Deblokkeren" +suspend: "Opschorten" +unsuspend: "Heractiveren" +blockConfirm: "Weet je zeker dat je dit account wil blokkeren?" +unblockConfirm: "Ben je zeker dat je deze account wil blokkeren?" +suspendConfirm: "Ben je zeker dat je deze account wil suspenderen?" +unsuspendConfirm: "Ben je zeker dat je deze account wil opnieuw aanstellen?" +flagAsBot: "Markeer dit account als een robot 🤖" +flagAsBotDescription: "Als dit account van een programma wordt beheerd, zet deze vlag + aan. Het aanzetten helpt andere ontwikkelaars om bijvoorbeeld onbedoelde feedback + loops te doorbreken of om Iceshrimp meer geschikt te maken." +flagAsCat: "Markeer dit account als een kat." +flagAsCatDescription: "Zet deze vlag aan als je wilt aangeven dat dit account een + kat is." +flagShowTimelineReplies: "Toon antwoorden op de tijdlijn" +flagShowTimelineRepliesDescription: "Als je deze vlag aanzet, toont de tijdlijn ook + antwoorden op andere en niet alleen jouw eigen post." +autoAcceptFollowed: "Accepteer verzoeken om jezelf te volgen vanzelf als je de verzoeker + al volgt" +addAccount: "Account toevoegen" +loginFailed: "Aanmelding mislukt." +showOnRemote: "Bekijk op de externe server" +general: "Algemeen" +wallpaper: "Achtergrond" +setWallpaper: "Achtergrond instellen" +removeWallpaper: "Achtergrond verwijderen" +searchWith: "Zoeken: {q}" +youHaveNoLists: "Je hebt geen lijsten" +followConfirm: "Weet je zeker dat je {name} wilt volgen?" +proxyAccount: "Proxy account" +proxyAccountDescription: "Een proxy-account is een account dat onder bepaalde voorwaarden + fungeert als externe volger voor gebruikers. Als een gebruiker bijvoorbeeld een + externe gebruiker aan de lijst toevoegt, wordt de activiteit van de externe gebruiker + niet aan de server geleverd als geen lokale gebruiker die gebruiker volgt, dus het + proxy-account volgt in plaats daarvan." +host: "Server" +selectUser: "Kies een gebruiker" +recipient: "Ontvanger(s)" +annotation: "Reacties" +federation: "Federatie" +instances: "Servers" +registeredAt: "Geregistreerd op" +latestRequestSentAt: "Laatste aanvraag verstuurd" +latestRequestReceivedAt: "Laatste aanvraag ontvangen" +latestStatus: "Laatste status" +storageUsage: "Gebruikte opslagruimte" +charts: "Grafieken" +perHour: "Per uur" +perDay: "Per dag" +stopActivityDelivery: "Stop met versturen activiteiten" +blockThisInstance: "Blokkeer deze server" +operations: "Verwerkingen" +software: "Software" +version: "Versie" +metadata: "Metadata" +monitor: "Monitor" +jobQueue: "Job Queue" +cpuAndMemory: "CPU en geheugen" +network: "Netwerk" +disk: "Schijfruimte" +instanceInfo: "Serverinformatie" +statistics: "Statistieken" +clearQueue: "Wachtrij wissen" +clearQueueConfirmTitle: "Weet je zeker dat je de wachtrji leeg wil maken?" +clearQueueConfirmText: "Niet-bezorgde posts die nog in de wachtrij staan, worden niet + gefedereerd. Meestal is deze operatie niet nodig." +clearCachedFiles: "Cache opschonen" +clearCachedFilesConfirm: "Weet je zeker dat je alle externe bestanden in de cache + wilt verwijderen?" +blockedInstances: "Geblokkeerde servers" +blockedInstancesDescription: "Maak een lijst van de servers die moeten worden geblokkeerd, + gescheiden door regeleinden. Geblokkeerde servers kunnen niet meer communiceren + met deze server." +muteAndBlock: "Gedempt en geblokkeerd" +mutedUsers: "Gedempte gebruikers" +blockedUsers: "Geblokkeerde gebruikers" +noUsers: "Er zijn geen gebruikers." +editProfile: "Bewerk Profiel" +noteDeleteConfirm: "Ben je zeker dat je deze post wil verwijderen?" +pinLimitExceeded: "Je kunt geen posts meer vastprikken" +intro: "Installatie van Iceshrimp geëindigd! Maak nu een beheerder aan." +done: "Klaar" +processing: "Bezig met verwerken…" +preview: "Voorbeeld" +default: "Standaard" +noCustomEmojis: "Er zijn geen emojis" +noJobs: "Er zijn geen taken" +federating: "Federeren" +blocked: "Geblokkeerd" +suspended: "Opgeschort" +all: "Alle" +subscribing: "Abonneren" +publishing: "Publiceren" +notResponding: "Reageert niet" +instanceFollowing: "Volgend op server" +instanceFollowers: "Volgers op server" +instanceUsers: "Gebruikers van deze server" +changePassword: "Wachtwoord wijzigen" +security: "Beveiliging" +retypedNotMatch: "Invoer komt niet overeen" +currentPassword: "Huidig wachtwoord" +newPassword: "Nieuwe wachtwoord" +newPasswordRetype: "Nieuw wachtwoord (herhalen)" +attachFile: "Bestanden toevoegen" +more: "Meer" +featured: "Uitgelicht" +usernameOrUserId: "Gebruikersnaam of id" +noSuchUser: "Gebruiker niet gevonden" +lookup: "Opzoeken" +announcements: "Aankondigingen" +imageUrl: "AfbeeldingsURL" +remove: "Verwijderen" +removed: "Succesvol verwijderd" +removeAreYouSure: "Weet je zeker dat je \"{x}\" wil verwijderen?" +deleteAreYouSure: "Weet je zeker dat je \"{x}\" wil verwijderen?" +resetAreYouSure: "Weet je zeker dat je wilt resetten?" +saved: "Opgeslagen" +messaging: "Chat" +upload: "Uploaden" +keepOriginalUploading: "Origineel beeld behouden." +keepOriginalUploadingDescription: "Bewaar de originele versie bij het uploaden van + afbeeldingen. Indien uitgeschakeld, wordt bij het uploaden een alternatieve versie + voor webpublicatie genereert." +fromDrive: "Van schijf" +fromUrl: "Van URL" +uploadFromUrl: "Uploaden vanaf een URL" +uploadFromUrlDescription: "URL van het bestand dat je wil uploaden" +uploadFromUrlRequested: "Uploadverzoek" +uploadFromUrlMayTakeTime: "Het kan even duren voordat het uploaden voltooid is." +explore: "Verkennen" +messageRead: "Lezen" +noMoreHistory: "Er is geen verdere geschiedenis" +startMessaging: "Start een gesprek" +nUsersRead: "gelezen door {n}" +agreeTo: "Ik stem in met {0}" +tos: "Gebruiksvoorwaarden" +start: "Aan de slag" +home: "Startpagina" +remoteUserCaution: "Aangezien deze gebruiker van een externe server afkomstig is, + kan de weergegeven informatie onvolledig zijn." +activity: "Activiteit" +images: "Afbeeldingen" +birthday: "Geboortedatum" +yearsOld: "{age} jaar" +registeredDate: "Inschrijvingsdatum" +location: "Locatie" +theme: "Thema's" +themeForLightMode: "Thema voor gebruik in de lichte modus" +themeForDarkMode: "Thema voor gebruik in de donkere modus" +light: "Licht" +dark: "Donker" +lightThemes: "Licht thema's" +darkThemes: "Donkere thema's" +syncDeviceDarkMode: "Synchroniseer donkere modus met je apparaatinstellingen" +drive: "Schijf" +fileName: "Bestandsnaam" +selectFile: "Kies een bestand" +selectFiles: "Selecteer bestanden" +selectFolder: "Kies een map" +selectFolders: "Kies mappen" +renameFile: "Wijzig bestandsnaam" +folderName: "Mapnaam" +createFolder: "Map aanmaken" +renameFolder: "Map hernoemen" +deleteFolder: "Map verwijderen" +addFile: "Bestand toevoegen" +emptyDrive: "Jouw Drive is leeg." +emptyFolder: "Deze map is leeg" +unableToDelete: "Kan niet worden verwijderd" +inputNewFileName: "Voer een nieuwe naam in" +copyUrl: "URL kopiëren" +rename: "Hernoemen" +avatar: "Avatar" +banner: "Banner" +nsfw: "Gevoelig" +whenServerDisconnected: "Wanneer de verbinding met de server wordt onderbroken" +disconnectedFromServer: "Verbinding met de server onderbroken." +inMb: "in megabytes" +pinnedNotes: "Vastgemaakte notitie" +userList: "Lijsten" +aboutIceshrimp: "Over Iceshrimp" +administrator: "Beheerder" +token: "Token" +securityKeyName: "Sleutelnaam" +registerSecurityKey: "Zekerheids-Sleutel registreren" +lastUsed: "Laatst gebruikt" +unregister: "Uitschrijven" +passwordLessLogin: "Inloggen zonder wachtwoord" +resetPassword: "Wachtwoord terugzetten" +newPasswordIs: "Het nieuwe wachtwoord is „{password}”." +reduceUiAnimation: "Verminder beweging in de UI" +share: "Delen" +notFound: "Niet gevonden" +cacheClear: "Cache verwijderen" +smtpHost: "Server" +smtpUser: "Gebruikersnaam" +smtpPass: "Wachtwoord" +clearCache: "Cache opschonen" +user: "Gebruikers" +muteThread: "Discussies dempen " +unmuteThread: "Dempen van discussie ongedaan maken" +hide: "Verbergen" +searchByGoogle: "Zoeken" +cropImage: "Afbeelding bijsnijden" +cropImageAsk: "Bijsnijdengevraagd" +file: "Bestanden" +_email: + _follow: + title: "Je hebt een nieuwe volger" + _receiveFollowRequest: + title: Je hebt een volgverzoek ontvangen +_mfm: + mention: "Vermelding" + quote: "Quote" + search: "Zoeken" + warn: MFM kan snel bewegende of knipperende animaties bevatten + intro: MFM is een opmaaktaal die door Iceshrimp, Misskey, Akkoma, en anderen wordt + gerbuikt in posts en chats. Je kan hier een lijst vinden met alle beschikbare + MFM-commando's. + mentionDescription: Je kan een user benoemen met een apestaartje, gevolgd door een + gebruikersnaam. + dummy: Iceshrimp breidt de wereld van de Fediverse uit + twitchDescription: Geeft inhoud een sterk trillende animatie. + emojiDescription: Door de naam van een eigen emoji tussen dubbele punten te zetten, + kan er een eigen emoji getoond worden. + alwaysPlay: Altijd alle geanimeerde MMF afspelen + spin: Animatie (draaien) + boldDescription: Geeft nadruk op letters door ze dik te drukken. + fade: Vervagen + play: MFM afspelen + stop: MFM stoppen + url: URL + urlDescription: URL's kunnen getoond worden. + bold: Dikgedrukt + small: Klein + cheatSheet: MFM-spiekbriefje + advanced: Geavanceerd MFM + hashtag: Hashtag + hashtagDescription: Je kan een hashtag opgeven met een hekje (#) en tekst. + link: Link + linkDescription: Specifieke delen van tekst kunnen als een URL getoond worden. + advancedDescription: Indien uitgeschakeld, is alleen basis-opmaak mogelijk tenzij + geanimeerde MFM aan het afspelen is + center: Midden + inlineCode: Broncode (in tekst) + blockCode: Broncode (in blok) + inlineMath: Wiskundige formules (in tekst) + blockMath: Wiskundige formules (in blok) + smallDescription: Toont inhoud klein en dungedrukt. + centerDescription: Plaatst content in het midden. + inlineMathDescription: Toon wiskundige formules (KaTeX) in tekst + blockMathDescription: Toon wiskundige formules (KaTeX) in een blok + quoteDescription: Toont inhoud als een citaat. + flip: Spiegelen + flipDescription: Inhoud horizontaal of verticaal spiegelen. + jelly: Animatie (pudding) + jellyDescription: Geeft inhoud een pudding-achtige animatie. + tada: Animatie (Tada) + tadaDescription: Geeft inhoud een "Tada!"-achtige animatie. + inlineCodeDescription: Toont syntax-highlighting in text voor (programma-)code. + emoji: Eigen emoji + searchDescription: Toont een zoekveld met vooraf opgegeven tekst. + jump: Animatie (springen) + jumpDescription: Geeft content een 'springende' animatie. + blockCodeDescription: Toont syntax-highlighting voor (programma-)code in meerdere + regels in een blok. + shake: Animatie (schudden) + shakeDescription: Geeft inhoud een schudanimatie. + bounce: Animatie (stuiteren) + bounceDescription: Geeft inhoud een stuiterende animatie. + twitch: Animatie (trillen) + spinDescription: Geeft inhoud een draaiende animatie. + x2Description: Toont inhoud groter. + x3: Heel groot + x3Description: Toont inhoud nog groter. + x4: Ongelofelijk groot + position: Positie + x4Description: Toont inhoud nog groter-dan-groot. + blur: Vervaging + blurDescription: Vervaagt inhoud. Het zal helder worden weergegeven wanneer de muisaanwijzer + over de inhoud wordt geplaatst. + font: Lettertype + fontDescription: Stelt het lettertype voor de inhoud in. + rainbow: Regenboog + rainbowDescription: Laat de inhoud in regenboogkleuren tonen. + rotate: Draaien + rotateDescription: Draait inhoud met een specifieke hoek. + fadeDescription: Inhoud zal in- en uit vervagen. + crop: Bijsnijden + cropDescription: Inhoud bijsnijden. + scale: Schaal + scaleDescription: Inhoud op een bepaalde schaal schalen. + foreground: Voorgrondkleur + foregroundDescription: Wijzig de voorgrondkleur van de tekst. + background: Achtergrondkleur + backgroundDescription: Wijzig de achtergrondkleur van de tekst. + plain: Eenvoudig + x2: Groot +_theme: + keys: + mention: "Vermelding" + renote: "Herdelen" + wallpaperOverlay: Overlay op achtergrondbeeld + buttonHoverBg: Achtergrond van knop (zwevend) + messageBg: Chatachtergrond + accentDarken: Accent (donker) + accentLighten: Accent (licht) + fgHighlighted: Geselecteerde tekst + explore: Thema's verkennen + install: Een thema installeren + manage: Thema's beheren + code: Themacode + description: Omschrijving + installed: '{name} is geïnstalleerd' + installedThemes: Geïnstalleerde thema's + builtinThemes: Ingebouwde thema's + alreadyInstalled: Dit thema is al geïnstalleerd + make: Maak een thema + base: Basis + color: Kleur +_sfx: + note: "Notities" + notification: "Meldingen" + chat: "Chat" + noteMy: Eigen post + chatBg: Chat (achtergrond) + antenna: Antennes + channel: Kanaalnotificaties +_widgets: + notifications: "Meldingen" + timeline: "Tijdlijn" + activity: "Activiteit" + federation: "Federatie" + jobQueue: "Job Queue" + calendar: Kalender + trends: Trending + clock: Klok + rss: RSS-lezer + rssTicker: RSS-lichtkrant + photos: Foto's + digitalClock: Digitale klok + unixClock: UNIX-klok + button: Knop + onlineUsers: Online gebruikers + serverMetric: Serverstatistieken + serverInfo: Serverinfo +_cw: + show: "Laad meer" + chars: '{count} karakters' + hide: Inhoud verbergen +_visibility: + home: "Startpagina" + followers: "Volgers" + public: Openbaar + publicDescription: Je post zal in alle openbare tijdlijnen zichtbaar zijn + homeDescription: Alleen naar thuistijdlijn posten + followersDescription: Je post alleen voor volgers en andere genoemde gebruikers + zichtbaar maken + specified: Direct + specifiedDescription: Alleen zichtbaar maken voor genoemde gebruikers + localOnly: Alleen lokaal + localOnlyDescription: Niet zichtbaar voor gebruikers buiten deze server +_profile: + username: "Gebruikersnaam" +_exportOrImport: + followingList: "Volgend" + muteList: "Dempen" + blockingList: "Blokkeren" + userLists: "Lijsten" + excludeMutingUsers: "Negeer gedempte gebruikers" + excludeInactiveUsers: "Negeer inactieve gebruikers" +_charts: + federation: "Federatie" + notesTotal: Totaal aantal posts + filesTotal: Totaal aantal bestanden +_timelines: + home: "Startpagina" + recommended: Aanbevolen + social: Sociaal + global: Globaal +_pages: + blocks: + image: "Afbeeldingen" + script: + categories: + list: "Lijsten" + blocks: + _join: + arg1: "Lijsten" + _randomPick: + arg1: "Lijsten" + _dailyRandomPick: + arg1: "Lijsten" + _seedRandomPick: + arg2: "Lijsten" + _pick: + arg1: "Lijsten" + _listLen: + arg1: "Lijsten" + types: + array: "Lijsten" + created: Pagina succesvol gemaakt + editThisPage: Deze Pagina bewerken + viewPage: Je Pagina's inzien + like: Like + editPage: Deze pagina bewerken + newPage: Pagina aanmaken + updated: Pagina succesvol bewerkt + deleted: Pagina succesvol verwijderd + pageSetting: Pagina-instellingen + unlike: Like verwijderen + my: Mijn Pagina's + liked: Gelikete Pagina's + featured: Populair + inspector: Inspecteur + contents: Inhoud + content: Paginablok + variables: Variabelen + title: Titel + url: Pagina-URL + summary: Pagina-samenvatting + alignCenter: Centreren + viewSource: Bron weergeven +_notification: + youWereFollowed: "volgt jou" + _types: + follow: "Volgend" + mention: "Vermelding" + renote: "Herdelen" + quote: "Quote" + reaction: "Reacties" + _actions: + reply: "Antwoord" + renote: "Herdelen" +_deck: + _columns: + notifications: "Meldingen" + tl: "Tijdlijn" + list: "Lijsten" + mentions: "Vermeldingen" +showLess: Sluiten +emoji: Emoji +selectList: Selecteer een lijst +selectAntenna: Selecteer een antenne +deleted: Verwijderd +editNote: Bewerk notitie +edited: 'Bewerkt om {date} {time}' +emojis: Emojis +emojiName: Emoji naam +emojiUrl: Emoji URL +addEmoji: Voeg toe +settingGuide: Aanbevolen instellingen +flagSpeakAsCat: Praat als een kat +accountMoved: 'Gebruiker is naar een nieuw account verhuisd:' +showEmojisInReactionNotifications: Toon emojis in reactie notificaties +selectWidget: Selecteer een widget +editWidgetsExit: Klaar +noThankYou: Nee bedankt +addInstance: Voeg een server toe +enableEmojiReactions: Schakel emoji reacties in +editWidgets: Bewerk widgets +thisYear: Jaar +thisMonth: Maand +registration: Registreren +_ffVisibility: + public: Openbaar + private: Privé + followers: Alleen zichtbaar voor volgers +noInstances: Er zijn geen servers +_signup: + almostThere: Bijna klaar + emailAddressInfo: Voer je emailadres in. Deze zal niet openbaar gemaakt worden. + emailSent: Een bevestigingsemail is naar je emailadres ({email}) verstuurd. Klik + op de bijgevoegde link om je account te voltooien. +_ad: + back: Terug + reduceFrequencyOfThisAd: Toon deze advertentie minder +pushNotificationNotSupported: Je browser of server ondersteunt geen pushmeldingen +sendPushNotificationReadMessage: Verwijder pushmeldingen wanneer de relevante meldingen + of berichten zijn gelezen +customEmojis: Custom emoji +cacheRemoteFiles: Cache externe bestanden +hiddenTags: Verborgen hashtags +enableRecommendedTimeline: Schakel aanbevolen tijdlijn in +_forgotPassword: + enterEmail: Voer het emailadres in dat je gebruikte om te registreren. Een link + waarmee je je wachtwoord opnieuw kunt instellen zal daar naartoe gestuurd worden. + contactAdmin: Deze server ondersteunt geen emailadressen, neem daarom contact op + met de serveradministrator om je wachtwoord te resetten. + ifNoEmail: Neem contact op met de serveradministrator als je geen email tijdens + je registratie hebt opgegeven. +jumpToReply: Spring naar Antwoord +newer: nieuwer +older: ouder +selectInstance: Kies een server +defaultValueIs: 'Standaard: {value}' +reload: Hernieuwen +doNothing: Negeren +today: Vandaag +inputNewDescription: Voer een nieuw onderschrift in +inputNewFolderName: Voer een nieuwe mapnaam in +circularReferenceFolder: De bestemmingsmap is een submap van de map die je wil verplaatsen. +hasChildFilesOrFolders: Omdat deze map niet leeg is, kan deze niet verwijderd worden. +enableLocalTimeline: Schakel lokale tijdlijn in +enableGlobalTimeline: Schakel globale tijdlijn in +enableRegistration: Nieuwe gebruikersregistratie inschakelen +invite: Uitnodigen +move: Verplaatsen +showAds: Toon advertenties +pushNotification: Pushmeldingen +_gallery: + my: Mijn Gallerij + liked: Gelikete posts + like: Like + unlike: Like verwijderen +reactionSetting: Reacties om te tonen in het reactie selectie menu +dayX: '{day}' +renoteMute: Demp boosts +reloadConfirm: Wil je de tijdlijn hernieuwen? +watch: Volgen +unwatch: Ontvolgen +accept: Accepteren +reject: Afwijzen +normal: Normaal +pages: Pagina's +integration: Integraties +connectService: Koppelen +monthX: '{month}' +yearX: '{year}' +instanceName: Servernaam +instanceDescription: Server omschrijving +maintainerName: Onderhouder +maintainerEmail: Onderhouder email +tosUrl: Algemene Voorwaarden URL +disconnectService: Ontkoppelen +unread: Ongelezen +manageGroups: Beheer groepen +subscribePushNotification: Pushmeldingen inschakelen +unsubscribePushNotification: Pushmeldingen uitschakelen +pushNotificationAlreadySubscribed: Pushmeldingen zijn al ingeschakeld +antennaSource: Antenne bron +antennaKeywords: Trefwoorden om naar te luisteren +antennaExcludeKeywords: Trefwoorden om te negeren +driveCapacityPerRemoteAccount: Schijfruimte per externe gebruiker +backgroundImageUrl: Achtergrondafbeelding URL +basicInfo: Basis informatie +pinnedUsers: Vastgezette gebruikers +pinnedPages: Vastgezette Pagina's +driveCapacityPerLocalAccount: Schijfruimte per lokale gebruiker +iconUrl: Icoon URL +bannerUrl: Banner afbeelding URL +manageAntennas: Beheer Antennes +name: Naam +notifyAntenna: Meld nieuwe posts +withFileAntenna: Alleen posts met bestanden +enableServiceworker: Schakel pushmeldingen voor je browser in +renoteUnmute: Ontdemp boosts +jumpToPrevious: Spring naar vorige +caseSensitive: Hoofdlettergevoelig +cw: Inhoudswaarschuwing +recaptcha: reCAPTCHA +enableRecaptcha: reCAPTCHA inschakelen +recaptchaSiteKey: Site sleutel +notFoundDescription: Een pagina met deze URL kon niet worden gevonden. +uploadFolder: Standaard map voor uploads +markAsReadAllNotifications: Markeer alle notificaties als gelezen +text: Tekst +enable: Inschakelen +or: Of +language: Taal +securityKey: Veiligheidssleutel +groupInvited: Je bent voor een groep uitgenodigd +docSource: Bron van dit document +createAccount: Maak account aan +groupName: Groepsnaam +members: Leden +messagingWithUser: Privé chat +messagingWithGroup: Groepschat +title: Titel +createGroup: Maak een groep +ownedGroups: Beheerde groepen +invites: Uitnodigingen +useOsNativeEmojis: Gebruik je standaard besturingssysteem Emojis +disableDrawer: Gebruik niet de lade-stijl menus +joinOrCreateGroup: Krijg een uitnodiging voor een groep of maak er zelf eentje aan. +noHistory: Geen geschiedenis beschikbaar +signinHistory: Inloggeschiedenis +available: Beschikbaar +unavailable: Niet beschikbaar +tooShort: Te kort +signinFailed: Niet gelukt om in te loggen. Gebruikersnaam of wachtwoord is incorrect. +tapSecurityKey: Tik je veiligheidssleutel aan +recaptchaSecretKey: Geheime sleutel +antennas: Antennes +antennaUsersDescription: Zet één gebruikersnaam per regel neer +notesAndReplies: Posts en antwoorden +withFiles: Met bijlagen +popularUsers: Populaire gebruikers +recentlyUpdatedUsers: Recente actieve gebruikers +recentlyRegisteredUsers: Nieuwe gebruikers +recentlyDiscoveredUsers: Nieuwe ontdekte gebruikers +exploreUsersCount: Er zijn {count} gebruikers +about: Over +exploreFediverse: Ontdek de Fediverse +popularTags: Populaire labels +moderation: Moderatie +nUsersMentioned: Genoemd door {n} gebruikers +markAsReadAllUnreadNotes: Markeer alle posts als gelezen +markAsReadAllTalkMessages: Markeer alle berichten als gelezen +help: Help +inputMessageHere: Schrijf hier je bericht +close: Sluiten +group: Groep +groups: Groepen +newMessageExists: Er zijn nieuwe berichten +next: Volgende +noteOf: Post door {user} +inviteToGroup: Nodig uit voor de groep +quoteAttached: Quote +noMessagesYet: Nog geen berichten +weakPassword: Zwak wachtwoord +normalPassword: Middelmatig wachtwoord +strongPassword: Sterk wachtwoord +onlyOneFileCanBeAttached: Je kan maar één bestand toevoegen aan je bericht +invitationCode: Uitnodigingscode +checking: Controleren… +uiLanguage: Gebruikersinterface taal +aboutX: Over {x} +youHaveNoGroups: Je hebt geen groepen +disableAnimatedMfm: Schakel MFM met animaties uit +passwordMatched: Komt overeen +passwordNotMatched: Komt niet overeen +signinWith: Log in met {x} +fontSize: Tekstgrootte +openImageInNewTab: Open afbeeldingen in een nieuwe tab +category: Categorie +tags: Labels +existingAccount: Bestaand account +regenerate: Hernieuwen +dayOverDayChanges: Verschillen met gisteren +appearance: Uiterlijk +local: Lokaal +remote: Extern +total: Totaal +weekOverWeekChanges: Verschillen met vorige week +hcaptcha: hCaptcha +enableHcaptcha: hCaptcha inschakelen +hcaptchaSiteKey: Site sleutel +hcaptchaSecretKey: Geheime sleutel +withReplies: Met antwoorden +twoStepAuthentication: Tweefactorauthenticatie +moderator: Moderator +invitations: Uitnodigingen +tooLong: Te lang +doing: Verwerken… +silencedInstances: Gedempte Servers +cacheRemoteFilesDescription: Als deze instelling is uitgeschakeld, worden externe + bestanden direct van de externe server geladen. Het uitschakelen zal opslagruimte + verminderen, maar verkeer zal toenemen, omdat er geen thumbnails gemaakt zullen + worden. +flagSpeakAsCatDescription: Je posts zullen worden ge-'nyanified' als je in kat-modus + zit +avoidMultiCaptchaConfirm: Het gebruik van meerdere Captcha systemen kan voor storing + zorgen tussen ze. Wil je de andere actieve Captcha systemen uitschakelen? Als je + ze ingeschakeld wilt houden, klik op annuleren. +silence: Dempen +silenceConfirm: Weet je zeker dat je deze gebruiker wilt dempen? +unsilence: Ontdempen +unsilenceConfirm: Weet je zeker dat je het dempen van deze gebruiker ongedaan wilt + maken? +silenceThisInstance: Demp deze server +silenced: Gedempt +disablingTimelinesInfo: Beheerders en moderators zullen altijd toegang hebben tot + alle tijdlijnen, zelfs als deze uitgeschakeld zijn. +accountSettings: Account Instellingen +numberOfDays: Aantal dagen +hideThisNote: Verberg deze post +dashboard: Dashboard +accessibility: Toegankelijkheid +promotion: Gepromoot +promote: Promoten +objectStorage: Objectopslag +useObjectStorage: Gebruik objectopslag +objectStorageBaseUrl: Basis -URL +objectStorageUseSSLDesc: Schakel dit uit als je geen HTTPS voor je API connecties + gebruikt +objectStorageUseProxy: Verbind over Proxy +objectStorageUseProxyDesc: Schakel dit uit als je geen Proxy voor je API connecties + gebruikt +sounds: Geluiden +lastUsedDate: Laatst gebruikt op +installedDate: Geautoriseerd op +sort: Sorteren +output: Uitvoer +script: Script +popout: Pop-out +descendingOrder: Aflopend +showInPage: Toon in de pagina +chooseEmoji: Kies een emoji +ascendingOrder: Oplopend +volume: Volume +masterVolume: Master volume +details: Details +unableToProcess: Deze operatie kon niet worden voltooid +nothing: Niks te zien hier +scratchpad: Kladblok +recentUsed: Recentelijk gebruikt +install: Installeer +uninstall: Verwijderen +installedApps: Geautoriseerde Applicaties +state: Status +updateRemoteUser: Update externe gebruikersinformatie +listen: Luister +none: Geen +scratchpadDescription: Het kladblok is een omgeving voor AiScript experimenten. Je + kan hier schrijven, uitvoeren, en de resultaten bekijken van de interactie met Iceshrimp. +disablePagesScript: Zet AiScript op Pages uit +deleteAllFiles: Verwijder alle bestanden +deleteAllFilesConfirm: Weet je zeker dat je alle bestanden wil verwijderen? +removeAllFollowing: Ontvolg alle gevolgde gebruikers +serverLogs: Server logboek +deleteAll: Verwijder alles +showFixedPostForm: Toon het post formulier bovenaan de tijdlijn +newNoteRecived: Er zijn nieuwe posts +smtpPort: Poort +database: Database +previewNoteText: Voorvertoning laten zien +saveAs: Opslaan als… +expandAllCws: Inhoud voor alle reacties tonen +listsDesc: Met lijsten kan je tijdlijnen maken met bepaalde gebruikers. Je kan deze + inzien via de tijdlijnen. +selectChannel: Een kanaal selecteren +silencedInstancesDescription: Geef de hostnames op van servers die je wilt dempen. + Accounts in de servers in deze lijst worden behandeld als 'gedempt' en kunnen alleen + volgverzoeken maken, en kunnen geen lokale accounts vermelden. Dit heeft geen effect + op geblokkeerde servers. +usernameInvalidFormat: Je kan hoofd- en kleine letters, cijfers en liggende streepjes + gebruiken. +noFollowRequests: Je hebt geen openstaande volgverzoeken +objectStorageBaseUrlDesc: "De URL, ter referentie. Geef de URL van je CDN of proxy + als je een van beide gebruikt.\nVoor S3 gebruik 'https://.s3.amazonaws.com' + en gebruik voor GCS of equivalente diensten 'https://storage.googleapis.com/', + etc." +removeAllFollowingDescription: Dit uitvoeren zal alle accounts van {host} ontvolgen. + Gebruik dit als de server bijvoorbeeld niet meer bestaat. +yourAccountSuspendedDescription: Dit account is opgeschort doordat deze de server-voorwaarden + heeft overtreden, of iets soortgelijks. Neem contact op met de administrator als + je een meer gedetailleerde reden wilt weten. Maak alsjeblieft geen nieuw account + aan. +leaveConfirm: Er zijn nog niet opgeslagen wijzigingen. Wil je ze verwerpen? +tokenRequested: Toegang geven tot account +pluginTokenRequestedDescription: Deze plugin zal de permissies die hier staan kunnen + gebruiken. +emailConfigInfo: Wordt gebruikt om tijdens registratie je email te bevestigen of wanneer + je je wachtwoord vergeet +regexpErrorDescription: 'Een fout is opgetreden in de regular expression op regel + {line} van je {tab} woorddempingen:' +channelFederationWarn: Kanalen federeren nog niet naar andere servers +makeExplorableDescription: Als je dit uit zet, verschijnt je account niet in het "Verkennen"-tabblad. +sendErrorReports: Foutrapporten sturen +sendErrorReportsDescription: "Wanneer deze optie aanstaat, wordt er gedetailleerde + foutinformatie gedeeld met Iceshrimp als een fout optreedt, zodat de kwaliteit van + Iceshrimp verbeterd kan worden.\n Dit bevat informatie zoals de versie van je besturingssysteem, + je browser, je activiteit binnen Iceshrimp, etc." +receiveAnnouncementFromInstance: Notificaties van deze server ontvangen +publish: Publiceren +markAllAsRead: Alles als gelezen markeren +notSpecifiedMentionWarning: Deze post noemt gebruikers die niet toegevoegd zijn als + ontvangers +disabled: Uitgeschakeld +noMaintainerInformationWarning: Beheerdersinformatie is niet geconfigureerd. +privateModeInfo: Indien ingeschakeld kunnen alleen de servers in de lijst met je server + federeren. Alle posts worden van het brede publiek verborgen. +allowedInstancesDescription: Hostnames van servers waarmee gefedereerd mag worden, + gescheiden door een regeleinde (alleen van toepassing in privémodus). +iceshrimpUpdated: Iceshrimp is bijgewerkt! +usernameInfo: Een naam die jouw account van anderen kan onderscheiden op deze server. Je + kan het alfabet (a-z, A-Z), cijfers (0-9) of liggende streepjes (_) gebruiken. Gebruikersnamen + kunnen later niet gewijzigd worden. +searchPlaceholder: Doorzoek de Fediverse +collapseAllCws: Inhoud voor alle reacties verbergen +removeReaction: Verwijder je reactie +pinnedUsersDescription: Geef gebruikersnamen op, gescheiden door middel van lijnonderbrekingen, + om gepind te worden in het "Verkennen"-tabblad. +pinnedPagesDescription: Geef de paden van de Pagina's op die je aan de top-pagina + van deze server wilt vastpinnen, gescheiden door lijnonderbrekingen. +antennasDesc: "Antennas tonen nieuwe posts die voldoen aan criteria die jij instelt!\n + Ze zijn zichtbaar in de tijdlijnen-pagina." +antennaKeywordsDescription: Onderbreek met spaties voor een 'AND'-conditie of met + lijnonderbrekingen voor een 'OR'-conditie. +antennaInstancesDescription: Één server host per regel +useCw: Inhoud verbergen +showTitlebar: Titelbalk tonen +learnMore: Meer informatie +antennaTimelineHint: Antennes tonen overeenkomende posts in volgorde van binnenkomst. + Dat is niet per sé chronologisch. +connectedTo: Volgende account(s) zijn verbonden +signinRequired: Log alsjeblieft in of registreer voordat je verder gaat +clientSettings: Client-instellingen +showFeaturedNotesInTimeline: Toon uitgelichtte posts in tijdlijnen +objectStorageRegionDesc: Geef een regio op, bijvoorbeeld 'xx-east-1'. Laat dit leeg + of vul 'us-east-1' in als je dienst geen onderscheid maakt tussen regios. +objectStorageUseSSL: Gebruik SSL +objectStorageSetPublicRead: Stel "public-read" in op upload +userSuspended: Deze gebruiker is opgeschort. +userSilenced: Deze gebruiker is gedempt. +yourAccountSuspendedTitle: Dit account is opgeschort +menu: Menu +relays: Relays +addRelay: Relay Toevoegen +inboxUrl: Inbox-URL +addedRelays: Toegevoegde Relays +serviceworkerInfo: Moet ingeschakeld zijn voor pushnotificaties. +deletedNote: Verwijderde post +invisibleNote: Onzichtbare post +enableInfiniteScroll: Automatisch meer laden +visibility: Zichtbaarheid +cannotChangeScopeWhenEditing: Je kan de zichtbaarheid van deze post tijdens het bewerken + niet wijzigen +poll: Peiling +enablePlayer: Videospeler openen +disablePlayer: Videospeler sluiten +expandTweet: Tweet uitklappen +themeEditor: Thema-bewerker +description: Omschrijving +describeFile: Onderschrift toevoegen +enterFileDescription: Ondertitel invoeren +author: Auteur +manage: Beheer +plugins: Plugins +width: Breedte +height: Hoogte +xl: XL +large: Groot +medium: Middel +small: Klein +generateAccessToken: Toegangstoken genereren +permission: Permissies +enableAll: Alles inschakelen +disableAll: Alles uitschakelen +notificationType: Notificatietype +edit: Wijzigen +emailServer: Emailserver +enableEmail: Emaildistributie inschakelen +email: Email +emailAddress: Emailadres +smtpConfig: SMTP-server Configuratie +emptyToDisableSmtpAuth: Laat gebruikersnaam en wachtwoord leeg om SMTP-verificatie + uit te zetten +smtpSecure: Implicit SSL/TLS voor SMTP-verbindingen gebruiken +smtpSecureInfo: Zet dit uit wanneer STARTTLS wordt gebruikt +testEmail: Emaillevering testen +wordMute: Woord dempen +regexpError: Regular Expression-fout +instanceMute: Server Dempingen +userSaysSomething: '{name} zei iets' +userSaysSomethingReason: '{name} zei {reason}' +userSaysSomethingReasonReply: '{name} heeft op een post met {reason} geantwoord' +userSaysSomethingReasonRenote: '{name} heeft een post met {reason} geboost' +userSaysSomethingReasonQuote: '{name} heeft een post met {reason} gequote' +makeActive: Activeren +create: Maken +notificationSetting: Notificatieinstellingen +notificationSettingDesc: Selecteer de types notificatie om te tonen. +useGlobalSetting: Globale instellingen gebruiken +showGapBetweenNotesInTimeline: Toont een scheiding tussen posts in de tijdlijn +duplicate: Dubbel +left: Links +center: Midden +wide: Wijd +narrow: Smal +reloadToApplySetting: Deze instelling wordt van alleen kracht nadat de pagina is herladen. + Wil je nu herladen? +needReloadToApply: Herladen is nodig om dit te tonen. +onlineUsersCount: '{n} gebruikers zijn online' +nUsers: '{n} Gebruikers' +nNotes: '{n} Posts' +myTheme: Mijn thema +backgroundColor: Achtergrondkleur +accentColor: Accentkleur +textColor: Tekstkleur +advanced: Geavanceerd +value: Waarde +createdAt: Gemaakt op +saveConfirm: Wijzigingen opslaan? +deleteConfirm: Echt verwijderen? +invalidValue: Ongeldige waarde. +closeAccount: Account sluiten +currentVersion: Huidige versie +latestVersion: Nieuwste versie +youAreRunningUpToDateClient: Je gebruikt de nieuwste versie van je client. +newVersionOfClientAvailable: Er is een nieuwere versie van je client beschikbaar. +capacity: Capaciteit +apply: Toepassen +emailNotification: Emailnotificaties +inChannelSearch: In kanaal zoeken +useReactionPickerForContextMenu: Reactie-kiezer openen na rechts klikken +jumpToSpecifiedDate: Naar specifieke datum springen +clear: Legen +goBack: Terug +fullView: Volledige weergave +quitFullView: Volledige weergave verlaten +addDescription: Omschrijving toevoegen +info: Over +userInfo: Gebruikersinformatie +unknown: Onbekend +onlineStatus: Onlinestatus +hideOnlineStatus: Onlinestatus verbergen +hideOnlineStatusDescription: Je onlinestatus verbergen verminderd het gemak van sommige + functies zoals de zoekfunctie. +online: Online +active: Actief +offline: Offline +notRecommended: Niet aanbevolen +instanceBlocking: Federatiebeheer +selectAccount: Account selecteren +switchAccount: Account wijzigen +enabled: Ingeschakeld +quickAction: Snelle acties +administration: Beheer +accounts: Accounts +switch: Omschakelen +noBotProtectionWarning: Robotbescherming is niet geconfigureerd. +configure: Configureren +postToGallery: Nieuwe galerijpost maken +gallery: Galerij +recentPosts: Recente pagina's +popularPosts: Populaire pagina's +shareWithNote: Met post delen +ads: Advertenties +expiration: Deadline +memo: Memo +priority: Prioriteit +high: Hoog +middle: Midden +low: Laag +emailNotConfiguredWarning: Emailadres niet ingesteld. +ratio: Verhouding +secureMode: Veilige Modus (Authorized Fetch) +instanceSecurity: Serverbeveiliging +secureModeInfo: Bij verzoeken aan andere servers, niks terugsturen zonder bewijs. +privateMode: Privémodus +allowedInstances: Toegestane servers +customCss: Aangepaste CSS +customCssWarn: Deze instellingen horen alleen gebruikt te worden als je weet wat ze + doen. Foute waardes invoeren kan er voor zorgen dat de client niet meer goed werkt. +global: Globaal +recommended: Aanbevolen +squareAvatars: Vierkante avatars tonen +seperateRenoteQuote: Losse boost- en quoteknoppen +sent: Verstuurd +received: Ontvangen +searchResult: Zoekresultaten +hashtags: Hashtags +troubleshooting: Foutoplossing +useBlurEffect: Waas-effecten in de UI gebruiken +whatIsNew: Wijzigijngen tonen +translate: Vertalen +accountDeletionInProgress: Account wordt momenteel verwijderd +keepCw: Inhoudswaarschuwingen behouden +forwardReport: Melding naar de betreffende server doorsturen +notSet: Niet ingesteld +editCode: Code wijzigen +alwaysExpandCws: Altijd posts met inhoudswaarschuwingen uitklappen +_wellness: + name: Welzijn + description: Met deze instellingen kan je wijzigijngen aan brengen aan mogelijk + verslavende of beangstigende aspecten van sociale media. Gebruik de instellingen + die het best voor jou werken. +_cwStyle: + modern: Modern + classic: Klassiek (Misskey/Foundkey-achtig) + alternative: Alternatief (Firefish-achtig) +alwaysMarkSensitive: Standaard als gevoelig markeren +sensitiveMediaDetection: Gevoelige media detecteren +enableAutoSensitive: Automatisch als gevloeg markeren +enableAutoSensitiveDescription: Staat automatische detectie en markering van gevoelige + media door middel van Machine Learning toe, waar mogelijk. Zelfs als deze optie + uitstaat, kan dit ingeschakeld worden voor de hele server. +cannotUploadBecauseInappropriate: Dit bestand kan niet geüpload worden omdat delen + hiervan mogelijk gevoelig zijn. +refreshInterval: Vernieuwingsinterval +fillAbuseReportDescription: Vul alsjeblieft details in over dit rapport. Als het over + een specifieke post gaat, voeg dan de URL toe. +defaultNavigationBehaviour: Standaard navigatiegedrag +unclip: Uit clip verwijderen +confirmToUnclipAlreadyClippedNote: Deze post hoort al bij de clip "{name}". Wil je + deze in plaats daarvan uit deze clip verwijderen? +receivedReactionsCount: Aantal ontvangen reacties +noCrawleDescription: Verzoek zoekmachines om je profielpagina, posts, Pages etc. niet + te indexeren +lockedAccountInfo: Zelfs als je volgverzoeken handmatig bevestigd, zullen posts voor + iedereen zichtbaar zijn, tenzij je de zichbaarheid van je post op "Alleen volgers" + instelt. +oneWeek: Één week +recentNHours: Afgelopen {n} uren +requireAdminForView: Je moet met een administratieaccount inloggen om dit te kunnen + zien. +isSystemAccount: Dit account is automatisch gemaakt, en wordt beheerd door het systeem. + Modereer, bewerk, verwijder of knoei niet met dit account, of anders kan je server + defect raken. +_accountDelete: + mayTakeTime: Omdat het verwijderen van een account veel systeembronnen nodig heeft, + kan het even duren om te voltooien, afhankelijk van hoeveel inhoud je hebt gemaakt + en hoeveel bestanden je hebt geüpload. + accountDelete: Account verwijderen + sendEmail: Als je accountverwijdering is uitgevoerd, wordt er een email gestuurd + naar het adres gekoppeld aan dit account. + requestAccountDelete: Accocuntverwijdering verzoeken + started: Verwijdering is gestart. + inProgress: Accountverwijdering wordt uitgevoerd +_instanceMute: + instanceMuteDescription: Dit zal alle posts/boosts van deze servers dempen, inclusief + deze van gebruikers die op een gebruiker van een gedempte server reageren. + instanceMuteDescription2: Met regeleinde onderbreken + title: Verbergt posts van deze servers. + heading: Lijst van gedempte servers +_filters: + excludeRenotes: Boosts uitsluiten + _dialog: + learnMore: Filter-syntax tonen + title: Zoekfilter-syntax + postDate: Op post-datum filteren + exclusivity: 'Let op dat het before: filter exclusief is en dat het after: filter + inclusief is.' + word: woord + phrase: letterlijke zin die (willikeurige) karakters bevat + attachmentType: Op bijlage type(s) filteren + replyTo: In antwoord op + fromUser: Van gebruiker + followersOnly: Alleen volgers + repliesOnly: Alleen antwoorden + excludeReplies: Antwoorden uitsluiten + caseSensitive: Hoofdlettergevoelig +_tutorial: + step2_2: Met wat basisinformatie kunnen mensen makkelijker beslissen of ze je posts + willen zien of je willen volgen. + step3_2: "Je thuis- en social-tijdlijnen zijn gebaseerd op wie je volgt, dus probeer + om te beginnen een paar accounts te volgen.\nKlik op het plus-cirkeltje in de + rechterbovenhoek van een profiel om diegene te volgen." + step6_3: Iedere server werkt anders, en ze draaien niet allemaal Iceshrimp. Deze + wel! Het is misschien wat lastig, maar je zal het in no time beter begrijpen. + step1_1: Welkom! + step1_2: Laten we beginnen. Je bent zo klaar! + step2_1: Vul eerst je profiel in. + step3_1: Nu is het tijd om wat mensen te gaan volgen! + step6_1: Dus, wat is deze plek? + step6_2: Nou, je bent niet alleen op Iceshrimp. Je hebt een portaal naar de Fediverse + geopend, een verbonden netwerk bestaande uit duizenden servers. +numberOfPageCacheDescription: Dit getal verhogen zal het gemak voor gebruikers verhogen + maar zal meer last op de server leggen, en ook meer geheugen gebruiken. +activeEmailValidationDescription: Schakelt strictere controle op de geldigheid van + emailadressen in, waaronder controleren op wegwerpadressen en of er ook daadwerkelijk + mee gecommuniceerd kan worden. Indien uitgeschakeld, wordt alleen het formaat van + het emailadres gecontrolerd. +_sensitiveMediaDetection: + sensitivityDescription: De gevoeligheid van de detectie verlagen zal resulteren + in minder foute detecties (fout-positieven), maar dit verhogen zal leiden tot + mindere gemiste detecties (fout-negatieven). + analyzeVideos: Videoanalyse inschakelen + analyzeVideosDescription: Naast afbeeldingen ook video's analiseren. Dit zal de + last op de server lichtelijk verhogen. + setSensitiveFlagAutomatically: Als gevoelig markeren. + setSensitiveFlagAutomaticallyDescription: De resultaten van de interne detectie + zullen bewaard worden, ook wanneer deze optie uit staat. + description: Verminderd de last op servermoderatie door middel van het automatisch + herkennen van gevoelige media door Machine Learning. Dit zal de last op de server + lichtelijk verhogen. + sensitivity: Detectiegevoeligheid +_preferencesBackups: + invalidFile: Ongeldig bestandsformaat + applyConfirm: Wil je echt de backup "{naam}" toepassen op dit apparaat? Bestaande + instellingen worden overschreven. + delete: Backup verwijderen + createdAt: 'Gemaakt op: {date} {time}' + saveConfirm: Backup als {naam} opslaan? + list: Gemaakte backups + saveNew: Nieuwe backup opslaan + loadFile: Van bestand inladen + apply: Toepassen op dit apparaat + save: Wijzigingen opslaan + inputName: Voer een naam in voor deze backup + cannotSave: Opslaan mislukt + nameAlreadyExists: Een backup genaamd "{name}" bestaat al. Geef een andere naam + op. + renameConfirm: Deze backup van "{old}" naar "{new}" hernoemen? + updatedAt: 'Geüpdatet op: {date} {time}' + cannotLoad: Inladen mislukt + deleteConfirm: De backup {name} verwijderen? + noBackups: Er bestaan geen backups. Je kan je clientinstellingen op deze server + opslaan door "Maak nieuwe backup" te gebruiken. +_auth: + authRequired: Authorizatie benodigd + copyAsk: 'Plak alsjeblieft de volgende authorizatiecode in de applicatie:' + allPermissions: Volledige toegang tot je account + signedInAs: Ingelogd als +moveFromDescription: 'Hiermee stel je een alias in van je oude account, zodat je van + dat account naar dit account kunt gaan. Doe dit VOORDAT je je oude account verplaatst. + Voer de tag van de account in als volgt in: @persoon@server.com' +customKaTeXMacroDescription: "Stel hier macro's in om makkelijk wiskundige formules + te beschrijven! De notatie is confirm de LaTeX-commandodefinities en wordt geschreven + als \\newcommand{\\ name}{content} of \\newcommand{\\name}[number of arguments]{content}. + Bijvoorbeeld: newcommand{\\add}[2]{#1 + #2} zal \\add{3}{foo} naar 3 + foo uitpakken. + De krulhaakjes om de macro-naam kunnen veranderd worden naar ronde of cierkante + haakjes. Dit heeft effect op de haakjes die voor argumenten worden gebruikt. Er + kan slechts één macro per regel gedefinieerd worden, en je kan geen regelonderbreking + midden in de definitie plaatsen. Ongeldige regels worden genegeerd. Er is alleen + ondersteuning voor simpele stringvervangingsfuncties, geavanceerde syntax, zoals + conditional branching, kan niet worden gebruikt." +_aboutIceshrimp: + morePatrons: We waarderen ook de steun van vele andere helpers die hier niet genoemd + worden. Dank jullie wel! 🥰 + translation: Vertalingen + chatroom: Chatroom + documentation: Documentatie + roadmap: Roadmap + changelog: Changelog + donate: Doneer aan Iceshrimp + donateTitle: Gebruik je Iceshrimp graag? + pleaseDonateToIceshrimp: Overweeg een donatie aan Iceshrimp om de ontwikkeling te + steunen. + pleaseDonateToHost: Overweeg ook om aan je eigen server ({host}) te doneren, om + hun te steunen in operationele kosten. + donateHost: Doneer aan {host} + about: Iceshrimp is nóg een fork van Misskey, met no-nonsense verbeteringen, oplossing + en functies die je echt nodig hebt sinds 2023. + source: Iceshrimp-ontwikkeling + sponsors: Iceshrimp-sponsors + patrons: Iceshrimp-Patrons + patronsList: Op chronologische volgorde, niet op grootte van donatie. Doneer met + de bovenstaande link om je naam hier te krijgen! + contributors: Belangrijkste bijdragers + allContributors: Alle bijdragers +hiddenTagsDescription: 'Geef de hashtags (zonder #) op die je uit Trending en Verkennen + wilt verbergen. Verborgen hashtags zijn nog steeds ontdekbaar via andere manieren.' +pinnedClipId: ID van de clip on vast te zetten +customMOTD: Zelf ingestelde dagelijkse boodschap (boodschappen in begroetingsscherm) +customSplashIcons: Eigen iconen voor begroetingsscherm (URL's) +sendPushNotificationReadMessageCaption: Een notificatie met de tekst "{emptyPushNotificationMessage}" + zal kort getoond worden. Dit kan batterijgebruik van je apparaat verhogen, indien + van toepassing. +adminCustomCssWarn: Deze instelling hoort alleen gebruikt te worden als je weet wat + deze doet. Hier onjuiste waardes opgeven kan er voor zorgen dat IEDEREEN zijn clients + niet meer normaal werken. Controleer of je CSS werkt door het in de gebruikersinstellingen + te testen. +enterSendsMessage: Druk op Return om in Chat om een bericht te sturen (uitschakelen + met Control + Return) +caption: Automatische ondertiteling +_weekday: + wednesday: Woensdag + sunday: Zondag + monday: Maandag + tuesday: Dinsdag + thursday: Donderdag + friday: Vrijdag + saturday: Zaterdag +_poll: + totalVotes: '{n} totale stemmen' + showResult: Toon resultaten + voted: Gestemd + closed: Beëindigd + remainingDays: Nog {d} dag(en) en {h} u(u)r(en) + noOnlyOneChoice: Tenminste twee keuzes nodig + choiceN: Keuze {n} + noMore: Je kan niet nog meer opties toevoegen + expiration: Peiling beëindigen + infinite: Nooit + at: Eindigt op… + after: Eindigt na… + deadlineDate: Einddatum + deadlineTime: Tijd + votesCount: '{n} stemmen' + vote: Stem + remainingHours: Nog {h} u(u)r(en) en {m} minu(u)t(en) + remainingMinutes: Nog {m} minu(u)t(en) en {s} seconde(n) + remainingSeconds: Nog {s} seconde(n) +_skinTones: + mediumLight: Middel-licht + yellow: Geel + light: Licht + medium: Middel + mediumDark: Middel-donker + dark: Donker +enableCustomKaTeXMacro: Zelf geschreven KaTeX-macro's inschakelen +noteId: Post-ID +migration: Migratie +moveTo: Huidig account naar een nieuw account verhuizen +moveAccount: Verhuizen! +recommendedInstances: Aanbevolen servers +recommendedInstancesDescription: Aanbevolen servers die in de aanbevolen tijdlijn + zullen verschijnen, gescheiden door lijnonderbrekingen. +logoImageUrl: Afbeeldings-URL logo +showAdminUpdates: Geef aan wanneer er een nieuwe Iceshrimp-versie is (alleen voor + admins) +updateAvailable: Er is misschien een update beschikbaar! +moveAccountDescription: Dit proces is niet terug te draaien. Controleer dat je een + alias voor dit account hebt ingesteld op je nieuwe account voor je verhuist. Voer + de tag van het account in als @persoon@server.com +moveFrom: Naar dit account verhuizen van een oud account +moveFromLabel: 'Account waar je vandaan verhuist:' +migrationConfirm: "Weet je absoluut zeker dat je je account wilt migreren naar {account}? + Als je dit eenmaal hebt gedaan, kun je het niet meer ongedaan maken en kun je je + account niet meer normaal gebruiken.\nZorg er ook voor dat je dit huidige account + hebt ingesteld als het account waarvan je verhuist." +signupsDisabled: Aanmelden op deze server is niet toegestaan, maar je kan je altijd + op een andere server aanmelden! Als je een uitnodigingscode hebt voor deze server, + kan je deze hieronder invoeren. +customSplashIconsDescription: URL's voor eigen iconen in het begroetingsscherm (gescheidden + door lijnonderbrekingen), worden willikeurig getoond wanneer een gebruiker de pagina + (her)laadt. Zorg ervoor dat deze op een vaste URL staan, bij voorkeur geschaald + naar 192x192. +showUpdates: Toon een popup wanneer Iceshrimp geupdatet wordt +splash: Begroetingsscherm +swipeOnMobile: Vegen tussen pagina's toestaan +swipeOnDesktop: Op desktop veeggebaren als op mobiel toestaan +replayTutorial: Uitleg opnieuw starten +defaultReaction: Standaard emoji-reactie voor inkomende en uitgaande posts +license: Licentie +customKaTeXMacro: Zelf gedefinieerde KaTeX-macro's +preventAiLearning: AI-bot scraping voorkomen +overridedDeviceKind: Apparaattype +smartphone: Smartphone +tablet: Tablet +auto: Automatisch +size: Grootte +numberOfColumn: Aantal kolommen +instanceDefaultLightTheme: Standaard licht thema voor de hele server +instanceDefaultDarkTheme: Standaard donker thema voor de hele server +instanceDefaultThemeDescription: Voer het thema-JSON in. +mutePeriod: Duur van dempen +indefinitely: Permanent +tenMinutes: 10 minuten +oneHour: Één uur +oneDay: Één dag +reflectMayTakeTime: Het kan even duren voor dit zichtbaar is. +failedToFetchAccountInformation: Kan account informatie niet ophalen +image: Afbeelding +video: Video +audio: Geluid +recentNDays: Afgelopen {n} dagen +noEmailServerWarning: Mailserver niet ingesteld. +thereIsUnresolvedAbuseReportWarning: Er zijn onopgeloste meldingen. +typeToConfirm: Voer {x} in om te bevestigen +deleteAccount: Account verwijderen +logoutConfirm: Echt uitloggen? +type: Type +speed: Snelheid +slow: Langzaam +fast: Snel +themeColor: Kleur van de serverlichtkrant +rateLimitExceeded: Rate-limiet overschreden +driveCapOverrideLabel: Verander de schijfcapaciteit voor deze gebruiker +driveCapOverrideCaption: Reset naar standaardwaarde door 0 of lager als waarde op + te geven. +document: Documentatie +numberOfPageCache: Aantal pagina's in cache +statusbar: Statusbalk +pleaseSelect: Selecteer een optie +colored: Gekleurd +label: Label +check: Controle +lastActiveDate: Laatst gebruikt op +reverse: Omkeren +localOnly: Alleen lokaal +remoteOnly: Alleen buiten deze server +failedToUpload: Upload mislukt +account: Account +cannotUploadBecauseExceedsFileSizeLimit: Dit bestand kan niet geupload worden omdat + het groter is dan de maximum toegestane grootte. +beta: Beta. +navbar: Navigatiebalk +cannotUploadBecauseNoFreeSpace: Upload mislukt door gebrek aan Drive-capaciteit. +shuffle: Shuffle +moveToLabel: 'Account waar je heen gaat verhuizen:' +removeRecipient: Ontvanger verwijderen +removeMember: Lid verwijderen +removeQuote: Citaat verwijderen +_feeds: + atom: Atom + copyFeed: Feed kopiëren + rss: RSS + jsonFeed: JSON-feed +reactionPickerSkinTone: Voorkeurshuidskleur voor emoji +findOtherInstance: Een andere server vinden +noGraze: Schakel alsjeblieft de "Graze for Mastodon" browserextensie uit, deze zorgt + voor storing met Iceshrimp. +silencedWarning: Je ziet deze pagina omdat deze gebruikers van servers afkomstig zijn + die door je admin zijn gedemt, dus ze zijn mogelijk spam. +isBot: Dit account is een robot +isLocked: Dit account heeft volgverzoeken +isAdmin: Administrator +youHaveUnreadAnnouncements: Je hebt ongelezen aankondigingen +donationLink: Link naar donatiepagina +neverShow: Niet meer tonen +remindMeLater: Misschien later +verifiedLink: Geverifieerde link +searchEmptyQuery: Geef alsjeblieft een zoekterm op. +apps: Apps +sendModMail: Moderatieboodschap sturen +preventAiLearningDescription: Verzoek AI-taalmodellen van derden om de inhoud die + je upload, zoals posts en afbeeldingen, niet te bestuderen. +isModerator: Moderator +isPatron: Iceshrimp-Patron +enableServerMachineStats: Server-hardwarestatistieken inschakelen +enableIdenticonGeneration: Identicons genereren inschakelen +showPopup: Gebruikers met popup informeren +openInMainColumn: In hoofdkolom openen +showWithSparkles: Met glinsteringen tonen +_plugin: + installWarn: Installeer alsjeblieft geen onbetrouwbare plugins. + manage: Plugins beheren + install: Plugins installeren +_nsfw: + respect: Gevoelige media verbergen + ignore: Gevoelige media niet verbergen + force: Alle media verbergen +_registry: + scope: Scope + key: Sleutel + keys: Sleutels + domain: Domein + createKey: Sleutel maken +_channel: + create: Kanaal maken + edit: Kanaal bewerken + featured: Trending + owned: Van jou + following: Gevolgd + usersCount: '{n} deelnemers' + notesCount: '{n} posts' + nameAndDescription: Naam en omschrijving + nameOnly: Alleen naam +_messaging: + groups: Groepen + dms: Privé +_menuDisplay: + sideFull: Zijkant + sideIcon: Zijkant (iconen) + top: Bovenkant + hide: Verbergen +_wordMute: + muteWords: Gedempte woorden + muteWordsDescription: Onderbreek met spaties voor een 'AND'-conditie of met lijnonderbrekingen + voor een 'OR'-conditie. + mutedNotes: Gedempte woorden +_instanceTicker: + none: Nooit tonen + remote: Voor gebruikers van andere servers tonen + always: Altijd tonen +_serverDisconnectedBehavior: + reload: Automatisch herladen + dialog: Waarschuwingsvenster tonen + quiet: Een niet-nadrukkelijke waarschuwing tonen + nothing: Niks doen +_ago: + secondsAgo: '{n}s geleden' + minutesAgo: "{n}min {n2}s geleden" + hoursAgo: "{n}u {n2}min geleden" + future: Toekomst + justNow: Zojuist + daysAgo: "{n}d {n2}u geleden" + weeksAgo: "{n}w {n2}d geleden" + yearsAgo: "{n}j {n2}m geleden" + monthsAgo: "{n}m {n2}w geleden" +_time: + second: Seconde(n) + minute: Minu(u)t(en) + hour: U(u)r(en) + day: Dag(en) +_2fa: + securityKeyNotSupported: Je browser ondersteunt geen beveiligingssleutels. + removeKeyConfirm: Wil je de sleutel {naam} echt verwijderen? +_permissions: + "write:following": Andere accounts volgen en ontvolgen + "read:following": Informatie inzien over wie je volgt + "read:messaging": Je chats inzien + "write:messaging": Chatberichten schrijven of verwijderen + "read:mutes": Je gedempte gebruikers zien + "write:notes": Posts opstellen of verwijderen + "read:notifications": Notificaties inzien + "write:notifications": Notificaties beheren +_postForm: + replyPlaceholder: Antwoord op deze post… + quotePlaceholder: Deze post citeren… + channelPlaceholder: Naar een kanaal posten… + _placeholders: + a: Wat ben je aan het doen? + b: Wat gebeurt er om je heen? + c: Waar denk je aan? + d: Wat heb je te zeggen? +_instanceCharts: + usersTotal: Totaal aantal gebruikers + notesTotal: Totaal aantal posts +_dialog: + charactersExceeded: 'Maximaal aantal karakters overschreden! Hudig aantal: {current}/Limiet: + {max}' + charactersBelow: 'Niet genoeg karakters! Huidig aantal: {current}/Limiet: {min}' +searchNotLoggedIn_1: Je moet ingelogd zijn om op tekst te kunnen zoeken. +searchNotLoggedIn_2: Je kunt wel zoeken op basis van hashtags, en je kunt naar gebruikers + zoeken. +_emailUnavailable: + used: Dit emailadres wordt al gebruikt + format: Het formaat van dit emailadres is ongeldig + disposable: Wegwerp-emailadressen mogen niet worden gebruikt + mx: Deze mailserver is ongeldig + smtp: Deze mailserver reageert niet +joinedGroups: Bijgetreden groepen +transfer: Overdragen +retype: Opnieuw invoeren +quoteQuestion: Als citaat bijvoegen? +expandOnNoteClick: Post openen na klikken +objectStorageBucket: Bucket +objectStorageBucketDesc: Geef de naam van de bucket op die je provider gebruikt. +objectStoragePrefix: Prefix +objectStoragePrefixDesc: Bestanden worden opgeslagen in mappen met deze prefix. +objectStorageEndpoint: Endpoint +objectStorageEndpointDesc: Laat dit leeg als je AWS S3 gebruikt, of geef het endpoint + op als '' of :', afhankelijk van de dienst die je gebruikt. +objectStorageRegion: Regio +expandOnNoteClickDesc: Indien uitgeschakeld, kun je nog steeds berichten openen in + het rechtermuisknopmenu of door op de tijd te klikken. +divider: Scheidingslijn +addItem: Item toevoegen +preferencesBackups: Voorkeursbackups +deck: Deck +undeck: Deck verlaten +useBlurEffectForModal: Vervagingseffect bij modals gebruiken +useFullReactionPicker: Reactiekiezer op volledige grootte gebruiken +display: Weergavetype +copy: Kopiëren +metrics: Statistieken +overview: Overzicht +logs: Logboeken +delayed: Vertraagd +channel: Kanalen +useGlobalSettingDesc: Indien ingeschakeld, worden de notificatieinstellingen van je + account gebruikt. Indien uitgeschakeld, kan je invidiuele instellingen maken. +other: Overig +regenerateLoginToken: Inlogtoken opnieuw genereren +regenerateLoginTokenDescription: Maakt een nieuwe token aan die intern wordt gebruikt + tijdens het inloggen. Normaal gesproken is deze handeling niet nodig. Als deze opnieuw + wordt gemaakt zullen alle apparaten worden uitgelogd. +setMultipleBySeparatingWithSpace: Meerdere instellingen met spaties scheiden. +fileIdOrUrl: Bestands-ID of URL +behavior: Gedrag +sample: Voorbeeld +abuseReports: Meldingen +reportAbuse: Melden +reportAbuseOf: Melding {name} +abuseReported: Je rapport is verstuurd. Hartelijk dank. +reporter: Melder +reporteeOrigin: Oorsprong van melding +reporterOrigin: Oorsprong van melder +forwardReportIsAnonymous: In plaats van jouw account wordt een anoniem systeemaccount + als melder doorgegeven aan de betrokken server. +send: Versturen +abuseMarkAsResolved: Rapport als opgelost markeren +openInNewTab: In nieuw tabblad openen +openInSideView: In zijweergave tonen +editTheseSettingsMayBreakAccount: Deze instellingen wijzigen kan je account beschadigen. +instanceTicker: Serverinformatie van posts +waitingFor: Wacht op {x} +random: Willekeurig +system: Systeem +switchUi: Layout wisselen +desktop: Bureaublad +clip: Clip +createNew: Maak nieuw +optional: Optioneel +createNewClip: Nieuwe clip maken +public: Openbaar +i18nInfo: Iceshrimp wordt door vrijwilligers naar verschillende talen vertaald. Je + kunt helpen op {link}. +manageAccessTokens: Accesstokens beheren +accountInfo: Accountinfo +notesCount: Aantal posts +repliesCount: Aantal verstuurde reacties +renotesCount: Aantal verstuurde boosts +repliedCount: Aantal ontvangen reacties +renotedCount: Aantal ontvangen boosts +followingCount: Aantal gevolgde accounts +followersCount: Aantal volgers +sentReactionsCount: Aantal verstuurde reacties +pollVotedCount: Ontvangen aantal stemmen op peilingen +pollVotesCount: Verstuurd aantal stemmen op peilingen +yes: Ja +no: Nee +driveFilesCount: Aantal Drive-bestanden +driveUsage: Drive-ruimteverbruik +noCrawle: Crawler-indexering weigeren +loadRawImages: Orginele afbeeldingen in plaats van voorbeeldafbeeldingen laden +disableShowingAnimatedImages: Geen geanimeerde afbeeldingen afspelen +verificationEmailSent: Een bevestigingsmail is verstuurd. Volg de link daar om verificatie + te voltooien. +emailVerified: Email is bevestigd +noteFavoritesCount: Aantal favoriete posts +pageLikesCount: Aantal gelikete Pages +pageLikedCount: Aantal ontvangen Page-likes +contact: Contact +useSystemFont: Gebruik het standaardlettertype van het systeem +clips: Clips +clipsDesc: Clips zijn een soort deelbare bladwijzers op categorie. Je kan clips delen + via het menu van individuele posts. +experimentalFeatures: Experiementele functies +developer: Ontwikkelaar +makeExplorable: Maak accout in "Verkennen" zichtbaar +updatedAt: Vernieuwd op +registry: Register +usageAmount: Gebruik +inUse: Gebruikt +typingUsers: '{gebruikers} is/zijn aan het typen' +showingPastTimeline: Er wordt een oude timeline getoond +unlikeConfirm: Wil je echt je like verwijderen? +userPagePinTip: Je kan posts hier laten zien door "Aan profiel vastmaken" te selecteren + vanuit het menu van een losse post. +botProtection: Botbescherming +translatedFrom: Uit {x} vertaald +pubSub: Pub/Sub-accounts +lastCommunication: Laatste communicatie +resolved: Opgelost +unresolved: Nog niet opgelost +breakFollow: Volger verwijderen +breakFollowConfirm: Weet je zeker dat je deze volger wilt verwijderen? +itsOn: Ingeschakeld +itsOff: Uitgeschakeld +emailRequiredForSignup: Emailadres vereisen bij registratie +filter: Filter +controlPanel: Controlepaneel +manageAccounts: Accounts beheren +makeReactionsPublic: Reactiegeschiedenis publiek maken +makeReactionsPublicDescription: Dit zal de lijst van je eerdere reacties publiek zichtbaar + maken. +classic: Gecentreerd +ffVisibility: Zichtbaarheid volgend/volgers +ffVisibilityDescription: Je kan instellen wie kan zien wie je volgt en wie je volgers + zijn. +continueThread: Thread voortzetten +aiChanMode: AI-modus +deleteAccountConfirm: Dit zal je account onherstelbaar verwijderen. Verdergaan? +incorrectPassword: Wachtwoord fout. +voteConfirm: Stem voor "{choice}" bevestigen? +leaveGroup: Groep verlaten +leaveGroupConfirm: Weet je zeker dat je "{name}" wilt verwijderen? +clickToFinishEmailVerification: Klik op {{ok}} om emailverificatie te voltooien. +alt: ALT +useDrawerReactionPickerForMobile: Op mobiele apparaten uitklapbare reactiekiezer tonen diff --git a/locales/no-NO.yml b/locales/no-NO.yml new file mode 100644 index 0000000..22e955b --- /dev/null +++ b/locales/no-NO.yml @@ -0,0 +1,83 @@ +_lang_: "Norsk Bokmål" +search: Søk +monthAndDay: '{day}/{month}' +fetchingAsApObject: Henter fra fediverset +ok: OK +gotIt: Jeg forstår! +profile: Profil +timeline: Tidslinje +save: Lagre +addToList: Legg til liste +searchPlaceholder: Søk Iceshrimp +username: Brukernavn +password: Passord +notifications: Meldinger +forgotPassword: Glemt passord +cancel: Avbryt +noNotes: Ingen poster +instance: Server +settings: Innstillinger +noAccountDescription: Denne brukeren har ikke fylt ut bio'en sin ennå. +login: Logg inn +loggingIn: Logger inn +signup: Oppretter bruker +uploading: Laster opp.. +enterUsername: Skriv inn brukernavn +noNotifications: Ingen meldinger +users: Brukere +addUser: Legg til en bruker +favorite: Legg til i bokmerker +cantFavorite: Kunne ikke legges til i bokmerker. +pin: Fest til profilen +copyContent: Kopier innhold +deleteAndEdit: Slett og rediger +sendMessage: Send en melding +copyUsername: Kopier brukernavn +reply: Svar +loadMore: Last mer +showLess: Lukk +receiveFollowRequest: Følgeforespørsel mottatt +directNotes: Direktemelding +importAndExport: Importer/eksporter data +importRequested: Du har bedt om en importering. Dette vil ta litt tid. +lists: Lister +listsDesc: Lister lar deg lage tidslinjer med utvalgte brukere. De kan hentes frem + fra tidslinje-siden. +deleted: Slettet +editNote: Rediger notat +followsYou: Følger deg +createList: Lag liste +newer: nyere +older: eldre +download: Last ned +unfollowConfirm: Er du sikker på at du ikke lenger vil følge {name}? +noLists: Du har ingen lister +following: Følger +files: Filer +note: Post +notes: Poster +followers: Følgere +otherSettings: Andre innstillinger +addInstance: Legg til en server +alreadyFavorited: Allerede lagt til i bokmerker. +delete: Slett +openInWindow: Åpne i vindu +basicSettings: Grunnleggende innstillinger +headlineIceshrimp: En desentralisert sosialt media-plattform, basert på åpen kildekode, + som alltid vil være gratis! 🚀 +introIceshrimp: Velkommen! Iceshrimp er en desentralisert sosialt media-plattform, basert + på åpen kildekode, som alltid vil være gratis! 🚀 +exportRequested: Du har bedt om en eksportering. Dette vil ta litt tid. Den vil bli + lagt til på disken din når den er ferdig. +noThankYou: Nei takk +favorites: Bokmerker +unfavorite: Fjern fra bokmerker +favorited: Lagt til i bokmerker. +copyLink: Kopier lenke +searchUser: Søk etter en bruker +jumpToPrevious: Gå til foregående +showMore: Vis mer +followRequestAccepted: Følgeforespørsel godtatt +import: Importer +export: Eksporter +logout: Logger ut diff --git a/locales/pl-PL.yml b/locales/pl-PL.yml new file mode 100644 index 0000000..47bece6 --- /dev/null +++ b/locales/pl-PL.yml @@ -0,0 +1,2144 @@ +_lang_: "Polski" +headlineIceshrimp: "Otwartoźródłowa, zdecentralizowana sieć społecznościowa, która + zawsze będzie darmowa! 🚀" +introIceshrimp: "Hej! Iceshrimp to otwartoźródłowa oraz zdecentralizowana sieć społecznościowa, + która zawsze będzie darmowa! 🚀" +monthAndDay: "{month}-{day}" +search: "Szukaj" +notifications: "Powiadomienia" +username: "Nazwa użytkownika" +password: "Hasło" +forgotPassword: "Nie pamiętam hasła" +fetchingAsApObject: "Pobieranie z Fediwersum" +ok: "OK" +gotIt: "Rozumiem!" +cancel: "Anuluj" +enterUsername: "Wprowadź nazwę użytkownika" +renotedBy: "Podbito przez {user}" +noNotes: "Brak wpisów" +noNotifications: "Brak powiadomień" +instance: "Serwer" +settings: "Ustawienia" +basicSettings: "Podstawowe ustawienia" +otherSettings: "Pozostałe ustawienia" +openInWindow: "Otwórz w oknie" +profile: "Profil" +timeline: "Oś czasu" +noAccountDescription: "Ten użytkownik nie napisał jeszcze swojego opisu." +login: "Zaloguj się" +loggingIn: "Logowanie" +logout: "Wyloguj się" +signup: "Zarejestruj się" +uploading: "Wysyłanie..." +save: "Zapisz" +users: "Użytkownicy" +addUser: "Dodaj użytkownika" +favorite: "Dodaj do ulubionych" +favorites: "Zakładki" +unfavorite: "Usuń zakładkę" +favorited: "Dodano do zakładek." +alreadyFavorited: "Już jest w zakładkach." +cantFavorite: "Nie można dodać do zakładek." +pin: "Przypnij do profilu" +unpin: "Odepnij z profilu" +copyContent: "Skopiuj zawartość" +copyLink: "Skopiuj odnośnik" +delete: "Usuń" +deleteAndEdit: "Usuń i edytuj" +deleteAndEditConfirm: "Czy na pewno chcesz usunąć ten wpis i zedytować go? Utracisz + wszystkie reakcje, podbicia i odpowiedzi do tego wpisu." +addToList: "Dodaj do listy" +sendMessage: "Wyślij wiadomość" +copyUsername: "Kopiuj nazwę użytkownika" +searchUser: "Wyszukiwanie użytkowników" +reply: "Odpowiedz" +loadMore: "Załaduj więcej" +showMore: "Pokaż więcej" +showLess: "Zamknij" +youGotNewFollower: "Zaobserwował* Cię" +receiveFollowRequest: "Otrzymano prośbę o możliwość obserwacji" +followRequestAccepted: "Zaakceptowano prośbę o możliwość obserwacji" +mention: "Wspomnij" +mentions: "Wspomnienia" +directNotes: "Bezpośrednie wiadomości" +importAndExport: "Import i eksport danych" +import: "Importuj" +export: "Eksportuj" +files: "Pliki" +download: "Pobierz" +driveFileDeleteConfirm: "Czy chcesz usunąć plik \"{name}\"? Wszystkie wpisy zawierające + ten plik również zostaną usunięte." +unfollowConfirm: "Czy na pewno chcesz przestać obserwować {name}?" +exportRequested: "Zażądałeś eksportu. Może to zająć chwilę. Po zakończeniu eksportu + zostanie on dodany do Twojego dysku." +importRequested: "Zażądano importu. Może to zająć chwilę." +lists: "Listy" +noLists: "Nie masz żadnych list" +note: "Utwórz wpis" +notes: "Wpisy" +following: "Obserwowani" +followers: "Obserwujący" +followsYou: "Obserwuje Cię" +createList: "Utwórz listę" +manageLists: "Zarządzaj listami" +error: "Błąd" +somethingHappened: "Coś poszło nie tak" +retry: "Spróbuj ponownie" +pageLoadError: "Nie udało się załadować strony." +pageLoadErrorDescription: "Zwykle jest to spowodowane problemem z siecią lub cache + przeglądarki. Spróbuj wyczyścić cache, albo zaczekaj chwilę i odśwież." +serverIsDead: "Serwer nie odpowiada. Zaczekaj chwilę i spróbuj ponownie." +youShouldUpgradeClient: "Aby zobaczyć tą stronę, odśwież ją, by zaaktualizować klienta." +enterListName: "Wpisz nazwę listy" +privacy: "Prywatność" +makeFollowManuallyApprove: "Prośby o możliwość obserwacji wymagają zatwierdzenia" +defaultNoteVisibility: "Domyślna widoczność" +follow: "Obserwuj" +followRequest: "Poproś o możliwość obserwacji" +followRequests: "Prośby o możliwość obserwacji" +unfollow: "Przestań obserwować" +followRequestPending: "Oczekująca prośba o możliwość obserwacji" +enterEmoji: "Wprowadź emoji" +renote: "Podbij" +unrenote: "Cofnij podbicie" +renoted: "Podbito." +cantRenote: "Ten wpis nie może zostać podbity." +cantReRenote: "Podbicie nie może zostać podbite." +quote: "Cytuj" +pinnedNote: "Przypięty wpis" +pinned: "Przypnij do profilu" +you: "Ty" +clickToShow: "Kliknij, aby wyświetlić" +sensitive: "Potencjalnie nieodpowiednie" +add: "Dodaj" +reaction: "Reakcja" +reactionSetting: "Reakcje do pokazania w wyborniku reakcji" +reactionSettingDescription2: "Przeciągnij aby zmienić kolejność, naciśnij aby usunąć, + naciśnij „+” aby dodać." +rememberNoteVisibility: "Zapamiętuj ustawienia widoczności wpisu" +attachCancel: "Usuń załącznik" +markAsSensitive: "Oznacz jako nieodpowiednie" +unmarkAsSensitive: "Cofnij oznaczenie jako nieodpowiednie" +enterFileName: "Wprowadź nazwę pliku" +mute: "Wycisz" +unmute: "Cofnij wyciszenie" +block: "Zablokuj" +unblock: "Odblokuj" +suspend: "Zawieś" +unsuspend: "Cofnij zawieszenie" +blockConfirm: "Czy na pewno chcesz zablokować to konto?" +unblockConfirm: "Czy na pewno chcesz odblokować to konto?" +suspendConfirm: "Czy na pewno chcesz zawiesić to konto?" +unsuspendConfirm: "Czy na pewno chcesz cofnąć zawieszenie tego konta?" +selectList: "Wybierz listę" +selectAntenna: "Wybierz antenę" +selectWidget: "Wybierz widżet" +editWidgets: "Edytuj widżety" +editWidgetsExit: "Gotowe" +customEmojis: "Niestandardowe emoji" +emoji: "Emoji" +emojis: "Emoji" +emojiName: "Nazwa emoji" +emojiUrl: "Adres URL emoji" +addEmoji: "Dodaj emoji" +settingGuide: "Proponowana konfiguracja" +cacheRemoteFiles: "Przechowuj zdalne pliki w pamięci podręcznej" +cacheRemoteFilesDescription: "Gdy ta opcja jest wyłączona, zdalne pliki są ładowane + bezpośrednio ze zdalnego serwera. Wyłączenie tej opcji zmniejszy użycie powierzchni + dyskowej, ale zwiększy transfer, ponieważ miniaturki nie będą generowane." +flagAsBot: "Oznacz to konto jako bota 🤖" +flagAsBotDescription: "Jeżeli ten kanał jest kontrolowany przez jakiś program, ustaw + tę opcję. Jeżeli włączona, będzie działać jako flaga informująca innych programistów, + aby zapobiegać nieskończonej interakcji z różnymi botami i dostosowywać wewnętrzne + systemy Iceshrimp, traktując konto jako bota." +flagAsCat: "Czy jesteś kotem? 😺" +flagAsCatDescription: "Dostaniesz kocie uszka, oraz będziesz mówić jak kot!" +flagShowTimelineReplies: "Pokazuj odpowiedzi na osi czasu" +autoAcceptFollowed: "Automatycznie przyjmuj prośby o możliwość obserwacji od użytkowników, + których obserwujesz" +addAccount: "Dodaj konto" +loginFailed: "Nie udało się zalogować" +showOnRemote: "Zobacz na zdalnym serwerze" +general: "Ogólne" +wallpaper: "Tapeta" +setWallpaper: "Ustaw tapetę" +removeWallpaper: "Usuń tapetę" +searchWith: "Szukaj: {q}" +youHaveNoLists: "Nie masz żadnej listy" +followConfirm: "Czy na pewno chcesz zaobserwować {name}?" +proxyAccount: "Konto proxy" +host: "Host" +selectUser: "Wybierz użytkownika" +recipient: "Odbiorca(-y)" +annotation: "Komentarze" +federation: "Federacja" +instances: "Serwery" +registeredAt: "Zarejestrowano" +latestRequestSentAt: "Ostatnie żądanie wysłano o" +latestRequestReceivedAt: "Ostatnie żądanie otrzymano o" +latestStatus: "Najnowszy status" +storageUsage: "Użycie pamięci" +charts: "Wykresy" +perHour: "co godzinę" +perDay: "co dzień" +stopActivityDelivery: "Przestań przesyłać aktywności" +blockThisInstance: "Zablokuj ten serwer" +operations: "Działania" +software: "Oprogramowanie" +version: "Wersja" +metadata: "Metadane" +monitor: "Monitor" +jobQueue: "Kolejka zadań" +cpuAndMemory: "CPU i pamięć" +network: "Sieć" +disk: "Dysk" +instanceInfo: "Informacje o serwerze" +statistics: "Statystyki" +clearQueue: "Wyczyść kolejkę" +clearQueueConfirmTitle: "Czy na pewno chcesz wyczyścić kolejkę?" +clearQueueConfirmText: "Wszystkie niewysłane wpisy z kolejki nie zostaną wysłane. + Zwykle to nie jest konieczne." +clearCachedFiles: "Wyczyść pamięć podręczną" +clearCachedFilesConfirm: "Czy na pewno chcesz usunąć wszystkie zdalne pliki z pamięci + podręcznej?" +blockedInstances: "Zablokowane serwery" +blockedInstancesDescription: "Wypisz nazwy hostów serwerów, które chcesz zablokować. + Wymienione serwery nie będą mogły dłużej komunikować się z tym serwerem." +muteAndBlock: "Wyciszenia i blokady" +mutedUsers: "Wyciszeni użytkownicy" +blockedUsers: "Zablokowani użytkownicy" +noUsers: "Brak użytkowników" +editProfile: "Edytuj profil" +noteDeleteConfirm: "Czy na pewno chcesz usunąć ten wpis?" +pinLimitExceeded: "Nie możesz przypiąć więcej wpisów" +intro: "Zakończono instalację Iceshrimp! Utwórz konto administratora." +done: "Gotowe" +processing: "Przetwarzanie..." +preview: "Podgląd" +default: "Domyślne" +defaultValueIs: "Domyślne: {value}" +noCustomEmojis: "Brak emoji" +noJobs: "Brak zadań" +federating: "Federowanie" +blocked: "Zablokowano" +suspended: "Zawieszono" +all: "Wszystkie" +subscribing: "Subskrybowanie" +publishing: "Publikowanie" +notResponding: "Nie odpowiada" +instanceFollowing: "Obserwowani na serwerze" +instanceFollowers: "Obserwujący na serwerze" +instanceUsers: "Użytkownicy tego serwera" +changePassword: "Zmień hasło" +security: "Bezpieczeństwo" +retypedNotMatch: "Wejście nie zgadza się." +currentPassword: "Obecne hasło" +newPassword: "Nowe hasło" +newPasswordRetype: "Powtórz nowe hasło" +attachFile: "Załącz pliki" +more: "Więcej" +featured: "Wyróżnione" +usernameOrUserId: "Nazwa lub id użytkownika" +noSuchUser: "Nie znaleziono użytkownika" +lookup: "Zapytania" +announcements: "Ogłoszenia" +imageUrl: "Adres URL obrazka" +remove: "Usuń" +removed: "Pomyślnie usunięto" +removeAreYouSure: "Czy na pewno chcesz usunąć „{x}”?" +deleteAreYouSure: "Czy na pewno chcesz usunąć „{x}”?" +resetAreYouSure: "Czy na pewno chcesz zresetować?" +saved: "Zapisano" +messaging: "Wiadomości" +upload: "Wyślij" +keepOriginalUploading: "Zachowaj oryginalny obraz" +fromDrive: "Z dysku" +fromUrl: "Z adresu URL" +uploadFromUrl: "Wyślij z adresu URL" +uploadFromUrlDescription: "Adres URL pliku, który chcesz wysłać" +uploadFromUrlRequested: "Zażądano wysłania" +uploadFromUrlMayTakeTime: "Wysyłanie może chwilę potrwać." +explore: "Eksploruj" +messageRead: "Przeczytano" +noMoreHistory: "Nie ma dalszej historii" +startMessaging: "Rozpocznij czat" +nUsersRead: "przeczytano przez {n}" +agreeTo: "Wyrażam zgodę na {0}" +tos: "Regulamin" +start: "Rozpocznij" +home: "Strona główna" +remoteUserCaution: "Te informacje mogą nie być aktualne, ponieważ użytkownik pochodzi + ze zdalnej instancji." +activity: "Aktywność" +images: "Zdjęcia" +birthday: "Data urodzenia" +yearsOld: "{age} lat" +registeredDate: "Zarejestrowano" +location: "Lokalizacja" +theme: "Motywy" +themeForLightMode: "Motyw używany w trybie jasnym" +themeForDarkMode: "Motyw używany w trybie ciemnym" +light: "Jasny" +dark: "Ciemny" +lightThemes: "Jasny motyw" +darkThemes: "Ciemny motyw" +syncDeviceDarkMode: "Synchronizuj ciemny motyw z ustawieniami urządzenia" +drive: "Dysk" +fileName: "Nazwa pliku" +selectFile: "Wybierz plik" +selectFiles: "Wybierz pliki" +selectFolder: "Wybierz folder" +selectFolders: "Wybierz foldery" +renameFile: "Zmień nazwę pliku" +folderName: "Nazwa katalogu" +createFolder: "Utwórz katalog" +renameFolder: "Zmień nazwę katalogu" +deleteFolder: "Usuń ten katalog" +addFile: "Dodaj plik" +emptyDrive: "Dysk jest pusty" +emptyFolder: "Ten katalog jest pusty" +unableToDelete: "Nie można usunąć" +inputNewFileName: "Wprowadź nową nazwę pliku" +inputNewDescription: "Proszę wpisać nowy napis" +inputNewFolderName: "Wprowadź nową nazwę katalogu" +circularReferenceFolder: "Katalog docelowy jest podkatalogiem katalogu, który chcesz + przenieść." +hasChildFilesOrFolders: "Ponieważ ten katalog nie jest pusty, nie może być usunięty." +copyUrl: "Skopiuj adres URL" +rename: "Zmień nazwę" +avatar: "Awatar" +banner: "Baner" +nsfw: "Potencjalnie nieodpowiednie" +whenServerDisconnected: "Po utracie połączenia z serwerem" +disconnectedFromServer: "Utracono połączenie z serwerem" +reload: "Odśwież" +doNothing: "Ignoruj" +reloadConfirm: "Czy chcesz odświeżyć oś czasu?" +watch: "Śledź" +unwatch: "Przestań śledzić" +accept: "Akceptuj" +reject: "Odrzuć" +normal: "Normalny" +instanceName: "Nazwa serwera" +instanceDescription: "Opis serwera" +maintainerName: "Administrator" +maintainerEmail: "E-mail administratora" +tosUrl: "Adres URL regulaminu" +thisYear: "Rok" +thisMonth: "Miesiąc" +today: "Dziś" +dayX: "{day}" +monthX: "{month}" +yearX: "{year}" +pages: "Strony" +integration: "Integracje" +connectService: "Połącz" +disconnectService: "Rozłącz" +enableLocalTimeline: "Włącz lokalną oś czasu" +enableGlobalTimeline: "Włącz globalną oś czasu" +disablingTimelinesInfo: "Administratorzy i moderatorzy będą zawsze mieć dostęp do + wszystkich osi czasu, nawet gdy są one wyłączone." +registration: "Zarejestruj się" +enableRegistration: "Włącz rejestrację nowych użytkowników" +invite: "Zaproś" +driveCapacityPerLocalAccount: "Powierzchnia dyskowa na lokalnego użytkownika" +driveCapacityPerRemoteAccount: "Powierzchnia dyskowa na zdalnego użytkownika" +inMb: "W megabajtach" +iconUrl: "Adres URL ikony" +bannerUrl: "Adres URL banera" +backgroundImageUrl: "Adres URL tła" +basicInfo: "Podstawowe informacje" +pinnedUsers: "Przypięty użytkownik" +pinnedUsersDescription: "Wypisz po jednej nazwie użytkownika w wierszu. Podani użytkownicy + zostaną przypięci pod kartą „Eksploruj”." +pinnedPages: "Przypięte strony" +pinnedPagesDescription: "Wprowadź ścieżki stron, które chcesz przypiąć do górnej strony + tego serwera, oddzielając je znakami końca wiersza." +pinnedClipId: "ID przypiętego klipu" +pinnedNotes: "Przypięty wpis" +hcaptcha: "hCaptcha" +enableHcaptcha: "Włącz hCaptcha" +hcaptchaSiteKey: "Klucz strony" +hcaptchaSecretKey: "Tajny klucz" +recaptcha: "reCAPTCHA" +enableRecaptcha: "Włącz reCAPTCHA" +recaptchaSiteKey: "Klucz strony" +recaptchaSecretKey: "Tajny klucz" +avoidMultiCaptchaConfirm: "Używanie wielu Captchy może spowodować zakłócenia. Czy + chcesz wyłączyć inną Captchę? Możesz zostawić wiele jednocześnie, klikając Anuluj." +antennas: "Anteny" +manageAntennas: "Zarządzaj antenami" +name: "Nazwa" +antennaSource: "Źródło anteny" +antennaKeywords: "Słowa kluczowe do obserwacji" +antennaExcludeKeywords: "Wykluczone słowa kluczowe" +antennaKeywordsDescription: "Oddziel spacjami dla warunku AND, albo wymuś koniec linii + dla warunku OR." +notifyAntenna: "Powiadamiaj o nowych wpisach" +withFileAntenna: "Filtruj tylko wpisy z załączonym plikiem" +enableServiceworker: "Włącz powiadomienia push dla twojej przeglądarki" +antennaUsersDescription: "Wypisz po jednej nazwie użytkownika w linii" +caseSensitive: "Wielkość liter ma znaczenie" +withReplies: "Uwzględnij odpowiedzi" +connectedTo: "Następujące konta są połączone" +notesAndReplies: "Wpisy i odpowiedzi" +withFiles: "Z załącznikami" +silence: "Wycisz" +silenceConfirm: "Czy na pewno chcesz wyciszyć tego użytkownika?" +unsilence: "Cofnij wyciszenie" +unsilenceConfirm: "Czy na pewno chcesz cofnąć wyciszenie tego użytkownika?" +popularUsers: "Popularni użytkownicy" +recentlyUpdatedUsers: "Ostatnio aktywni użytkownicy" +recentlyRegisteredUsers: "Ostatnio zarejestrowani użytkownicy" +recentlyDiscoveredUsers: "Ostatnio odkryci użytkownicy" +exploreUsersCount: "Jest {count} użytkowników" +exploreFediverse: "Eksploruj Fediwersum" +popularTags: "Tagi na czasie" +userList: "Listy" +about: "Informacje" +aboutIceshrimp: "O Iceshrimp" +administrator: "Admin" +token: "Token" +twoStepAuthentication: "Uwierzytelnianie dwuskładnikowe" +moderator: "Moderator" +moderation: "Moderacja" +nUsersMentioned: "{n} wspomnianych użytkowników" +securityKey: "Klucz bezpieczeństwa" +securityKeyName: "Nazwa klucza" +registerSecurityKey: "Zarejestruj klucz bezpieczeństwa" +lastUsed: "Ostatnio używane" +unregister: "Cofnij rejestrację" +passwordLessLogin: "Skonfiguruj logowanie bez użycia hasła" +resetPassword: "Zresetuj hasło" +newPasswordIs: "Nowe hasło to „{password}”" +reduceUiAnimation: "Ogranicz animacje w UI" +share: "Udostępnij" +notFound: "Nie znaleziono" +notFoundDescription: "Nie ma strony odpowiadającej określonemu adresowi URL." +uploadFolder: "Domyślne położenie wysłanych" +cacheClear: "Wyczyść pamięć podręczną" +markAsReadAllNotifications: "Oznacz wszystkie powiadomienia jako przeczytane" +markAsReadAllUnreadNotes: "Oznacz wszystkie wpisy jako przeczytane" +markAsReadAllTalkMessages: "Oznacz wszystkie wiadomości jako przeczytane" +help: "Pomoc" +inputMessageHere: "Wprowadź wiadomość tutaj" +close: "Zamknij" +group: "Grupa" +groups: "Grupy" +createGroup: "Utwórz grupę" +ownedGroups: "Posiadane grupy" +joinedGroups: "Członkostwa w grupach" +invites: "Zaproś" +groupName: "Nazwa grupy" +members: "Członkowie" +transfer: "Transfer" +messagingWithUser: "Rozmowy z innym użytkownikiem" +messagingWithGroup: "Rozmowy wewnątrz grupy" +title: "Tytuł" +text: "Tekst" +enable: "Włącz" +next: "Dalej" +retype: "Wprowadź ponownie" +noteOf: "Wpisy {user}" +inviteToGroup: "Zaproś do grupy" +quoteAttached: "Zacytowano" +quoteQuestion: "Czy na pewno chcesz umieścić cytat?" +noMessagesYet: "Nie napisano jeszcze wiadomości" +newMessageExists: "Masz nową wiadomość" +onlyOneFileCanBeAttached: "Możesz załączyć tylko jeden plik do wiadomości" +signinRequired: "Proszę się zalogować" +invitations: "Zaproś" +invitationCode: "Kod zaproszenia" +checking: "Sprawdzam..." +available: "Dostępna" +unavailable: "Niedostępna" +usernameInvalidFormat: "Nazwa użytkownika może zawierać litery, cyfry i podkreślniki." +tooShort: "Zbyt krótka" +tooLong: "Zbyt długa" +weakPassword: "Słabe hasło" +normalPassword: "Dobre hasło" +strongPassword: "Silne hasło" +passwordMatched: "Pasuje" +passwordNotMatched: "Hasła nie pasują do siebie" +signinWith: "Zaloguj się z {x}" +signinFailed: "Nie udało się zalogować. Wprowadzona nazwa użytkownika lub hasło są + nieprawidłowe." +tapSecurityKey: "Wybierz swój klucz bezpieczeństwa" +or: "Lub" +language: "Język" +uiLanguage: "Język wyświetlania UI" +groupInvited: "Zaproszony(-a) do grupy" +aboutX: "O {x}" +useOsNativeEmojis: "Używaj natywnych Emoji systemu" +disableDrawer: "Nie używaj menu w stylu szuflady" +youHaveNoGroups: "Nie masz żadnych grup" +joinOrCreateGroup: "Uzyskaj zaproszenie do dołączenia do grupy lub utwórz własną grupę." +noHistory: "Brak historii" +signinHistory: "Historia logowania" +disableAnimatedMfm: "Wyłącz MFM z animacją" +doing: "Przetwarzanie..." +category: "Kategoria" +tags: "Tagi" +docSource: "Źródło tego dokumentu" +createAccount: "Utwórz konto" +existingAccount: "Istniejące konto" +regenerate: "Wygeneruj ponownie" +fontSize: "Rozmiar czcionki" +noFollowRequests: "Nie masz żadnych oczekujących próśb o możliwość obserwacji" +openImageInNewTab: "Otwórz obraz w nowej karcie" +dashboard: "Kokpit" +local: "Lokalne" +remote: "Zdalny" +total: "Łącznie" +weekOverWeekChanges: "Cotygodniowo" +dayOverDayChanges: "Codziennie" +appearance: "Wygląd" +clientSettings: "Ustawienia klienta" +accountSettings: "Ustawienia konta" +promotion: "Promowane" +promote: "Promuj" +numberOfDays: "Liczba dni" +hideThisNote: "Ukryj ten wpis" +showFeaturedNotesInTimeline: "Pokazuj wyróżnione wpisy w osi czasu" +objectStorage: "Pamięć obiektowa" +useObjectStorage: "Używaj pamięci obiektowej" +objectStorageBaseUrl: "Podstawowy URL" +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "Podaj nazwę „wiadra” używaną przez konfigurowaną usługę." +objectStoragePrefix: "Prefiks" +objectStoragePrefixDesc: "Pliki będą przechowywane w katalogu z tym prefiksem." +objectStorageEndpoint: "Punkt końcowy" +objectStorageEndpointDesc: "Pozostaw puste jeżeli używasz AWS S3, w innym wypadku + określ punkt końcowy jako '' lub ':' zgodnie z instrukcjami usługi, + której używasz." +objectStorageRegion: "Region" +objectStorageRegionDesc: "Określ region, np. 'xx-east-1'. Jeżeli usługa której używasz + nie zawiera rozróżnienia regionów, pozostaw to pustym lub wprowadź 'us-east-1'." +objectStorageUseSSL: "Użyj SSL" +objectStorageUseSSLDesc: "Wyłącz, jeżeli nie zamierzasz używać HTTPS dla połączenia + z API" +objectStorageUseProxy: "Połącz przez proxy" +objectStorageUseProxyDesc: "Wyłącz, jeżeli nie zamierzasz używać proxy dla połączenia + z pamięcią blokową" +serverLogs: "Dziennik zdarzeń" +deleteAll: "Usuń wszystkie" +showFixedPostForm: "Wyświetlaj formularz tworzenia wpisu w górnej części osi czasu" +newNoteRecived: "Masz nowy wpis" +sounds: "Dźwięk" +listen: "Słuchaj" +none: "Brak" +showInPage: "Pokaż na stronie" +popout: "Popout" +volume: "Głośność" +masterVolume: "Głośność główna" +details: "Szczegóły" +chooseEmoji: "Wybierz emoji" +unableToProcess: "Nie udało się dokończyć działania" +recentUsed: "Ostatnio używane" +install: "Zainstaluj" +uninstall: "Odinstaluj" +installedApps: "Autoryzowane aplikacje" +nothing: "Nie ma tu niczego" +installedDate: "Autoryzowano" +lastUsedDate: "Ostatnie użycie" +state: "Stan" +sort: "Sortuj" +ascendingOrder: "Rosnąco" +descendingOrder: "Malejąco" +scratchpad: "Brudnopis" +scratchpadDescription: "Brudnopis to środowisko dla eksperymentów z AiScript. Możesz + pisać, wykonywać i sprawdzać wyniki interakcji skryptu z Iceshrimp." +output: "Wyjście" +script: "Skrypt" +disablePagesScript: "Wyłącz AiScript na Stronach" +updateRemoteUser: "Aktualizuj zdalne dane o użytkowniku" +deleteAllFiles: "Usuń wszystkie pliki" +deleteAllFilesConfirm: "Czy na pewno chcesz usunąć wszystkie pliki?" +removeAllFollowingDescription: "Wykonanie tego polecenia spowoduje usunięcie wszystkich + kont z {host}. Zrób to, jeśli serwer np. już nie istnieje." +userSuspended: "To konto zostało zawieszone." +userSilenced: "Ten użytkownik został wyciszony." +yourAccountSuspendedTitle: "To konto jest zawieszone" +yourAccountSuspendedDescription: "To konto zostało zawieszone z powodu złamania regulaminu + serwera lub innych podobnych. Skontaktuj się z administratorem, jeśli chciałbyś + poznać bardziej szczegółowy powód. Proszę nie zakładać nowego konta." +menu: "Menu" +divider: "Rozdzielacz" +addItem: "Dodaj element" +relays: "Przekaźniki" +addRelay: "Dodaj przekaźnik" +inboxUrl: "Adres URL skrzynki nadawczej" +addedRelays: "Dodane przekaźniki" +serviceworkerInfo: "Musi być włączone dla powiadomień push." +deletedNote: "Usunięty wpis" +invisibleNote: "Niewidzialny wpis" +enableInfiniteScroll: "Włącz nieskończone przewijanie" +visibility: "Widoczność" +poll: "Ankieta" +useCw: "Ukryj zawartość" +enablePlayer: "Otwórz odtwarzacz wideo" +disablePlayer: "Zamknij odtwarzacz wideo" +expandTweet: "Rozwiń tweet" +themeEditor: "Edytor motywu" +description: "Opis" +describeFile: "Dodaj podpis" +enterFileDescription: "Wprowadź napis" +author: "Autor" +leaveConfirm: "Są niezapisane zmiany. Czy chcesz je odrzucić?" +manage: "Zarządzanie" +plugins: "Wtyczki" +deck: "Tablica" +useBlurEffectForModal: "Używaj efektu rozmycia w modalach" +useFullReactionPicker: "Używaj pełnowymiarowego wybornika reakcji" +width: "Szerokość" +height: "Wysokość" +large: "Duże" +medium: "Średnie" +small: "Małe" +generateAccessToken: "Generuj token dostępu" +permission: "Uprawnienia" +enableAll: "Włącz wszystko" +disableAll: "Wyłącz wszystko" +tokenRequested: "Przydziel dostęp do konta" +pluginTokenRequestedDescription: "Ta wtyczka będzie mogła korzystać z ustawionych + tu uprawnień." +notificationType: "Rodzaj powiadomień" +edit: "Edytuj" +emailServer: "Serwer poczty e-mail" +enableEmail: "Włącz dostarczanie wiadomości e-mail" +emailConfigInfo: "Wykorzystywany do potwierdzenia adresu e-mail w trakcie rejestracji, + lub gdy zapomnisz hasła" +email: "Adres e-mail" +emailAddress: "Adres e-mail" +smtpConfig: "Konfiguracja serwera SMTP" +smtpHost: "Host" +smtpPort: "Port" +smtpUser: "Nazwa użytkownika" +smtpPass: "Hasło" +emptyToDisableSmtpAuth: "Pozostaw adres e-mail i hasło puste, aby wyłączyć weryfikację + SMTP" +smtpSecureInfo: "Wyłącz, jeżeli używasz STARTTLS" +testEmail: "Przetestuj dostarczanie wiadomości e-mail" +wordMute: "Wyciszenie słowa" +instanceMute: "Wyciszenie serwera" +userSaysSomething: "{name} powiedział* coś" +makeActive: "Aktywuj" +display: "Wyświetlanie" +copy: "Kopiuj" +metrics: "Pomiary" +overview: "Przegląd" +logs: "Dzienniki" +delayed: "Opóźnione" +database: "Baza danych" +channel: "Kanały" +create: "Utwórz" +notificationSetting: "Ustawienia powiadomień" +notificationSettingDesc: "Wybierz rodzaj powiadomień do wyświetlania." +useGlobalSetting: "Użyj globalnych ustawień" +useGlobalSettingDesc: "Jeżeli włączone, zostaną wykorzystane ustawienia powiadomień + Twojego konta. Jeżeli wyłączone, mogą zostać wykonane oddzielne konfiguracje." +other: "Inne" +regenerateLoginToken: "Generuj token logowania ponownie" +regenerateLoginTokenDescription: "Regeneruje token używany wewnętrznie podczas logowania. + Zazwyczaj nie jest to konieczne. Po regeneracji wszystkie urządzenia zostaną wylogowane." +setMultipleBySeparatingWithSpace: "Możesz ustawić wiele, oddzielając je spacjami." +fileIdOrUrl: "ID pliku albo URL" +behavior: "Zachowanie" +sample: "Przykład" +abuseReports: "Zgłoszenia" +reportAbuse: "Zgłoś" +reportAbuseOf: "Zgłoś {name}" +fillAbuseReportDescription: "Wypełnij szczegóły zgłoszenia. Jeżeli dotyczy ono określonego + wpisu, uwzględnij jego adres URL." +abuseReported: "Twoje zgłoszenie zostało wysłane. Dziękujemy." +reporteeOrigin: "Pochodzenie osoby zgłoszonej" +reporterOrigin: "Pochodzenie osoby zgłaszającej" +forwardReport: "Przekaż zgłoszenie do zdalnego serwera" +send: "Wyślij" +abuseMarkAsResolved: "Oznacz zgłoszenie jako rozwiązane" +openInNewTab: "Otwórz w nowej karcie" +openInSideView: "Otwórz w bocznym widoku" +defaultNavigationBehaviour: "Domyślne zachowanie nawigacji" +editTheseSettingsMayBreakAccount: "Edycja tych ustawień może uszkodzić Twoje konto." +instanceTicker: "Informacje o wpisach serwera" +waitingFor: "Oczekiwanie na {x}" +random: "Losowe" +system: "System" +switchUi: "Layout" +desktop: "Pulpit" +clip: "Klip" +createNew: "Utwórz nowy" +optional: "Nieobowiązkowe" +createNewClip: "Utwórz nowy klip" +unclip: "Odczep" +confirmToUnclipAlreadyClippedNote: "Ten wpis jest już częścią klipu \"{name}\". Czy + chcesz ją usunąć z tego klipu?" +public: "Publiczny" +i18nInfo: "Iceshrimp jest tłumaczone na wiele języków przez wolontariuszy. Możesz + pomóc na {link}." +manageAccessTokens: "Zarządzaj tokenami dostępu" +accountInfo: "Informacje o koncie" +notesCount: "Liczba wpisów" +repliesCount: "Liczba wysłanych odpowiedzi" +renotesCount: "Liczba wysłanych podbić" +repliedCount: "Liczba otrzymanych odpowiedzi" +renotedCount: "Liczba otrzymanych podbić" +followingCount: "Liczba obserwowanych kont" +followersCount: "Liczba obserwujących" +sentReactionsCount: "Liczba wysłanych reakcji" +receivedReactionsCount: "Liczba otrzymanych reakcji" +pollVotesCount: "Liczba wysłanych głosów w ankietach" +pollVotedCount: "Liczba otrzymanych głosów w ankietach" +yes: "Tak" +no: "Nie" +driveFilesCount: "Liczba plików na dysku" +driveUsage: "Użycie przestrzeni dyskowej" +noCrawle: "Odrzuć indeksowanie przez crawlery" +noCrawleDescription: "Proś wyszukiwarki internetowe, aby nie indeksowały Twojego profilu, + wpisów, stron itd." +lockedAccountInfo: "Dopóki nie ustawisz widoczności wpisu na \"Obserwujący\", twoje + wpisy będą mogli widzieć wszyscy, nawet jeśli ustawisz manualne zatwierdzanie obserwujących." +alwaysMarkSensitive: "Oznacz domyślnie jako nieodpowiednie" +loadRawImages: "Wyświetlaj zdjęcia w załącznikach w całości zamiast miniatur" +disableShowingAnimatedImages: "Nie odtwarzaj animowanych obrazów" +verificationEmailSent: "Wiadomość weryfikacyjna została wysłana. Odwiedź uwzględniony + odnośnik, aby ukończyć weryfikację." +notSet: "Nie ustawiono" +emailVerified: "Adres e-mail został potwierdzony" +noteFavoritesCount: "Liczba zakładek" +pageLikesCount: "Liczba otrzymanych polubień stron" +pageLikedCount: "Liczba polubionych stron" +contact: "Kontakt" +useSystemFont: "Używaj domyślnej czcionki systemu" +clips: "Klipy" +experimentalFeatures: "Eksperymentalne funkcje" +developer: "Programista" +makeExplorable: "Pokazuj konto na stronie „Eksploruj”" +makeExplorableDescription: "Jeżeli wyłączysz tę opcję, Twoje konto nie będzie wyświetlać + się w sekcji „Eksploruj”." +showGapBetweenNotesInTimeline: "Pokazuj odstęp między wpisami na osi czasu" +duplicate: "Duplikuj" +left: "Lewo" +center: "Wyśrodkuj" +wide: "Szerokie" +narrow: "Wąskie" +reloadToApplySetting: "To ustawienie zostanie zastosowane po odświeżeniu strony. Chcesz + odświeżyć?" +needReloadToApply: "To ustawienie zostanie zastosowane po odświeżeniu strony." +showTitlebar: "Pokazuj pasek tytułowy" +clearCache: "Wyczyść pamięć podręczną" +onlineUsersCount: "{n} osób jest online" +nUsers: "{n} użytkowników" +nNotes: "{n} wpisów" +sendErrorReports: "Wyślij raporty o błędach" +myTheme: "Mój motyw" +backgroundColor: "Tło" +accentColor: "Akcent" +textColor: "Tekst" +saveAs: "Zapisz jako…" +advanced: "Zaawansowane" +value: "Wartość" +createdAt: "Utworzono" +updatedAt: "Zaktualizowano" +saveConfirm: "Zapisać zmiany?" +deleteConfirm: "Na pewno usunąć?" +invalidValue: "Nieprawidłowa wartość." +registry: "Rejestr" +closeAccount: "Zamknij konto" +currentVersion: "Bieżąca wersja" +latestVersion: "Najnowsza wersja" +youAreRunningUpToDateClient: "Korzystasz z najnowszej wersji klienta." +newVersionOfClientAvailable: "Nowsza wersja klienta jest dostępna." +usageAmount: "Użycie" +capacity: "Pojemność" +inUse: "Użyto" +editCode: "Edytuj kod" +apply: "Zastosuj" +receiveAnnouncementFromInstance: "Otrzymuj powiadomienia e-mail z tego serwera" +emailNotification: "Powiadomienia e-mail" +publish: "Publikuj" +inChannelSearch: "Szukaj na kanale" +useReactionPickerForContextMenu: "Otwórz wybornik reakcji prawym kliknięciem" +typingUsers: "{users} pisze/ą" +jumpToSpecifiedDate: "Przejdź do określonej daty" +showingPastTimeline: "Obecnie wyświetla starą oś czasu" +clear: "Wróć" +markAllAsRead: "Oznacz wszystkie jako przeczytane" +goBack: "Wróć" +unlikeConfirm: "Na pewno chcesz usunąć polubienie?" +fullView: "Pełny widok" +quitFullView: "Opuść pełny widok" +addDescription: "Dodaj opis" +userPagePinTip: "Możesz wyświetlać wpisy w tym miejscu po wybraniu \"Przypnij do profilu\"\ + \ z menu pojedynczego wpisu." +notSpecifiedMentionWarning: "Ten wpis zawiera wzmianki o użytkownikach niezawartych + jako odbiorcy" +info: "Informacje" +userInfo: "Informacje o użykowniku" +unknown: "Nieznane" +onlineStatus: "Status online" +hideOnlineStatus: "Ukryj status online" +hideOnlineStatusDescription: "Ukrywanie statusu online ogranicza wygody niektórych + funkcji, takich jak wyszukiwanie." +online: "Online" +active: "Aktywny" +offline: "Offline" +notRecommended: "Nie zalecane" +botProtection: "Zabezpieczenie przed botami" +instanceBlocking: "Zarządzanie federacją" +selectAccount: "Wybierz konto" +switchAccount: "Przełącz konto" +enabled: "Właczono" +disabled: "Wyłączono" +quickAction: "Szybkie działania" +user: "Użytkownicy" +administration: "Zarządzanie" +accounts: "Konta" +switch: "Przełącz" +noMaintainerInformationWarning: "Informacje o administratorze nie są skonfigurowane." +noBotProtectionWarning: "Zabezpieczenie przed botami nie jest skonfigurowane." +configure: "Skonfiguruj" +postToGallery: "Opublikuj w galerii" +gallery: "Galeria" +recentPosts: "Ostatnie wpisy" +popularPosts: "Popularne wpisy" +shareWithNote: "Udostępnij z wpisem" +ads: "Reklamy" +expiration: "Ankieta kończy się" +memo: "Notatki" +priority: "Priorytet" +high: "Wysoki" +middle: "Średnie" +low: "Niski" +emailNotConfiguredWarning: "Nie podano adresu e-mail." +ratio: "Stosunek" +previewNoteText: "Pokaż podgląd" +customCss: "Własny CSS" +customCssWarn: "Używaj tego ustawienia tylko wtedy, gdy wiesz co ono robi. Nieprawidłowe + wpisy mogą spowodować, że klient przestanie działać poprawnie." +global: "Globalna" +squareAvatars: "Wyświetlaj kwadratowe awatary" +sent: "Wysłane" +received: "Otrzymane" +searchResult: "Wyniki wyszukiwania" +hashtags: "Hashtag" +troubleshooting: "Rozwiązywanie problemów" +useBlurEffect: "Użyj efektów rozmycia w UI" +learnMore: "Dowiedz się więcej" +iceshrimpUpdated: "Iceshrimp zostało zaktualizowane!" +whatIsNew: "Pokaż zmiany" +translate: "Przetłumacz" +translatedFrom: "Przetłumaczone z {x}" +accountDeletionInProgress: "Trwa usuwanie konta" +usernameInfo: "Nazwa, która identyfikuje Twoje konto spośród innych na tym serwerze.\ + \ Możesz użyć alfabetu (a~z, A~Z), cyfr (0~9) lub podkreślników (_). Nazwy użytkownika + nie mogą być później zmieniane." +aiChanMode: "Ai-chan w klasycznym interfejsie" +keepCw: "Zostaw ostrzeżenia o zawartości" +pubSub: "Konta Pub/Sub" +resolved: "Rozwiązane" +unresolved: "Nierozwiązane" +breakFollow: "Usuń obserwującego" +itsOn: "Włączone" +itsOff: "Wyłączone" +unread: "Nieodczytane" +filter: "Filtr" +controlPanel: "Panel sterowania" +manageAccounts: "Zarządzaj kontami" +makeReactionsPublic: "Ustaw historię reakcji jako publiczną" +makeReactionsPublicDescription: "To spowoduje, że lista wszystkich Twoich dotychczasowych + reakcji będzie publicznie widoczna." +classic: "Wyśrodkowany" +muteThread: "Wycisz wątek" +unmuteThread: "Wyłącz wyciszenie wątku" +ffVisibility: "Widoczność obserwowanych/obserwujących" +ffVisibilityDescription: "Pozwala skonfigurować, kto może zobaczyć, kogo obserwujesz + i kto Cię obserwuje." +continueThread: "Kontynuuj wątek" +deleteAccountConfirm: "Spowoduje to nieodwracalne usunięcie Twojego konta. Kontynuować?" +incorrectPassword: "Nieprawidłowe hasło." +voteConfirm: "Potwierdzić swój głos na \"{choice}\"?" +hide: "Ukryj" +leaveGroup: "Opuść grupę" +leaveGroupConfirm: "Czy na pewno chcesz opuścić \"{name}\"?" +useDrawerReactionPickerForMobile: "Wyświetlaj wybornik reakcji jako szufladę na urządzeniach + mobilnych" +clickToFinishEmailVerification: "Kliknij [{ok}], aby zakończyć weryfikację e-mail." +overridedDeviceKind: "Typ urządzenia" +smartphone: "Smartfon" +tablet: "Tablet" +auto: "Automatycznie" +size: "Rozmiar" +numberOfColumn: "Liczba kolumn" +searchByGoogle: "Szukaj" +indefinitely: "Dożywotnio" +file: "Pliki" +logoutConfirm: "Czy na pewno chcesz się wylogować?" +lastActiveDate: "Ostatnio użyte w" +statusbar: "Pasek stanu" +pleaseSelect: "Wybierz opcję" +reverse: "Odwróć" +colored: "Kolorowe" +label: "Etykieta" +type: "Typ" +speed: "Prędkość" +localOnly: "Tylko lokalne" +failedToUpload: "Przesyłanie nie powiodło się" +cannotUploadBecauseInappropriate: "Nie można przesłać tego pliku, ponieważ jego części + zostały wykryte jako potencjalnie nieodpowiednie." +cannotUploadBecauseNoFreeSpace: "Przesyłanie nie powiodło się z powodu braku miejsca + na dysku." +beta: "Beta" +enableAutoSensitive: "Automatyczne oznaczanie plików jako nieodpowiednie" +enableAutoSensitiveDescription: "Umożliwia automatyczne wykrywanie i oznaczanie nieodpowiednich + treści za pomocą uczenia maszynowego tam, gdzie to możliwe. Nawet jeśli ta opcja + jest wyłączona, może być włączona na całym serwerze." +navbar: "Pasek nawigacyjny" +account: "Konta" +move: "Przenieś" +_sensitiveMediaDetection: + description: "Zmniejsza wysiłek związany z moderacją serwera dzięki automatycznemu + rozpoznawaniu nieodpowiedniej zawartości za pomocą uczenia maszynowego. To nieznacznie + zwiększy obciążenie serwera." + setSensitiveFlagAutomatically: "Oznacz jako NSFW" + sensitivity: Czułość wykrywania + analyzeVideosDescription: Analizuje filmy, w dodatku do zdjęć. Zwiększy to nieznacznie + zużycie serwera. + sensitivityDescription: Zmniejszenie czułości doprowadzi do mniejszej liczby błędnych + wykryć (fałszywie pozytywnych), podczas gdy zwiększenie czułości doprowadzi do + mniejszej liczby brakujących wykryć (fałszywie negatywnych). + setSensitiveFlagAutomaticallyDescription: Wyniki wykrywania wewnętrznego zostaną + zachowane, nawet jeśli ta opcja jest wyłączona. + analyzeVideos: Włącz analizę filmów +_emailUnavailable: + used: "Ten adres e-mail jest już używany" + format: "Format tego adresu e-mail jest nieprawidłowy" + disposable: "Nie można używać jednorazowych adresów e-mail" + mx: "Ten serwer e-mail jest nieprawidłowy" + smtp: "Ten serwer e-mail nie odpowiada" +_ffVisibility: + public: "Publiczne" + followers: "Widoczne tylko dla obserwujących" + private: "Prywatne" +_signup: + almostThere: "Prawie na miejscu" + emailAddressInfo: "Podaj swój adres e-mail. Nie zostanie on upubliczniony." + emailSent: "E-mail z potwierdzeniem został wysłany na Twój adres e-mail ({email}). + Kliknij dołączony link, aby dokończyć tworzenie konta." +_accountDelete: + accountDelete: "Usuń konto" + mayTakeTime: "Ponieważ usuwanie konta jest procesem wymagającym dużej ilości zasobów, + jego ukończenie może zająć trochę czasu, w zależności od ilości utworzonej zawartości + i liczby przesłanych plików." + sendEmail: "Po zakończeniu usuwania konta na adres e-mail zarejestrowany na tym + koncie zostanie wysłana wiadomość e-mail." + requestAccountDelete: "Poproś o usunięcie konta" + started: "Usuwanie się rozpoczęło." + inProgress: "Usuwanie jest obecnie w toku" +_ad: + back: "Wróć" + reduceFrequencyOfThisAd: "Pokazuj tę reklamę rzadziej" +_forgotPassword: + enterEmail: "Wpisz adres e-mail użyty do rejestracji. Zostanie do niego wysłany + link, za pomocą którego możesz zresetować hasło." + ifNoEmail: "Jeśli nie użyłeś adresu e-mail podczas rejestracji, skontaktuj się z + administratorem serwera." + contactAdmin: "Ten serwer nie obsługuje adresów e-mail, zamiast tego skontaktuj + się z administratorem serwera, aby zresetować hasło." +_gallery: + my: "Moja galeria" + liked: "Polubione wpisy" + like: "Polub" + unlike: "Cofnij polubienie" +_email: + _follow: + title: "Zaobserwował* Cię" + _receiveFollowRequest: + title: "Otrzymano prośbę o możliwość obserwacji" +_plugin: + install: "Zainstaluj wtyczki" + installWarn: "Nie instaluj niezaufanych wtyczek." + manage: "Zarządzanie wtyczkami" +_preferencesBackups: + list: "Utworzone kopie zapasowe" + saveNew: "Zapisz nową kopię zapasową" + loadFile: "Załaduj z pliku" + apply: "Zastosuj do tego urządzenia" + save: "Zapisz zmiany" + inputName: "Proszę podać nazwę dla tej kopii zapasowej" + cannotSave: "Zapisanie nie powiodło się" + nameAlreadyExists: "Kopia zapasowa o nazwie \"{name}\" już istnieje. Proszę podać + inną nazwę." + applyConfirm: "Czy na pewno chcesz zastosować kopię zapasową \"{name}\" na tym urządzeniu? + Istniejące ustawienia tego urządzenia zostaną nadpisane." + saveConfirm: "Zapisać kopię zapasową jako {name}?" + deleteConfirm: "Usunąć kopię zapasową {name}?" + renameConfirm: "Zmienić nazwę kopii zapasowej z \"{old}\" na \"{new}\"?" + createdAt: "Utworzono w: {date} {time}" + updatedAt: "Zaktualizowano w: {date} {time}" + cannotLoad: "Ładowanie nie powiodło się" + invalidFile: "Nieprawidłowy format pliku" + noBackups: Nie znaleziono kopii zapasowych. Możesz utworzyć kopię zapasową twoich + ustawień klienta na tym serwerze poprzez użycie “Utwórz nową kopię zapasową”. + delete: Usuń kopię zapasową +_registry: + scope: "Zakres" + key: "Klucz" + keys: "Klucz" + domain: "Domena" + createKey: "Utwórz klucz" +_aboutIceshrimp: + about: "Iceshrimp jest forkiem Iceshrimp utworzonym przez ThatOneCalculator, rozwijanym + od 2022." + contributors: "Główni twórcy" + allContributors: "Wszyscy twórcy" + source: "Kod źródłowy" + translation: "Tłumacz Iceshrimp" + donate: "Przekaż darowiznę na Iceshrimp" + morePatrons: "Naprawdę doceniam wsparcie ze strony wielu niewymienionych tu osób. + Dziękuję! 🥰" + patrons: "Wspierający" + donateHost: Wesprzyj {host} + pleaseDonateToIceshrimp: Proszę zastanów się nad dotacją dla Iceshrimp, w celu wsparcia + rozwoju oprogramowania. + donateTitle: Czy podoba ci się Iceshrimp? + pleaseDonateToHost: Zastanów się również nad darowizną dla twojego serwera domowego, + {host}, w celu wsparcia finansowego obsługi serwera. + sponsors: Sponsorzy Iceshrimp + patronsList: Wymienieni chronologicznie, a nie według rozmiaru wsparcia. Wesprzyj + używając powyższego linku, by się tutaj znaleźć! +_nsfw: + respect: "Ukrywaj potencjalnie nieodpowiednie media" + ignore: "Nie ukrywaj nieodpowiednich mediów" + force: "Ukrywaj wszystkie media" +_mfm: + cheatSheet: "Ściąga MFM" + intro: "MFM jest językiem składniowym używanym przez m.in. Iceshrimp, forki *key + (w tym Iceshrimp), oraz Akkomę, który może być użyty w wielu miejscach. Tu znajdziesz + listę wszystkich możliwych elementów składni MFM." + dummy: "Iceshrimp rozszerza świat Fediwersum" + mention: "Wspomnij" + mentionDescription: "Używając znaku @ i nazwy użytkownika, możesz określić danego + użytkownika." + hashtag: "Hashtag" + hashtagDescription: "Używając kratki i tekstu, możesz określić hashtag." + url: "Adres URL" + urlDescription: "Adresy URL mogą być wyświetlane." + link: "Odnośnik" + linkDescription: "Określone części tekstu mogą być wyświetlane jako adres URL." + bold: "Pogrubienie" + boldDescription: "Wyróżnia litery pogrubiając je." + small: "Małe" + smallDescription: "Wyświetla treść jako małą i cienką." + center: "Wyśrodkowanie" + centerDescription: "Wyśrodkowuje zawartość." + inlineCode: "Kod (w wierszu)" + blockCode: "Kod (blok)" + blockCodeDescription: "Wyświetla kod z podświetlaną składnią składający się z wielu + linii." + blockMath: "Matematyka (Blok)" + quote: "Cytuj" + quoteDescription: "Wyświetla treść jako cytat." + emoji: "Niestandardowe emoji" + emojiDescription: "Otaczając nazwę niestandardowego emoji dwukropkami, możesz użyć + niestandardowego emoji." + search: "Szukaj" + searchDescription: "Wyświetla pole wyszukiwania z wcześniej wpisanym tekstem." + flip: "Odwróć" + flipDescription: "Przerzuca treść poziomo lub pionowo." + jelly: "Animacja (Galaretka)" + jellyDescription: "Nadaje treści galaretowatą animację." + tada: "Animacja (Tada)" + tadaDescription: "Nadaje treści animację podobną do \"Tada!\"." + jump: "Animacja (Skok)" + jumpDescription: "Nadaje treści animację skakania." + bounce: "Animacja (Odbijanie)" + bounceDescription: "Nadaje treści animację odbijania się." + shake: "Animacja (Wstrząsanie)" + shakeDescription: "Nadaje treści animację wstrząsania." + twitch: "Animacja (Drganie)" + twitchDescription: "Nadaje treści mocno drgającą animację." + spin: "Animacja (Obrót)" + spinDescription: "Nadaje treści animację obracania." + x2: "Duże" + x2Description: "Czyni treść większą." + x3: "Bardzo duże" + x3Description: "Czyni treść jeszcze większą." + x4: "Ogromne" + x4Description: "Czyni treść nawet większą niż jeszcze większa." + blur: "Rozmycie" + blurDescription: "Rozmywa treść. Zostanie wyraźnie wyświetlona po najechaniu." + font: "Czcionka" + fontDescription: "Wybiera czcionkę do wyświetlania treści." + rainbow: "Tęcza" + rainbowDescription: "Sprawia, że zawartość pojawia się w kolorach tęczy." + sparkle: "Blask" + sparkleDescription: "Nadaje zawartości efekt lśniącego brokatu." + rotate: "Obrót" + rotateDescription: "Obraca zawartość o określony kąt." + plain: "Zwyczajny" + plainDescription: "Wyłącza efekty wszystkich MFM zawartych w tym efekcie MFM." + inlineCodeDescription: Wyświetla podświetlanie składni dla kodu (programu) w linii. + inlineMath: Matematyka (Inline) + inlineMathDescription: Pokaż formuły matematyczne (KaTeX) w linii + blockMathDescription: Pokaż wieloliniowe formuły matematyczne (KaTeX) w bloku + background: Kolor tła + backgroundDescription: Zmień kolor tła tekstu. + foregroundDescription: Zmień kolor pierwszoplanowy tekstu. + positionDescription: Przesuń zawartość o określoną wartość. + position: Pozycjonuj + foreground: Kolor pierwszoplanowy + scaleDescription: Skaluj treść o określoną wielkość. + scale: Skaluj + warn: MFM może zawierać szybko poruszające się albo migające animacje + advancedDescription: Jeśli wyłączone, pozwala tylko na podstawowe znaczniki, chyba, + że animowane MFM jest odtwarzane + advanced: Zaawansowane MFM + stop: Zatrzymaj MFM + alwaysPlay: Autoodtwarzaj wszystkie animowane MFM + fade: Zanik + fadeDescription: Pozwala na zanikanie i ponowne pojawianie się zawartości. + crop: Kadrowanie + cropDescription: Kadruj zawartość. + play: Odtwórz MFM +_instanceTicker: + none: "Nigdy nie pokazuj" + remote: "Pokaż dla zdalnych użytkowników" + always: "Zawsze pokazuj" +_serverDisconnectedBehavior: + reload: "Automatycznie odśwież" + dialog: "Pokazuj okno ostrzeżenia" + quiet: "Pokazuj nieirytujące ostrzeżenia" + nothing: Nic nie rób +_channel: + create: "Utwórz kanał" + edit: "Edytuj kanał" + setBanner: "Ustaw baner" + removeBanner: "Usuń baner" + featured: "Na czasie" + owned: "Własny" + following: "Śledzeni" + usersCount: "{n} uczestnicy" + notesCount: "{n} wpisy" + nameAndDescription: Nazwa i opis + nameOnly: Tylko nazwa +_menuDisplay: + top: "Góra" + hide: "Ukryj" + sideFull: Z boku + sideIcon: Z boku (tylko ikony) +_wordMute: + muteWords: "Słowo do wyciszenia" + muteWordsDescription2: "Otocz słowa kluczowe ukośnikami, aby używać wyrażeń regularnych." + soft: "Łagodny" + hard: "Twardy" + mutedNotes: "Wyciszone wpisy" + muteWordsDescription: Rozdzielaj spacją dla kondycji AND, lub przerwaniem wiersza + dla kondycji OR. + softDescription: Ukryj z osi czasu wpisy, które spełniają podane warunki. + hardDescription: Zapobiega dodawania do osi czasu wpisów, które spełniają podane + warunki. Dodatkowo, te wpisy nie zostaną dodane do osi czasu, jeśli warunki się + zmienią. +_instanceMute: + title: "Ukrywa wpisy z wymienionych instancji." + heading: "Lista instancji do wyciszenia" + instanceMuteDescription2: Oddzielaj nowymi liniami + instanceMuteDescription: Spowoduje to wyciszenie wszystkich wpisów/podbić z podanych + instancji, w tym tych od użytkowników odpowiadających na wpisy z wyciszonych instancji. +_theme: + explore: "Przeglądaj motywy" + install: "Zainstaluj motyw" + manage: "Zarządzanie motywami" + code: "Kod motywu" + description: "Opis" + installed: "Zainstalowano {name}" + installedThemes: "Zainstalowane motywy" + builtinThemes: "Wbudowane motywy" + alreadyInstalled: "Motyw jest już zainstalowany" + invalid: "Format motywu jest nieprawidłowy" + make: "Utwórz motyw" + base: "Podstawowy" + addConstant: "Dodaj stałą" + constant: "Stała" + defaultValue: "Domyślna wartość" + color: "Kolor" + refProp: "Nawiąż do właściwości" + refConst: "Nawiąż do stałej" + key: "Klucz" + func: "Funkcje" + funcKind: "Rodzaj funkcji" + argument: "Argument" + basedProp: "Nawiązana właściwość" + alpha: "Przezroczystość" + darken: "Ściemnij" + lighten: "Rozjaśnij" + inputConstantName: "Wprowadź nazwę stałej" + importInfo: "Jeżeli wprowadzisz tu kod motywu, możesz zaimportować go w edytorze + motywu" + deleteConstantConfirm: "Czy na pewno chcesz usunąć stałą {const}?" + keys: + accent: "Akcent" + bg: "Tło" + fg: "Tekst" + focus: "Skupienie" + indicator: "Wskaźnik" + panel: "Panel" + shadow: "Cień" + header: "Nagłówek" + navBg: "Tło paska bocznego" + navFg: "Tekst paska bocznego" + navHoverFg: "Tekst paska bocznego (zbliżenie)" + navActive: "Tekst paska bocznego (aktywny)" + navIndicator: "Wskaźnik paska bocznego" + link: "Odnośnik" + hashtag: "Hashtag" + mention: "Wspomnij" + mentionMe: "Wspomnienia (ja)" + renote: "Podbij" + modalBg: "Tło modalu" + divider: "Rozdzielacz" + scrollbarHandle: "Uchwyt paska przewijania" + scrollbarHandleHover: "Uchwyt paska przewijania (po najechaniu)" + dateLabelFg: "Tekst z datą" + infoBg: "Tło informacji" + infoFg: "Tekst informacji" + infoWarnBg: "Tło ostrzeżenia" + infoWarnFg: "Tekst ostrzeżenia" + cwBg: "Tło CW" + cwFg: "Tekst CW" + cwHoverBg: "Tło CW (po najechaniu)" + toastBg: "Tło powiadomień" + toastFg: "Tekst powiadomień" + buttonBg: "Tło przycisku" + buttonHoverBg: "Tło przycisku (po najechaniu)" + inputBorder: "Obramowanie pola wejścia" + listItemHoverBg: "Tło elementu listy (po najechaniu)" + driveFolderBg: "Tło folderu na dysku" + wallpaperOverlay: "Nakładka tapety" + badge: "Odznaka" + messageBg: "Tło czatu" + accentDarken: "Akcent (ciemniejszy)" + accentLighten: "Akcent (jaśniejszy)" + fgHighlighted: "Wyróżniony tekst" +_sfx: + note: "Wpisy" + noteMy: "Mój wpis" + notification: "Powiadomienia" + chat: "Wiadomości" + chatBg: "Rozmowy (tło)" + antenna: "Anteny" + channel: "Powiadomienia kanału" +_ago: + future: "W przyszłości" + justNow: "Przed chwilą" + secondsAgo: "{n} sek. temu" + minutesAgo: "{n} min {n2} sek. temu" + hoursAgo: "{n} godz {n2} min. temu" + daysAgo: "{n} dni {n2} godz. temu" + weeksAgo: "{n} tyg. {n2} dni temu" + monthsAgo: "{n} mies {n2} tyg. temu" + yearsAgo: "{n} lat {n2} mies. temu" +_time: + second: "sekunda" + minute: "minuta" + hour: "godz." + day: "dzień" +_tutorial: + title: "Jak korzystać z Iceshrimp" + step1_1: "Witamy!" + step1_2: "Pozwól, że Cię skonfigurujemy. Będziesz działać w mgnieniu oka!" + step2_1: "Najpierw, proszę wypełnij swój profil." + step2_2: "Podanie kilku informacji o tym, kim jesteś, ułatwi innym stwierdzenie, + czy chcą zobaczyć Twoje wpisy lub śledzić Cię." + step3_1: "Pora znaleźć osoby do śledzenia!" + step3_2: "Twoje domowe i społeczne linie czasu opierają się na tym, kogo śledzisz, + więc spróbuj śledzić kilka kont, aby zacząć.\nKliknij kółko z plusem w prawym + górnym rogu profilu, aby go śledzić." + step4_1: "Pozwól, że zabierzemy Cię tam." + step4_2: "W pierwszym wpisie możesz się przedstawić lub wysłać powitanie - \"Witaj, + świecie!\"" + step5_1: "Osie czasu, wszędzie widzę osie czasu!" + step5_2: "Twoja instancja ma włączone {timelines} różne osie czasu." + step5_3: "Główna {icon} oś czasu to miejsce, w którym możesz zobaczyć posty od użytkowników + których obserwujesz, oraz innych użytkowników z tej instancji. Jeśli wolisz, by + główna oś czasu pokazywała tylko posty od użytkowników których obserwujesz, możesz + łatwo to zmienić w ustawieniach!" + step5_4: "Lokalna {icon} oś czasu to miejsce, w którym możesz zobaczyć posty od + wszystkich innych osób na tej instancji." + step5_5: "Społeczna {icon} oś czasu to miejsce, gdzie możesz zobaczyć posty z instancji, + które admini polecają." + step5_6: "Polecana {icon} oś czasu to miejsce, gdzie możesz zobaczyć posty z instancji, + które admini polecają." + step5_7: "Globalna {icon} oś czasu to miejsce, gdzie możesz zobaczyć posty z każdej + innej połączonej instancji." + step6_1: "Więc, czym to jest to miejsce?" + step6_2: "Cóż, nie dołączył*ś po prostu do Iceshrimp. Dołączył*ś do portalu do Fediverse, + połączonej sieci tysięcy serwerów, zwanych instancjami." + step6_3: "Każdy serwer działa w inny sposób, i nie wszystkie serwery używają Iceshrimp. + Ten jednak używa! Jest to trochę skomplikowane, ale w krótkim czasie załapiesz + o co chodzi." + step6_4: "A teraz idź, odkrywaj i baw się dobrze!" +_2fa: + alreadyRegistered: "Zarejestrowałeś już urządzenie do uwierzytelniania dwuskładnikowego." + registerTOTP: "Zarejestruj nowe urządzenie" + registerSecurityKey: "Zarejestruj klucz bezpieczeństwa" + step1: "Najpierw, zainstaluj aplikację uwierzytelniającą (taką jak {a} lub {b}) + na swoim urządzeniu." + step2: "Następnie, zeskanuje kod QR z ekranu." + step3: "Wprowadź token podany w aplikacji, aby ukończyć konfigurację." + step4: "Od teraz, przy każdej próbie logowania otrzymasz prośbę o token logowania." + step2Url: 'Możesz też wpisać ten URL jeśli używasz programu komputerowego:' + securityKeyInfo: Oprócz uwierzytelnienia odciskiem palców lub PIN, możesz również + skonfigurować uwierzytelnienie za pomocą kluczy sprzętowych obsługujących FIDO2, + w celu dalszego zabezpieczenia Twojego konta. + step2Click: Kliknięcie tego kodu QR pozwoli ci na zarejestrowanie 2FA na twoim kluczu + bezpieczeństwa, albo aplikacji uwierzytelniającej na telefonie. + registerTOTPBeforeKey: Proszę zarejestruj aplikację uwierzytelniającą w celu zarejestrowania + klucza bezpieczeństwa, albo passkey. + whyTOTPOnlyRenew: Aplikacja uwierzytelniająca nie może być usunięta tak długo, jak + do konta jest przypisany klucz bezpieczeństwa. + step3Title: Wprowadź kod uwierzytelniający + securityKeyNotSupported: Twoja przeglądarka nie obsługuje kluczy bezpieczeństwa + chromePasskeyNotSupported: Passkey Chrome nie są obecnie wspierane. + securityKeyName: Wprowadź nazwę klucza + tapSecurityKey: Postępuj zgodnie z instrukcjami przeglądarki w celu zarejestrowania + klucza + removeKey: Usuń klucz bezpieczeństwa + removeKeyConfirm: Czy na pewno chcesz usunąć klucz {name}? + renewTOTP: Przekonfiguruj aplikację uwierzytelniającą + renewTOTPConfirm: To sprawi, że kody z poprzedniej aplikacji przestaną działać + renewTOTPCancel: Anuluj + token: Token 2FA + renewTOTPOk: Przekonfiguruj +_permissions: + "read:account": "Wyświetlanie informacji o twoim koncie" + "write:account": "Edycja informacji o twoim koncie" + "read:blocks": "Wyświetlanie listy zablokowanych użytkowników" + "write:blocks": "Blokowanie i odblokowywanie użytkowników" + "read:drive": "Wyświetlanie plików i folderów z twojego Dysku" + "write:drive": "Edycja i usuwanie plików i katalogów z Twojego dysku" + "read:favorites": "Wyświetlanie Twoich zakładek" + "write:favorites": "Edycja Twoich zakładek" + "read:following": "Wyświetlanie informacji o obserwowanych" + "write:following": "Obserwowanie lub cofanie obserwacji innych kont" + "read:messaging": "Wyświetlanie twoich czatów" + "read:mutes": "Wyświetlanie listy wyciszonych osób" + "write:mutes": "Edycja listy wyciszonych osób" + "read:notifications": "Wyświetlanie powiadomień" + "write:notifications": "Działanie na powiadomieniach" + "read:reactions": "Wyświetlanie reakcji" + "write:reactions": "Edycja reakcji" + "write:votes": "Głosowanie w ankiecie" + "read:pages": "Wyświetlanie Twoich stron" + "write:pages": "Edycja i usuwanie Twoich stron" + "read:page-likes": "Wyświetlanie polubień na stronach" + "write:page-likes": "Edycja polubień na stronach" + "read:user-groups": "Wyświetlanie grup użytkownika" + "write:user-groups": "Edycja i usuwanie grup użytkownika" + "read:channels": "Wyświetlenie Twoich kanałów" + "write:channels": "Edycja Twoich kanałów" + "read:gallery": "Wyświetlenie Twojej galerii" + "write:gallery": "Edycja Twojej galerii" + "write:messaging": Tworzenie i usuwanie wiadomości czatu + "write:notes": Tworzenie i usuwanie wpisów + "read:gallery-likes": Wyświetlenie Twojej listy z polubionymi postami galerii + "write:gallery-likes": Edycja Twojej listy z polubionymi postami galerii +_auth: + shareAccess: "Czy chcesz autoryzować „{name}” do dostępu do tego konta?" + permissionAsk: "Ta aplikacja wymaga następujących uprawnień:" + denied: Odmowa dostępu + copyAsk: 'Proszę wpisz następujący kod autoryzacyjny w aplikacji:' + shareAccessAsk: Czy na pewno chcesz upoważnić tą aplikację do dostępu do Twojego + konta? + pleaseGoBack: Wróć do aplikacji + callback: Wracam do aplikacji + allPermissions: Pełny dostęp do konta +_weekday: + sunday: "Niedziela" + monday: "Poniedziałek" + tuesday: "Wtorek" + wednesday: "Środa" + thursday: "Czwartek" + friday: "Piątek" + saturday: "Sobota" +_widgets: + memo: "Przypięte notatki" + notifications: "Powiadomienia" + timeline: "Oś czasu" + calendar: "Kalendarz" + trends: "Na czasie" + clock: "Zegar" + rss: "Czytnik RSS" + activity: "Aktywność" + photos: "Zdjęcia" + digitalClock: "Zegar cyfrowy" + unixClock: "Zegar UNIX" + federation: "Federacja" + postForm: "Formularz tworzenia wpisu" + slideshow: "Pokaz slajdów" + button: "Przycisk" + onlineUsers: "Użytkownicy online" + jobQueue: "Kolejka zadań" + serverMetric: "Metryka serwera" + aiscript: "Konsola AiScript" + aichan: "Ai" + rssTicker: Ticker RSS + userList: Lista użytkowników + _userList: + chooseList: Wybierz listę + serverInfo: Informacje o serwerze + meiliIndexCount: Zindeksowane posty + meiliStatus: Status serwera + meiliSize: Rozmiar indeksu +_cw: + hide: "Ukryj" + show: "Załaduj więcej" + chars: "{count} znak(-i/-ów)" + files: "{count} plik(-i/-ów)" +_poll: + noOnlyOneChoice: "Wymagane są przynajmniej dwie opcje" + choiceN: "Opcja {n}" + noMore: "Nie możesz dodać więcej opcji" + canMultipleVote: "Pozwól na wiele odpowiedzi" + expiration: "Ankieta kończy się" + infinite: "Nigdy" + at: "Zakończ o…" + after: "Zakończ po…" + deadlineDate: "Data zakończenia" + deadlineTime: "godz." + duration: "Czas trwania" + votesCount: "{n} głosów" + totalVotes: "Łącznie {n} głosów" + vote: "Głosowanie w ankiecie" + showResult: "Pokaż wyniki" + voted: "Zagłosowano" + closed: "Zakończono" + remainingDays: "Pozostało {d} dni i {h} godzin" + remainingHours: "Pozostali {h} godzin i {m} minut" + remainingMinutes: "Pozostało {m} minut i {s} sekund" + remainingSeconds: "Pozostało {s} sekund" +_visibility: + public: "Publiczny" + publicDescription: "Wpis pojawi się u wszystkich" + home: "Niewidoczny" + followers: "Obserwujący" + specified: "Bezpośredni" + specifiedDescription: "Napisz tylko określonym użytkownikom" + homeDescription: Wpis będzie publiczny ale nie pojawi się na osi czasu instancji + followersDescription: Wpis pojawi się tylko na osiach czasu Twoich obserwujących + localOnly: Lokalnie + localOnlyDescription: Wpis będzie widoczny tylko dla użytkowników tej instancji +_postForm: + _placeholders: + a: "Co się dzieje?" + b: "Co się wydarzyło?" + c: "Co Ci chodzi po głowie?" + d: "Czy masz coś do powiedzenia?" + e: "Zacznij coś pisać…" + f: "Czekamy, aż coś napiszesz." + quotePlaceholder: Cytuj ten wpis... + channelPlaceholder: Wyślij na kanał... + replyPlaceholder: Odpowiedz na ten wpis... +_profile: + name: "Nazwa" + username: "Nazwa użytkownika" + description: "Opis" + youCanIncludeHashtags: "Możesz umieścić hashtagi w swoim opisie." + metadata: "Dodatkowe informacje" + metadataEdit: "Edytuj dodatkowe informacje" + metadataDescription: "Możesz wyświetlać do czterech sekcji dodatkowych informacji + na swoim profilu. Możesz dodać tag {a} lub tag {l} z {rel}, aby zweryfikować link + w swoim profilu!" + metadataLabel: "Etykieta" + metadataContent: "Treść" + changeAvatar: "Zmień awatar" + changeBanner: "Zmień baner" + locationDescription: Jeśli wpiszesz z początku swoje miasto, twój czas lokalny będzie + się pokazywać innym użytkownikom. +_exportOrImport: + allNotes: "Wszystkie wpisy" + followingList: "Obserwowani" + muteList: "Wycisz" + blockingList: "Zablokuj" + userLists: "Listy" + excludeMutingUsers: Wyklucz wyciszonych użytkowników + excludeInactiveUsers: Wyklucz nieaktywnych użytkowników +_charts: + federation: "Federacja" + apRequest: "Żądania" + usersTotal: "Łącznie # użytkowników" + activeUsers: "Aktywni użytkownicy" + storageUsageTotal: Łączne użycie dysku + filesIncDec: Różnica w liczbie plików + filesTotal: Łączna liczba plików + storageUsageIncDec: Różnica w wykorzystaniu miejsca + localNotesIncDec: Różnica w liczbie lokalnych wpisów + remoteNotesIncDec: Różnica w liczbie zdalnych wpisów + notesTotal: Łączna liczba wpisów + usersIncDec: Różnica w liczbie użytkowników + notesIncDec: Różnica w liczbie wpisów +_instanceCharts: + requests: "Żądania" + notesTotal: "Łącznie # wpisów" + ff: "Różnica w # obserwujących " + ffTotal: "Łączna liczba # obserwujących" + cacheSize: "Różnica w rozmiarze pamięci podręcznej" + cacheSizeTotal: "Łączny rozmiar pamięci podręcznej" + files: "Różnica # plików" + filesTotal: "Łącznie # plików" + usersTotal: Łączna liczba użytkowników + users: Różnica w liczbie użytkowników + notes: Różnica w liczbie wpisów +_timelines: + home: "Strona główna" + local: "Lokalne" + social: "Społeczna" + global: "Globalna" + recommended: Polecana +_pages: + newPage: "Utwórz stronę" + editPage: "Edytuj tę stronę" + readPage: "Aktywowano widok źródła" + created: "Pomyślnie utworzono stronę" + updated: "Pomyślnie zaktualizowano stronę" + deleted: "Pomyślnie usunięto stronę" + pageSetting: "Ustawienia strony" + nameAlreadyExists: "Określony adres URL strony już istnieje" + invalidNameTitle: "Podany adres URL strony jest nieprawidłowy" + invalidNameText: "Upewnij się, że pole tytułowe strony nie jest puste" + editThisPage: "Edytuj tę stronę" + viewSource: "Zobacz źródło" + viewPage: "Wyświetlanie Twoich stron" + like: "Lubię" + unlike: "Cofnij polubienie" + my: "Moje strony" + liked: "Polubione strony" + featured: "Popularne" + inspector: "Inspektor" + contents: "Zawartość" + content: "Blok strony" + variables: "Zmienne" + title: "Tytuł" + url: "URL strony" + summary: "Podsumowanie strony" + alignCenter: "Wyśrodkuj elementy" + hideTitleWhenPinned: "Ukryj tytuł strony, gdy przypięta do profilu" + font: "Czcionka" + fontSerif: "Szeryfowa" + fontSansSerif: "Bezszeryfowa" + eyeCatchingImageSet: "Ustaw miniaturę" + eyeCatchingImageRemove: "Usuń miniaturę" + chooseBlock: "Dodaj blok" + selectType: "Wybierz typ" + enterVariableName: "Wprowadź nazwę dla swojej zmiennej" + variableNameIsAlreadyUsed: "Ta nazwa jest już używana przez inną zmienną" + contentBlocks: "Zawartość" + inputBlocks: "Wejście" + specialBlocks: "Specjalne" + blocks: + text: "Tekst" + textarea: "Pole tekstowe" + section: "Sekcja" + image: "Zdjęcia" + button: "Przycisk" + if: "Jeżeli" + _if: + variable: "Zmienna" + post: "Utwórz wpis" + _post: + text: "Treść" + attachCanvasImage: Załącz obraz płótna + canvasId: ID płótna + textInput: "Pole tekstowe" + _textInput: + name: "Nazwa zmiennej" + text: "Tytuł" + default: "Domyślna wartość" + textareaInput: "Wielowierszowe pole tekstowe" + _textareaInput: + name: "Nazwa zmiennej" + text: "Tytuł" + default: "Domyślna wartość" + numberInput: "Pole na liczbę" + _numberInput: + name: "Nazwa zmiennej" + text: "Tytuł" + default: "Domyślna wartość" + _canvas: + width: "Szerokość" + height: "Wysokość" + id: ID płótna + note: "Osadzony wpis" + _note: + id: "ID wpisu" + idDescription: "Możesz też wkleić adres URL wpisu, aby go ustawić." + detailed: "Szczegółowy widok" + switch: "Przełącznik" + _switch: + name: "Nazwa zmiennej" + text: "Tytuł" + default: "Domyślna wartość" + counter: "Licznik" + _counter: + name: "Nazwa zmiennej" + text: "Tytuł" + inc: "Zwiększ o" + _button: + text: "Tytuł" + colored: "Kolorowe" + action: "Działanie wykonywane przy naciśnięciu przycisku" + _action: + dialog: "Pokazuj okno dialogowe" + _dialog: + content: "Treść" + resetRandom: "Resetuj losowe ziarno" + pushEvent: "Wyślij zdarzenie" + _pushEvent: + event: "Nazwa zdarzenia" + message: "Wiadomość do wyświetlenia po aktywowaniu" + variable: "Zmienna do wysłania" + no-variable: "Brak" + callAiScript: "Wywołaj AiScript" + _callAiScript: + functionName: "Nazwa funkcji" + radioButton: "Wybór" + _radioButton: + name: "Nazwa zmiennej" + title: "Tytuł" + values: "Lista wyborów (oddzielonych znakiem nowego wiersza)" + default: "Domyślna wartość" + canvas: Płótno + script: + categories: + flow: "Kontrola przepływu" + logical: "Operacje logiczne" + operation: "Obliczanie" + comparison: "Porównanie" + random: "Losowe" + value: "Wartość" + fn: "Funkcje" + text: "Działania na tekście" + convert: "Transformacja" + list: "Listy" + blocks: + text: "Tekst" + multiLineText: "Tekst (w wielu wierszach)" + _textList: + info: "Oddziel każdy wpis znakiem nowego wiersza" + strLen: "Długość tekstu" + _strLen: + arg1: "Tekst" + _strPick: + arg1: "Tekst" + arg2: "Położenie znaku" + strReplace: "Zamiana tekstu" + _strReplace: + arg1: "Tekst" + arg2: "Tekst do zamiany" + arg3: "Zamieniono z" + _strReverse: + arg1: "Tekst" + _join: + arg1: "Listy" + arg2: "Odstęp" + add: "Dodaj" + _add: + arg1: "A" + arg2: "B" + subtract: "Odejmij" + _subtract: + arg1: "A" + arg2: "B" + multiply: "Pomnóż" + _multiply: + arg1: "A" + arg2: "B" + divide: "Podziel" + _divide: + arg1: "A" + arg2: "B" + mod: "Reszta" + _mod: + arg1: "A" + arg2: "B" + _round: + arg1: "Liczba" + eq: "A i B są sobie równe" + _eq: + arg1: "A" + arg2: "B" + notEq: "A i B różnią się" + _notEq: + arg1: "A" + arg2: "B" + and: "A I B" + _and: + arg1: "A" + arg2: "B" + or: "A LUB B" + _or: + arg1: "A" + arg2: "B" + lt: "< A jest mniejsze niż B" + _lt: + arg1: "A" + arg2: "B" + gt: "> A jest większe od B" + _gt: + arg1: "A" + arg2: "B" + ltEq: "<= A jest mniejsze lub równe B" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: ">= A jest większe lub równe B" + _gtEq: + arg1: "A" + arg2: "B" + if: "Warunek" + _if: + arg1: "Jeżeli" + arg2: "Wtedy" + arg3: Inaczej + not: "NIE" + _not: + arg1: "NIE" + random: "Losowe" + _random: + arg1: "Prawdopodobieństwo" + rannum: "Losowa liczba" + _rannum: + arg1: "Minimalna wartość" + arg2: "Maksymalna wartość" + randomPick: "Wybierz losowo z listy" + _randomPick: + arg1: "Listy" + dailyRandom: "Losowo (zmienia się raz dziennie dla każdego użytkownika)" + _dailyRandom: + arg1: "Prawdopodobieństwo" + dailyRannum: "Losowa liczba (zmienia się raz dziennie dla każdego użytkownika)" + _dailyRannum: + arg1: "Minimalna wartość" + arg2: "Maksymalna wartość" + dailyRandomPick: "Wybierz losowo z listy (zmienia się raz dziennie dla każdegoużytkownika)" + _dailyRandomPick: + arg1: "Listy" + seedRandom: "Losowo (z ziarnem)" + _seedRandom: + arg1: "Ziarno" + arg2: "Prawdopodobieństwo" + seedRannum: "Losowa liczba (z ziarnem)" + _seedRannum: + arg1: "Ziarno" + arg2: "Minimalna wartość" + arg3: "Maksymalna wartość" + seedRandomPick: "Wybierz losowo z listy (z ziarnem)" + _seedRandomPick: + arg1: "Ziarno" + arg2: "Listy" + DRPWPM: "Wybierz losowo z ważonej listy (zmienia się raz dziennie dla każdegoużytkownika)" + pick: "Wybierz z listy" + _pick: + arg1: "Listy" + arg2: "Położenie" + listLen: "Uzyskaj długość listy" + _listLen: + arg1: "Listy" + number: "Liczba" + stringToNumber: "Tekst na liczbę" + _stringToNumber: + arg1: "Tekst" + numberToString: "Liczba na tekst" + _numberToString: + arg1: "Liczba" + splitStrByLine: "Rozdziel tekst znakami nowej linii" + _splitStrByLine: + arg1: "Tekst" + ref: "Zmienne" + aiScriptVar: "Zmienna AiScript" + fn: "Funkcje" + _fn: + arg1: "Wyjście" + slots-info: Oddziel każde gniazdo nową linią + slots: Gniazda + for: "Powtórzenie" + _for: + arg1: "Liczba powtórzeń" + arg2: "Działanie" + textList: Lista tekstowa + strPick: Wyciągaczka ciągu znaków + strReverse: Odwróć tekst + join: Łączenie tekstu + round: Zaokrąglanie wartości dziesiętnych + _DRPWPM: + arg1: Lista tekstowa + types: + string: "Tekst" + number: "Liczba" + boolean: "Flaguj" + array: "Listy" + stringArray: Lista tekstowa + enviromentVariables: "Zmienna środowiskowa" + pageVariables: "Element strony" + argVariables: Gniazda wejściowe + typeError: Gniazdo {slot} akceptuje wartości typu “{expect}”, lecz wprowadzona + wartość jest typu “{actual}”! + thereIsEmptySlot: Gniazdo {slot} jest puste! + emptySlot: Puste gniazdo +_relayStatus: + requesting: "Oczekujące" + accepted: "Zaakceptowano" + rejected: "Odrzucono" +_notification: + fileUploaded: "Pomyślnie wysłano plik" + youGotMention: "{name} wspomniał* o Tobie" + youGotReply: "{name} odpowiedział* Tobie" + youGotQuote: "{name} zacytował* Ciebie" + youRenoted: "{name} podbił* Twój wpis" + youGotPoll: "{name} zagłosował* w Twojej ankiecie" + youGotMessagingMessageFromUser: "{name} wysłał* Ci wiadomość" + youGotMessagingMessageFromGroup: "Została wysłana wiadomość do grupy {name}" + youWereFollowed: "Zaobserwował* Cię" + youReceivedFollowRequest: "Otrzymał*ś prośbę o możliwość obserwacji" + yourFollowRequestAccepted: "Twoja prośba o możliwość obserwacji została przyjęta" + youWereInvitedToGroup: "{userName} zaprosił* Ciebie do grupy" + pollEnded: "Wyniki ankiety stały się dostępne" + emptyPushNotificationMessage: "Powiadomienia push zostały zaktualizowane" + _types: + all: "Wszystkie" + follow: "Nowi obserwujący" + mention: "Wspomnienia" + reply: "Odpowiedzi" + renote: "Podbicia" + quote: "Cytaty" + reaction: "Reakcje" + pollVote: "Głosy w ankietach" + receiveFollowRequest: "Otrzymane prośby o możliwość obserwacji" + followRequestAccepted: "Przyjęte prośby o możliwość obserwacji" + groupInvited: "Zaproszenia do grup" + app: "Powiadomienia z powiązanych aplikacji" + pollEnded: Zakończone ankiety + _actions: + followBack: "zaobserwował* cię z powrotem" + reply: "Odpowiedz" + renote: "Podbicia" + reacted: zareagował* na Twój post + voted: zagłosował* na Twoją ankietę + renoted: podbił* Twój post +_deck: + alwaysShowMainColumn: "Zawsze pokazuj główną kolumnę" + columnAlign: "Wyrównaj kolumny" + addColumn: "Dodaj kolumnę" + configureColumn: "Ustawienia kolumny" + swapLeft: "Przesuń w lewo" + swapRight: "Przesuń w prawo" + swapUp: "Zamień z powyższym" + swapDown: "Zamień z poniższym" + stackLeft: "Przypnij do lewej" + popRight: "Odepnij w prawo" + profile: "Przestrzeń" + newProfile: "Nowa przestrzeń" + deleteProfile: "Usuń przestrzeń" + widgetsIntroduction: "Wybierz \"Edytuj widżety\" w menu kolumny i dodaj widżet." + _columns: + main: "Główna" + widgets: "Widżety" + notifications: "Powiadomienia" + tl: "Oś czasu" + antenna: "Anteny" + list: "Listy" + mentions: "Wspomnienia" + direct: "Bezpośrednie wiadomości" + channel: Kanał + introduction2: Kliknij + z prawej strony ekranu, by dodać nowe kolumny kiedy chcesz. + introduction: Utwórz idealny dla siebie interfejs, poprzez dowolne ustawianie kolumn! + renameProfile: Zmień nazwę przestrzeni + nameAlreadyExists: Ta nazwa przestrzeni już istnieje. +accountMoved: 'Użytkownik przeniósł się na nowe konto:' +flagShowTimelineRepliesDescription: Jeśli włączone, pokazuje odpowiedzi użytkowników + na wpisy innych użytkowników na osi czasu. +manageGroups: Zarządzaj grupami +objectStorageSetPublicRead: Ustaw "public-read" podczas wysyłania +removeAllFollowing: Przestań obserwować wszystkich obserwowanych użytkowników +smtpSecure: Użyj implicit SSL/TLS dla połączeń SMTP +secureMode: Tryb bezpieczny (Authorized Fetch) +instanceSecurity: Bezpieczeństwo serwera +privateMode: Tryb prywatny +allowedInstances: Dopuszczone serwery +recommended: Polecane +allowedInstancesDescription: Hosty serwerów, które mają być dopuszczone do federacji, + każdy oddzielony nowym wierszem (dotyczy tylko trybu prywatnego). +seperateRenoteQuote: Oddziel przyciski podbicia i cytowania +refreshInterval: 'Częstotliwość aktualizacji ' +slow: Wolna +_messaging: + dms: Prywatne + groups: Grupy +_antennaSources: + all: Wszystkie wpisy + users: Wpisy od konkretnych użytkowników + homeTimeline: Wpisy od obserwowanych użytkowników + userList: Wpisy od użytkowników z konkretnej listy + userGroup: Wpisy od użytkowników z konkretnej grupy + instances: Wpisy od wszystkich użytkowników na instancji +enableRecommendedTimeline: Włącz polecaną oś czasu +recentNDays: Ostatnie {n} dni +driveCapOverrideCaption: Zresetuj pojemność do domyślnej poprzed wpisanie wartości + 0 lub mniejszej. +requireAdminForView: Musisz zalogować się jako administrator, by to zobaczyć. +replayTutorial: Powtórz samouczek +migration: Migracja +moveTo: Przenieś obecne konto do nowego +moveToLabel: 'Konto na które się przenosisz:' +moveAccount: Przenieś konto! +moveAccountDescription: Ten proces jest nieodwracalny. Upewnij się, że utworzył*ś + alias dla tego konta na nowym koncie, przed rozpoczęciem. Proszę wpisz tag konta + w formacie @osoba@serwer.com +moveFrom: Przejdź ze starego konta na obecne +moveFromLabel: 'Konto które przenosisz:' +showUpdates: Pokaż pop-up po aktualizacji Iceshrimp +swipeOnDesktop: Zezwól na przeciąganie w stylu mobilnym na desktopie +moveFromDescription: To utworzy alias twojego starego konta, w celu umożliwienia migracji + z tamtego konta na to. Zrób to ZANIM rozpoczniesz przenoszenie się z tamtego konta. + Proszę wpisz tag konta w formacie @osoba@serwer.com +migrationConfirm: "Czy jesteś absolutnie pewn* tego, że chcesz przenieść swoje konto + na {account}? Tego działania nie można odwrócić. Nieodwracalnie stracisz możliwość + normalnego korzystania z konta.\nUpewnij się, że to konto zostało ustawione jako + konto z którego się przenosisz." +noThankYou: Nie, dziękuję +addInstance: Dodaj serwer +renoteMute: Wycisz podbicia +renoteUnmute: Odcisz podbicia +flagSpeakAsCat: Mów jak kot +flagSpeakAsCatDescription: Twoje posty zostaną znya-izowane, gdy w trybie kota +selectInstance: Wybierz serwer +noInstances: Brak serwerów +keepOriginalUploadingDescription: Zapisuje oryginalne zdjęcie. Jeśli wyłączone, wersja + do wyświetlania w sieci zostanie wygenerowana podczas wysłania. +antennaInstancesDescription: Wymień jeden host serwera w każdym wierszu +regexpError: Błąd regularnego wyrażenia +regexpErrorDescription: 'Wystąpił błąd w regularnym wyrażeniu znajdującym się w linijce + {line} Twoich {tab} wyciszeń słownych:' +forwardReportIsAnonymous: Zamiast twojego konta, anonimowe konto systemowe będzie + wyświetlane jako zgłaszający na zdalnym serwerze. +breakFollowConfirm: Czy na pewno chcesz usunąć obserwującego? +instanceDefaultThemeDescription: Wpisz kod motywu w formacie obiektowym. +mutePeriod: Długość wyciszenia +tenMinutes: 10 minut +showLocalPosts: 'Pokaż lokalne wpisy w:' +socialTimeline: Społeczna oś czasu +homeTimeline: Główna oś czasu +reflectMayTakeTime: Może upłynąć trochę czasu, zanim pojawią się zmiany. +failedToFetchAccountInformation: Nie można uzyskać informacji o koncie +pushNotification: Powiadomienia push +subscribePushNotification: Włącz powiadomienia push +unsubscribePushNotification: Wyłącz powiadomienia push +pushNotificationAlreadySubscribed: Powiadomienia push są już włączone +pushNotificationNotSupported: Twoja przeglądarka lub serwer nie obsługuje powiadomień + push +sendPushNotificationReadMessage: Usuń powiadomienia push, gdy odpowiednie powiadomienia + lub wiadomości zostaną odczytane +sendPushNotificationReadMessageCaption: Powiadomienie zawierające tekst "{emptyPushNotificationMessage}" + zostanie wyświetlone przez krótką chwilę. Jeśli dotyczy, może to zwiększyć zużycie + baterii Twojego urządzenia. +defaultReaction: Domyślna reakcja emoji dla wychodzących i przychodzących wpisów +license: Licencja +indexPosts: Indeksuj wpisy +indexFrom: Indeksuj wpisy od ID +indexFromDescription: Zostaw puste dla indeksowania wszystkich wpisów +indexNotice: Indeksuję. Zapewne zajmie to chwilę, nie restartuj serwera przez co najmniej + godzinę. +customKaTeXMacro: Niestandardowe makra KaTeX +enableCustomKaTeXMacro: Włącz niestandardowe makra KaTeX +noteId: ID wpisu +hiddenTagsDescription: 'Wypisz tagi (bez #) hashtagów które masz zamiar ukryć z "Na + czasie" i "Eksploruj". Na ukryte hashtagi można dalej wejść innymi sposobami. Ta + lista nie ma wpływu na zablokowane instancje.' +proxyAccountDescription: Konto proxy jest kontem które w określonych sytuacjach zachowuje + się jak zdalny obserwujący. Na przykład, kiedy użytkownik dodaje zdalnego użytkownika + do listy, oraz żaden lokalny użytkownik nie obserwuje tego konta, aktywność owego + użytkownika nie zostanie dostarczona na oś czasu. W takim razie, użytkownika zaobserwuje + konto proxy. +objectStorageBaseUrlDesc: "URL stosowany jako odniesienie. Podaj URL twojego CDN, + albo proxy, jeśli używasz któregokolwiek.\nDla S3 użyj 'https://.s3.amazonaws.com', + a dla GCS i jego odpowiedników użyj 'https://storage.googleapis.com/', itd." +sendErrorReportsDescription: "Gdy ta opcja jest włączona, szczegółowe informacje o + błędach będą udostępnianie z Iceshrimp gdy wystąpi problem, pomagając w ulepszaniu + Iceshrimp.\nZawrze to informacje takie jak wersja twojego systemu operacyjnego, + przeglądarki, Twoja aktywność na Iceshrimp itd." +privateModeInfo: Gdy ta opcja jest włączona, tylko serwery z tej listy mogą federować + się z twoim serwerem. Wszystkie posty będą niewidoczne dla publiki. +oneHour: Godzina +oneDay: Dzień +oneWeek: Tydzień +recommendedInstances: Polecane serwery +recommendedInstancesDescription: Polecane serwery, mające pojawić się w odpowiedniej + osi czasu, oddzielane nowymi liniami. NIE dodawaj “https://”, TYLKO samą domenę. +rateLimitExceeded: Przekroczono ratelimit +cropImage: Kadruj zdjęcie +cropImageAsk: Czy chcesz skadrować to zdjęcie? +recentNHours: Ostatnie {n} godzin +noEmailServerWarning: Serwer email nie jest skonfigurowany. +thereIsUnresolvedAbuseReportWarning: Istnieją nierozwiązane zgłoszenia. +check: Sprawdź +driveCapOverrideLabel: Zmień pojemność dysku dla tego użytkownika +isSystemAccount: To konto jest tworzone i automatycznie obsługiwane przez system. + Nie moderuj, nie edytuj, nie usuwaj, ani w żaden inny sposób nie ingeruj w to konto, + bowiem może to uszkodzić twój serwer. +typeToConfirm: Wpisz {x} by potwierdzić +deleteAccount: Usuń konto +document: Dokumentacja +numberOfPageCache: Liczba zbuforowanych stron +numberOfPageCacheDescription: Zwiększenie tej liczby poprawi wygodę użytkowników, + ale spowoduje większe zużycie serwera, jak i pamięci. +fast: Szybka +sensitiveMediaDetection: Wykrywanie nieodpowiednich multimediów +remoteOnly: Tylko zdalne +activeEmailValidationDescription: Włącza ściślejszą walidację adresów e-mail, która + obejmuje sprawdzanie adresów jednorazowych oraz tego, czy rzeczywiście można się + z nim komunikować. Jeśli wyłączone, walidowany jest tylko format wiadomości e-mail. +shuffle: Losuj +showAds: Pokazuj reklamy +enterSendsMessage: Wciśnij Enter w komunikatorze, by wysłać wiadomość (domyślnie – + Ctrl + Enter) +adminCustomCssWarn: To ustawienie powinno być używane tylko pod warunkiem, że wiesz + za co ono odpowiada. Wpisanie niepoprawnych wartości może spowodować niepoprawne + działanie klientów KAŻDEGO użytkownika. Proszę upewnij się, że twój CSS działa poprawnie + poprzez przetestowanie go w ustawieniach twojego użytkownika. +customMOTD: Niestandardowe MOTD (wiadomości splash screen) +customMOTDDescription: Niestandardowe wiadomości dla MOTD (splash screen), oddzielane + nowymi liniami, mające pokazywać się za każdym razem gdy użytkownik ładuje/odświeża + stronę. +customSplashIcons: Niestandardowe ikony na splash screenie (URL-e) +customSplashIconsDescription: URL-e dla niestandardowych ikonych na splash screenie, + mające pokazywać się za każdym razem, gdy użytkownik ładuje/odświeża stronę, oddzielane + nowymi liniami. Upewnij się, że zdjęcia są na statycznych URL-ach, najlepiej o rozmiarze + 192x192. +caption: Auto opis +splash: Splash screen +updateAvailable: Może być dostępna aktualizacja! +logoImageUrl: URL grafiki loga +showAdminUpdates: Wskaż, że jest dostępna nowa wersja Iceshrimp (tylko dla adminów) +hiddenTags: Ukryte hashtagi +userSaysSomethingReason: '{name} powiedział* {reason}' +customKaTeXMacroDescription: 'Skonfiguruj makra, aby łatwo pisać wyrażenia matematyczne! + Notacja jest zgodna z definicjami poleceń LaTeXa i zapisywana jest jako \newcommand{\nazwa}{treść} + lub \newcommand{\nazwa}[numer argumentów]{treść}. Na przykład, \newcommand{\add}[2]{#1 + + #2} rozszerzy \add{3}{foo} do 3 + foo. Nawiasy klamrowe otaczające nazwę makra + mogą być zmienione na nawiasy okrągłe lub kwadratowe. Wpłynie to na nawiasy używane + dla argumentów. W każdym wierszu można zdefiniować jedno (i tylko jedno) makro i + nie można przerwać linii w środku definicji. Nieprawidłowe linie są po prostu ignorowane. + Obsługiwane są tylko proste funkcje podstawiania łańcuchów; nie można tu stosować + zaawansowanej składni, takiej jak warunkowe rozgałęzienia.' +secureModeInfo: W przypadku żądań z innych serwerów nie odsyłaj bez dowodu. +preferencesBackups: Kopie zapasowe ustawień +undeck: Opuść tablicę +reporter: Osoba zgłaszająca +instanceDefaultDarkTheme: Domyślny ciemny motyw serwera +lastCommunication: Ostatnie połączenie +emailRequiredForSignup: Wymagaj adresu email przy rejestracji +themeColor: Kolor znacznika serwera +instanceDefaultLightTheme: Domyślny jasny motyw serwera +enableEmojiReactions: Włącz reakcje emoji +showEmojisInReactionNotifications: Pokazuj emoji w powiadomieniach reakcyjnych +apps: Aplikacje +silenceThisInstance: Wycisz ten serwer +silencedInstances: Wyciszone serwery +deleted: Usunięte +editNote: Edytuj wpis +edited: 'Edytowano o {date} {time}' +silenced: Wyciszony +findOtherInstance: Znajdź inny serwer +userSaysSomethingReasonReply: '{name} odpowiedział na wpis zawierający {reason}' +userSaysSomethingReasonRenote: '{name} podbił post zawierający {reason}' +signupsDisabled: Rejestracja na tym serwerze jest obecnie zamknięta, ale zawsze możesz + zarejestrować się na innym serwerze! Jeśli masz kod zaproszenia na ten serwer, wpisz + go poniżej. +userSaysSomethingReasonQuote: '{name} zacytował wpis zawierający {reason}' +silencedInstancesDescription: Wypisz nazwy hostów serwerów, które chcesz wyciszyć. + Konta na wymienionych serwerach są traktowane jako "Wyciszone", mogą jedynie wysyłać + prośby obserwacji i nie mogą oznaczać we wzmiankach profili lokalnych jeśli nie + są obserwowane. To nie będzie miało wpływu na zablokowane serwery. +cannotUploadBecauseExceedsFileSizeLimit: Ten plik nie mógł być przesłany, ponieważ + jego wielkość przekracza dozwolony limit. +sendModMail: Wyślij Powiadomienie Moderacyjne +searchPlaceholder: Przeszukaj Fediwersum +jumpToPrevious: Przejdź do poprzedniej sekcji +listsDesc: Listy umożliwiają tworzenie osi czasu z określonymi użytkownikami. Dostęp + do nich można uzyskać na stronie osi czasu. +accessibility: Dostępność +selectChannel: Wybierz kanał +antennasDesc: "Anteny wyświetlają nowe posty spełniające ustawione przez Ciebie kryteria!\n + Dostęp do nich można uzyskać ze strony osi czasu." +expandOnNoteClick: Otwórz post przy kliknięciu +expandOnNoteClickDesc: Jeśli opcja ta jest wyłączona, nadal będzie można otwierać + posty w menu po kliknięciu prawym przyciskiem myszy lub klikając znacznik czasowy. +channelFederationWarn: Kanały nie są jeszcze federowane z innymi serwerami +newer: nowsze +older: starsze +cw: Ostrzeżenie zawartości +removeReaction: Usuń reakcję +audio: Audio +cwStyle: Wygląd CW +_cwStyle: + classic: Klasyczny (taki jak w Misskey/Foundkey) + alternative: Alternatywny (Taki jak w Firefish) + modern: Nowoczesny +hideFromHome: Ukryj z głównej osi czasu +expandAllCws: Pokaż zawartość wszystkich odpowiedzi +collapseAllCws: Ukryj zawartość wszystkich odpowiedzi +cannotChangeScopeWhenEditing: Nie możesz zmienić widoczności posta podczas jego edycji +xl: XL +clipsDesc: Klipy to udostępnialne, kategoryzowalne zakładki. Możesz stworzyć klipy + poprzez menu pojedyńczych postów. +image: Zdjęcie +preventAiLearningDescription: Zażądaj, by modele językowe AI osób trzecich nie uczyły + się na treściach które wysyłasz, takich jak posty czy zdjęcia. +silencedWarning: Ta strona się pokazuje, ponieważ wymienieni użytkownicy są z serwerów + które Twój admin wyciszył, więc mogą być oni potencjalnymi spamerami. +verifiedLink: Zweryfikowany link +alwaysExpandCws: Zawsze rozwijaj posty z CW +video: Film +swipeOnMobile: Zezwól na przesuwanie palcem między stronami +preventAiLearning: Nie zezwalaj na zbieranie danych przez boty AI +noGraze: Wyłącz rozszerzenie "Graze for Mastodon", ponieważ zakłóca ono pracę z Iceshrimp. +isBot: To konto jest botem +isLocked: To konto zatwierdza obserwujących manualnie +isModerator: Moderator +isAdmin: Administrator +isPatron: Patron Iceshrimp +reactionPickerSkinTone: Preferowany ton skóry emoji +enableServerMachineStats: Włącz statystyki sprzętowe serwera +enableIdenticonGeneration: Włącz generację identikonów +showPopup: Powiadom użytkowników popupem +showWithSparkles: Pokaż z błyskotkami +youHaveUnreadAnnouncements: Masz nieprzeczytanie ogłoszenia +donationLink: Link do strony wspierania +neverShow: Nie pokazuj ponownie +remindMeLater: Może później +removeQuote: Usuń cytat +removeRecipient: Usuń odbiorcę +removeMember: Usuń członka +openInMainColumn: Otwórz w głównej kolumnie +searchNotLoggedIn_1: Musisz być zalogowany, by móc używać wyszukiwarki postów. +searchNotLoggedIn_2: Natomiast, dalej możesz szukać użytkowników, oraz postów przy + użyciu hashtagów. +_filters: + fromUser: Od użytkownika + withFile: Z plikiem + fromDomain: Od domeny + notesBefore: Posty przed + notesAfter: Posty po + followingOnly: Tylko od obserwowanych + followersOnly: Tylko od obserwujących +_dialog: + charactersExceeded: 'Limit znaków przekroczony! Obecnie: {current}/Limit: {max}' + charactersBelow: 'Za mało znaków! Obecnie: {current}/Limit: {max}' +_feeds: + rss: RSS + atom: Atom + copyFeed: Kopiuj kanał + jsonFeed: Kanał JSON +antennaTimelineHint: Anteny wyświetlają pasujące posty w kolejności w której zostały + one odebrane, która nie jest koniecznie chronologiczna. +alt: ALT diff --git a/locales/pt-BR.yml b/locales/pt-BR.yml new file mode 100644 index 0000000..d6ea0cb --- /dev/null +++ b/locales/pt-BR.yml @@ -0,0 +1,178 @@ +username: Nome de usuário +ok: OK +_lang_: Inglês +headlineIceshrimp: Uma plataforma de mídia social descentralizada e de código + aberto que é gratuita para sempre! 🚀 +search: Pesquisar +gotIt: Entendi! +introIceshrimp: Bem vinde! Iceshrimp é uma plataforma de mídia social + descentralizada e de código aberto que é gratuita para sempre! 🚀 +searchPlaceholder: Pesquise no Fediverso +notifications: Notificações +password: Senha +forgotPassword: Esqueci a senha +cancel: Cancelar +noThankYou: Não, obrigade +save: Salvar +enterUsername: Insira nome de usuário +cw: Aviso de conteúdo +driveFileDeleteConfirm: Tem a certeza de que pretende apagar o arquivo "{name}"? + O arquivo será removido de todas as mensagens que o contenham como anexo. +deleteAndEdit: Deletar e editar +import: Importar +exportRequested: Você pediu uma exportação. Isso pode demorar um pouco. Será + adicionado ao seu Drive quando for completo. +note: Postar +notes: Postagens +deleteAndEditConfirm: Você tem certeza que quer deletar esse post e edita-lo? + Você vai perder todas as reações, impulsionamentos e respostas dele. +showLess: Fechar +importRequested: Você requisitou uma importação. Isso pode demorar um pouco. +listsDesc: Listas deixam você criar linhas do tempo com usuários específicos. + Elas podem ser acessadas pela página de linhas do tempo. +edited: 'Editado às {date} {time}' +sendMessage: Enviar uma mensagem +older: antigo +createList: Criar lista +loadMore: Carregar mais +mentions: Menções +importAndExport: Importar/Exportar Dados +files: Arquivos +lists: Listas +manageLists: Gerenciar listas +error: Erro +somethingHappened: Ocorreu um erro +retry: Tentar novamente +renotedBy: Impulsionado por {user} +noNotes: Nenhum post +noNotifications: Nenhuma notificação +instance: Servidor +settings: Configurações +basicSettings: Configurações Básicas +otherSettings: Outras Configurações +openInWindow: Abrir em janela +profile: Perfil +noAccountDescription: Esse usuário ainda não escreveu sua bio. +login: Entrar +loggingIn: Entrando +logout: Sair +signup: Criar conta +uploading: Enviando… +users: Usuários +addUser: Adicione um usuário +addInstance: Adicionar um servidor +cantFavorite: Não foi possível adicionar aos marcadores. +pin: Fixar no perfil +unpin: Desfixar do perfil +copyContent: Copiar conteúdos +copyLink: Copiar link +delete: Deletar +deleted: Deletado +editNote: Editar anotação +addToList: Adicionar a lista +copyUsername: Copiar nome de usuário +searchUser: Procurar por um usuário +reply: Responder +jumpToPrevious: Pular para o anterior +showMore: Mostrar mais +newer: novo +youGotNewFollower: seguiu você +mention: Mencionar +directNotes: Mensagens diretas +export: Exportar +unfollowConfirm: Você tem certez que deseja para de seguir {name}? +noLists: Você não possui nenhuma lista +following: Seguindo +followers: Seguidores +followsYou: Segue você +fetchingAsApObject: Buscando do Fediverse +timeline: Linha do tempo +favorite: Adicionar aos marcadores +favorites: Marcadores +unfavorite: Remover dos marcadores +favorited: Adicionado aos marcadores. +alreadyFavorited: Já foi adicionado aos marcadores. +download: Download +pageLoadError: Ocorreu um erro ao carregar a página. +pageLoadErrorDescription: Isso normalmente é causado por erros de rede ou pelo + cache do navegador. Tente limpar o cache ou esperar um pouquinho e recarregar + a página. +serverIsDead: Esse servidos não está respondendo. Por favor espere um pouco e + tente novamente. +youShouldUpgradeClient: Para visualizar essa página, favor reiniciar para + atualizar seu cliente. +enterListName: Insira um nome para a lista +privacy: Privacidade +defaultNoteVisibility: Visibilidade padrão +makeFollowManuallyApprove: Pedidos de seguimento precisam de aprovação +follow: Seguir +followRequest: Seguir +followRequests: Pedidos de seguimento +unfollow: Parar de seguir +followRequestPending: Pedido de seguimento pendente +enterEmoji: Insira um emoji +markAsSensitive: Marcar como sensível +unmarkAsSensitive: Desmarcar como sensível +processing: Processando… +renoted: Impulsionado. +blockConfirm: Tem certeza de que deseja bloquear esta conta? +unsuspendConfirm: Tem certeza de que deseja remover a suspensão desta conta? +flagAsBotDescription: Habilite esta opção se esta conta for controlada por um + programa. Se ativado, ela funcionará como um sinalizador para outros + desenvolvedores, evitando intermináveis cadeias de interação com outros bots e + ajustando os sistemas internos do Iceshrimp para tratar esta conta como um + bot. +sendErrorReportsDescription: "Quando ativado, informações de erro detalhadas serão + compartilhadas com o Iceshrimp quando ocorrer um problema, ajudando a melhorar a + qualidade do Iceshrimp.\n Isso incluirá informações como a versão do seu sistema + operacional, qual navegador você está usando, sua atividade no Iceshrimp, etc." +general: Geral +federation: Federação +receiveFollowRequest: Pedido de seguidor recebido +followRequestAccepted: Pedido de seguidor aceito +pinned: Fixar no perfil +reaction: Reações +removeReaction: Remover sua reação +enableEmojiReactions: Ativar reações de emoji +showEmojisInReactionNotifications: Mostrar emojis em notificações de reação +reactionSetting: Reações a serem mostradas no seletor de reações +reactionSettingDescription2: Arraste para organizar, clique para excluir, aperte + "+" para adicionar. +attachCancel: Remover anexo +enterFileName: Insira nome de arquivo +suspend: Suspender +unsuspend: Remover suspensão +unblockConfirm: Tem certeza de que deseja desbloquear esta conta? +suspendConfirm: Tem certeza de que deseja suspender esta conta? +editWidgetsExit: Feito +sensitive: Sensível +block: Bloquear +unblock: Desbloquear +emoji: Emoji +emojis: Emoji +wallpaper: Plano de fundo +recipient: Destinatário(s) +annotation: Comentários +instances: Servidores +charts: Gráficos +operations: Operações +software: Programa +version: Versão +metadata: Metadados +network: Rede +disk: Disco +statistics: Estatísticas +done: Feito +default: Padrão +blocked: '' +migrationConfirm: "Você tem certeza absoluta de que deseja migrar sua conta para {account}? + Depois de fazer isso, você não poderá reverter e não poderá usar sua conta normalmente + novamente.\nAlém disso, certifique-se de definir esta conta atual como a conta da + qual você está migrando." +_biteControls: + nobody: Ninguém + followers: Seguidores +_profile: + pronouns: Pronomes +onlyOneFileCanBeAttached: Você só pode anexar um arquivo a uma mensagem +joinOrCreateGroup: Seja convidado para um grupo ou crie o seu próprio. diff --git a/locales/pt-PT.yml b/locales/pt-PT.yml new file mode 100644 index 0000000..59f12b8 --- /dev/null +++ b/locales/pt-PT.yml @@ -0,0 +1,732 @@ +--- +_lang_: "Português" +headlineIceshrimp: "Uma rede ligada por notas" +introIceshrimp: "Bem-vindo! Iceshrimp é um serviço de microblogue descentralizado de código aberto.\nCria \"notas\" e partilha o que te ocorre com todos à tua volta. 📡\nCom \"reações\" podes também expressar logo o que sentes às notas de todos. 👍\nExploremos um novo mundo! 🚀" +monthAndDay: "{day}/{month}" +search: "Buscar" +notifications: "Notificações" +username: "Nome de usuário" +password: "Senha" +forgotPassword: "Esqueci a senha" +fetchingAsApObject: "Buscando no Fediverso" +ok: "OK" +gotIt: "Entendi" +cancel: "Cancelar" +enterUsername: "Digite o nome de usuário" +renotedBy: "Repostado por {user}" +noNotes: "Sem posts" +noNotifications: "Sem notificações" +instance: "Instância" +settings: "Configurações" +basicSettings: "Configurações básicas" +otherSettings: "Outras configurações" +openInWindow: "Abrir numa janela" +profile: "Perfil" +timeline: "Timeline" +noAccountDescription: "Este usuário não tem uma descrição." +login: "Iniciar sessão" +loggingIn: "Iniciando sessão…" +logout: "Sair" +signup: "Registrar-se" +uploading: "Enviando…" +save: "Guardar" +users: "Usuários" +addUser: "Adicionar usuário" +favorite: "Favoritar" +favorites: "Favoritar" +unfavorite: "Remover dos favoritos" +favorited: "Adicionado aos favoritos." +alreadyFavorited: "Já adicionado aos favoritos." +cantFavorite: "Não foi possível adicionar aos favoritos." +pin: "Afixar no perfil" +unpin: "Desafixar do perfil" +copyContent: "Copiar conteúdos" +copyLink: "Copiar hiperligação" +delete: "Eliminar" +deleteAndEdit: "Eliminar e editar" +deleteAndEditConfirm: "Tens a certeza que pretendes eliminar esta nota e editá-la? Irás perder todas as suas reações, renotas e respostas." +addToList: "Adicionar a lista" +sendMessage: "Enviar uma mensagem" +copyUsername: "Copiar nome de utilizador" +searchUser: "Pesquisar utilizador" +reply: "Responder" +loadMore: "Carregar mais" +showMore: "Ver mais" +showLess: "Fechar" +youGotNewFollower: "Você tem um novo seguidor" +receiveFollowRequest: "Pedido de seguimento recebido" +followRequestAccepted: "Pedido de seguir aceito" +mention: "Menção" +mentions: "Menções" +directNotes: "Notas diretas" +importAndExport: "Importar/Exportar" +import: "Importar" +export: "Exportar" +files: "Ficheiros" +download: "Descarregar" +driveFileDeleteConfirm: "Tens a certeza que pretendes apagar o ficheiro \"{name}\"? As notas que tenham este ficheiro anexado serão também apagadas." +unfollowConfirm: "Tens a certeza que queres deixar de seguir {name}?" +exportRequested: "Pediste uma exportação. Este processo pode demorar algum tempo. Será adicionado à tua Drive após a conclusão do processo." +importRequested: "Pediste uma importação. Este processo pode demorar algum tempo." +lists: "Listas" +noLists: "Não tens nenhuma lista" +note: "Post" +notes: "Posts" +following: "Seguindo" +followers: "Seguidores" +followsYou: "Segue-te" +createList: "Criar lista" +manageLists: "Gerir listas" +error: "Erro" +somethingHappened: "Ocorreu um erro" +retry: "Tentar novamente" +pageLoadError: "Ocorreu um erro ao carregar a página." +pageLoadErrorDescription: "Isto é normalmente causado por erros de rede ou pela cache do browser. Experimenta limpar a cache e tenta novamente após algum tempo." +serverIsDead: "O servidor não está respondendo. Por favor espere um pouco e tente novamente." +youShouldUpgradeClient: "Para visualizar essa página, por favor recarregue-a para atualizar seu cliente." +enterListName: "Insira um nome para a lista" +privacy: "Privacidade" +makeFollowManuallyApprove: "Pedidos de seguimento precisam ser aprovados" +defaultNoteVisibility: "Visibilidade padrão" +follow: "Seguindo" +followRequest: "Mandar pedido de seguimento" +followRequests: "Pedidos de seguimento" +unfollow: "Deixar de seguir" +followRequestPending: "Pedido de seguimento pendente" +enterEmoji: "Inserir emoji" +renote: "Repostar" +unrenote: "Desmarcar" +renoted: "Repostado" +cantRenote: "Não pode repostar" +cantReRenote: "Não pode repostar este repost" +quote: "Citar" +pinnedNote: "Post fixado" +pinned: "Afixar no perfil" +you: "Você" +clickToShow: "Clique para ver" +sensitive: "Conteúdo sensível" +add: "Adicionar" +reaction: "Reações" +reactionSetting: "Quais reações a mostrar no selecionador de reações" +reactionSettingDescription2: "Arraste para reordenar, clique para excluir, pressione + para adicionar." +rememberNoteVisibility: "Lembrar das configurações de visibilidade de notas" +attachCancel: "Remover anexo" +markAsSensitive: "Marcar como sensível" +unmarkAsSensitive: "Desmarcar como sensível" +enterFileName: "Digite o nome do ficheiro" +mute: "Silenciar" +unmute: "Dessilenciar" +block: "Bloquear" +unblock: "Desbloquear" +suspend: "Suspender" +unsuspend: "Cancelar suspensão" +blockConfirm: "Tem certeza que gostaria de bloquear essa conta?" +unblockConfirm: "Tem certeza que gostaria de desbloquear essa conta?" +suspendConfirm: "Tem certeza que gostaria de suspender essa conta?" +unsuspendConfirm: "Tem certeza que gostaria de cancelar a suspensão dessa conta?" +selectList: "Escolhe uma lista" +selectAntenna: "Escolhe uma antena" +selectWidget: "Escolhe um widget" +editWidgets: "Editar widgets" +editWidgetsExit: "Pronto" +customEmojis: "Emoji personalizado" +emoji: "Emoji" +emojis: "Emojis" +emojiName: "Nome do Emoji" +emojiUrl: "URL do Emoji" +addEmoji: "Adicionar um Emoji" +settingGuide: "Guia de configuração" +cacheRemoteFiles: "Memória transitória de arquivos remotos" +cacheRemoteFilesDescription: "Se você desabilitar essa configuração, os arquivos remotos não serão armazenados em memória transitória e serão vinculados diretamente. Economiza o armazenamento do servidor, mas não gera miniaturas, o que aumenta o tráfego." +flagAsBot: "Marcar conta como robô" +flagAsBotDescription: "Se esta conta for operada por um programa, ative este sinalizador. Quando ativado, serve como um sinalizador para evitar o encadeamento de reações para outros programadores, e o manuseio do sistema do Iceshrimp é adequado para ‘bots’." +flagAsCat: "Marcar conta como gato" +flagAsCatDescription: "Ative essa opção para marcar essa conta como gato." +flagShowTimelineReplies: "Mostrar respostas na linha de tempo" +flagShowTimelineRepliesDescription: "Quando ativado, a linha do tempo mostra as respostas às outras notas do utilizador, além da nota do utilizador." +autoAcceptFollowed: "Aprove automaticamente os seguidores dos seguintes utilizadores" +addAccount: "Adicionar Conta" +loginFailed: "Não consegui logar" +showOnRemote: "Exibir remotamente" +general: "Geral" +wallpaper: "Papel de parede" +setWallpaper: "Definir papel de parede" +removeWallpaper: "Remover papel de parede" +searchWith: "Buscar: {q}" +youHaveNoLists: "Não tem nenhuma lista" +followConfirm: "Tem certeza que quer deixar de seguir {name}?" +proxyAccount: "Conta proxy" +proxyAccountDescription: "Uma conta proxy é uma conta que atua como seguidora remota para utilizadores sob determinadas condições. Por exemplo, quando um utilizador lista um utilizador remoto, a atividade não será entregue à instância, a menos que alguém esteja seguindo o utilizador listado, portanto, a conta proxy deve seguir." +host: "hospedeiro" +selectUser: "Selecionar utilizador" +recipient: "Morada" +annotation: "Anotação" +federation: "União" +instances: "Instância" +registeredAt: "Registrado em" +latestRequestSentAt: "Enviar a solicitação mais recente" +latestRequestReceivedAt: "Recebeu a última solicitação" +latestStatus: "Status mais recente" +storageUsage: "Uso de armazenamento" +charts: "gráfico" +perHour: "por hora" +perDay: "por dia" +stopActivityDelivery: "Parar a entrega de atividades" +blockThisInstance: "Bloquear esta instância" +operations: "operar" +software: "Programas" +version: "versão" +metadata: "Metadados" +monitor: "monitor" +jobQueue: "Fila de trabalhos" +cpuAndMemory: "CPU e memória" +network: "rede" +disk: "disco" +instanceInfo: "Informações da instância" +statistics: "Estatisticas" +clearQueue: "Limpar a fila" +clearQueueConfirmTitle: "Quer limpar a fila?" +clearQueueConfirmText: "Postagens não entregues não serão mais entregues. Normalmente você não precisa fazer isso." +clearCachedFiles: "Limpar memória transitória" +clearCachedFilesConfirm: "Tem certeza de que deseja excluir todos os arquivos remotos armazenados em memória transitória?" +blockedInstances: "Instância bloqueada" +blockedInstancesDescription: "Defina os anfitriões das instâncias que deseja bloquear, separados por quebras de linha. Uma instância bloqueada não poderá interagir com esta instância." +muteAndBlock: "Silenciar e bloquear" +mutedUsers: "Silenciar utilizador" +blockedUsers: "Utilizadores bloqueados" +noUsers: "Sem usuários" +editProfile: "Editar Perfil" +noteDeleteConfirm: "Deseja excluir esta nota?" +pinLimitExceeded: "Não consigo mais fixar" +intro: "A instalação do Iceshrimp está completa! Crie uma conta de administrador." +done: "Concluído" +processing: "Em Progresso" +preview: "Pré-visualizar" +default: "Padrão" +noCustomEmojis: "Não há emojis" +noJobs: "Sem trabalho" +federating: "federar" +blocked: "Bloqueado" +suspended: "Cancelar subscrição" +all: "Todos" +subscribing: "Subscrito" +publishing: "Executando" +notResponding: "Sem resposta" +instanceFollowing: "Seguir a instância" +instanceFollowers: "Seguidores da instância" +instanceUsers: "Utilizador da instância" +changePassword: "Mudar senha" +security: "Segurança" +retypedNotMatch: "As entradas não coincidem." +currentPassword: "Palavra-passe atual" +newPassword: "Nova palavra-passe" +newPasswordRetype: "Nova senha (redigite)" +attachFile: "Anexar arquivo" +more: "Mais!" +featured: "Destaques" +usernameOrUserId: "Nome de utilizador ou ID de utilizador" +noSuchUser: "Utilizador não encontrado" +lookup: "Buscando" +announcements: "Notícia" +imageUrl: "URL da imagem" +remove: "Eliminar" +removed: "Foi deletado" +removeAreYouSure: "Deseja excluir \"{x}\"?" +deleteAreYouSure: "Deseja excluir \"{x}\"?" +resetAreYouSure: "Redefinir agora?" +saved: "Salvo" +messaging: "Chat" +upload: "Enviando" +keepOriginalUploading: "Manter a imagem original" +keepOriginalUploadingDescription: "Mantenha a versão original ao carregar a imagem. Quando desligado, a imagem para publicação na web será gerada no navegador no momento do upload." +fromDrive: "\nDa unidade" +fromUrl: "Da URL" +uploadFromUrl: "Carregamento de URL" +uploadFromUrlDescription: "URL do arquivo que você deseja enviar" +uploadFromUrlRequested: "Upload solicitado" +uploadFromUrlMayTakeTime: "Pode levar algum tempo para que o upload seja concluído." +explore: "Explorar" +messageRead: "Lida" +noMoreHistory: "Sem mais história" +startMessaging: "Iniciar conversação" +nUsersRead: "{n} Pessoas leem" +agreeTo: "Eu concordo com {0}" +tos: "Termos de serviço" +start: "começar" +home: "casa" +remoteUserCaution: "As informações estão incompletas porque é um utilizador remoto." +activity: "atividade" +images: "imagem" +birthday: "aniversário" +yearsOld: "{age} anos" +registeredDate: "Data de registro" +location: "Lugar, colocar" +theme: "tema" +themeForLightMode: "Temas usados ​​no modo de luz" +themeForDarkMode: "Temas usados ​​no modo escuro" +light: "Claro" +dark: "Escuro" +lightThemes: "Tema claro" +darkThemes: "Tema escuro" +syncDeviceDarkMode: "Sincronize com o modo escuro do dispositivo" +drive: "Unidades" +fileName: "Nome do Ficheiro" +selectFile: "Selecione os arquivos" +selectFiles: "Selecione os arquivos" +selectFolder: "Selecionar uma pasta" +selectFolders: "Selecionar uma pasta" +renameFile: "Renomear ficheiro" +folderName: "Nome da pasta" +createFolder: "Criar pasta" +renameFolder: "Renomear Pasta" +deleteFolder: "Eliminar Pasta" +addFile: "Adicionar arquivo" +emptyDrive: "A unidade está vazia" +emptyFolder: "A pasta está vazia" +unableToDelete: "Não é possível eliminar" +inputNewFileName: "Por favor, digite um novo nome para a pasta!" +inputNewDescription: "Insira uma nova legenda" +inputNewFolderName: "Por favor, digite um novo nome para a pasta!" +circularReferenceFolder: "A pasta de destino é uma subpasta da pasta que você deseja mover." +hasChildFilesOrFolders: "Esta pasta não está vazia e não pode ser excluída." +copyUrl: "Copiar URL" +rename: "Renomear" +avatar: "Avatar" +banner: "Capa" +nsfw: "Conteúdo sensível" +whenServerDisconnected: "Quando a conexão com o servidor é perdida" +disconnectedFromServer: "Desconectado do servidor" +reload: "Recarregar" +doNothing: "Nenhuma ação adicional" +reloadConfirm: "Quer recarregar?" +watch: "ver" +unwatch: "Não observar" +accept: "Aceitar" +reject: "Rejeitar" +normal: "Normal" +instanceName: "Nome da instância" +instanceDescription: "Descrição da instância" +maintainerName: "Nome do administrador" +maintainerEmail: "E-mail do Administrador:" +tosUrl: "URL dos Termos de Uso" +thisYear: "Este ano" +thisMonth: "Este mês" +today: "Hoje" +dayX: " Dia {day}" +monthX: "mês de {month}" +yearX: "Ano {year}" +pages: "Páginas" +integration: "Integração" +connectService: "Conectar" +disconnectService: "Desconectar" +enableLocalTimeline: "Ativar linha do tempo local" +enableGlobalTimeline: "Ativar linha do tempo global" +disablingTimelinesInfo: "Se você desabilitar essas linhas do tempo, administradores e moderadores ainda poderão usá-las por conveniência." +registration: "Registar" +enableRegistration: "Permitir que qualquer pessoa se registre" +invite: "Convidar" +driveCapacityPerLocalAccount: "Capacidade da unidade por utilizador local" +driveCapacityPerRemoteAccount: "Capacidade da unidade por utilizador remoto" +inMb: "Em ‘megabytes’" +iconUrl: "URL da imagem do ícone (favicon, etc.)" +bannerUrl: "URL da imagem do ‘banner’" +backgroundImageUrl: "URL da imagem de fundo" +basicInfo: "Informações básicas" +pinnedUsers: "Utilizador fixado" +pinnedUsersDescription: "Descreva os utilizadores que você deseja fixar na página \"Localizar\", etc., separados por quebras de linha." +pinnedPages: "Página fixada" +pinnedPagesDescription: "Descreva o caminho da página que você deseja fixar na página superior da instância, separada por quebras de linha." +pinnedClipId: "ID do clipe a ser fixado" +pinnedNotes: "Post fixado" +hcaptcha: "hCaptcha" +enableHcaptcha: "Ativar hCaptcha" +hcaptchaSiteKey: "Chave do sítio ‘web’" +hcaptchaSecretKey: "Chave secreta" +recaptcha: "reCAPTCHA" +enableRecaptcha: "Habilitar reCAPTCHA" +recaptchaSiteKey: "Chave do sítio ‘web’" +recaptchaSecretKey: "Chave secreta" +avoidMultiCaptchaConfirm: "O uso de vários captchas pode causar interferência. Deseja desativar outros captchas? Você também pode cancelar e deixar vários captchas ativados." +antennas: "Antenas" +manageAntennas: "Gestão de antena" +name: "Nome" +antennaSource: "Origem de entrada" +antennaKeywords: "Palavras-chave recebidas" +antennaExcludeKeywords: "Palavras-chave negativas" +antennaKeywordsDescription: "Se você separá-lo com um espaço, será uma especificação AND, e se você separá-lo com uma quebra de linha, será uma especificação OR." +notifyAntenna: "Notificar novas notas" +withFileAntenna: "Apenas notas com arquivos anexados" +enableServiceworker: "Ative as notificações push para o seu navegador" +antennaUsersDescription: "Especificar nomes de utilizador separados por quebras de linha" +caseSensitive: "Maiúsculas e minúsculas" +withReplies: "Incluindo resposta" +connectedTo: "Você está conectado à seguinte conta" +notesAndReplies: "Publicações e respostas" +withFiles: "Com arquivo" +silence: "Silenciado" +silenceConfirm: "Quer silenciar?" +unsilence: "Liberar silenciar" +unsilenceConfirm: "Quer liberar o silêncio?" +popularUsers: "Utilizadores populares" +recentlyUpdatedUsers: "Utilizadores postados recentemente" +recentlyRegisteredUsers: "Utilizadores registrados recentemente" +recentlyDiscoveredUsers: "Utilizadores descobertos recentemente" +exploreUsersCount: "Há um utilizador de {count}" +exploreFediverse: "Explorar Fediverse" +popularTags: "Tags populares" +userList: "Listas" +about: "Informações" +aboutIceshrimp: "Sobre Iceshrimp" +administrator: "Administrador" +token: "Símbolo" +twoStepAuthentication: "Verificação em duas etapas" +moderator: "Moderador" +nUsersMentioned: "Postado por {n} pessoas" +securityKey: "Chave de segurança" +securityKeyName: "Nome chave" +registerSecurityKey: "Registre a chave de segurança" +lastUsed: "Último uso" +unregister: "Cancelar registro" +passwordLessLogin: "Entrar sem senha" +resetPassword: "Redefinir senha" +newPasswordIs: "A nova senha é \"{password}\"" +reduceUiAnimation: "Reduzir a animação da ‘interface’ do utilizador" +share: "Compartilhar" +notFound: "Não encontrado" +notFoundDescription: "Não havia página correspondente ao URL especificado." +uploadFolder: "Destino de ‘upload’ padrão" +cacheClear: "Excluir memória transitória" +markAsReadAllNotifications: "Marcar todas as notificações como lidas" +markAsReadAllUnreadNotes: "Marcar todas as postagens como lidas" +markAsReadAllTalkMessages: "Marcar todas as conversas como lidas" +help: "Ajuda" +inputMessageHere: "Escrever mensagem aqui" +close: "Fechar" +group: "Grupos" +groups: "Grupos" +createGroup: "Criar grupo" +ownedGroups: "Grupo próprio" +invites: "Convidar" +invitations: "Convidar" +tags: "Etiquetas" +docSource: "Fonte deste documento" +createAccount: "Criar conta" +existingAccount: "Contas existentes" +regenerate: "Gerar novamente" +fontSize: "Tamanho do texto" +noFollowRequests: "Não há aplicação de acompanhamento" +openImageInNewTab: "Abrir a imagem numa nova aba" +dashboard: "Painel de controle" +local: "Local" +remote: "Remoto" +total: "Total" +weekOverWeekChanges: "Em comparação com a semana anterior" +dayOverDayChanges: "Dia anterior" +appearance: "Aparência" +clientSettings: "Configurações do cliente" +accountSettings: "Configurações da conta" +promotion: "Promoção" +promote: "Promover" +numberOfDays: "Dias" +hideThisNote: "Ocultar esta nota" +showFeaturedNotesInTimeline: "Mostrar notas recomendadas na linha do tempo" +objectStorage: "Armazenamento de objetos" +useObjectStorage: "Usar armazenamento de objetos" +objectStorageBaseUrl: "URL base" +objectStorageBaseUrlDesc: "O URL usado para referência. Se você estiver usando um CDN ou Proxy, seu URL, S3:'https: // .s3.amazonaws.com', GCS, etc .:'https://storage.googleapis.com/ ' ." +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "Especifique o nome do bucket do serviço a ser usado." +objectStoragePrefix: "Prefixo" +objectStoragePrefixDesc: "Ele é armazenado neste diretório de prefixo." +objectStorageEndpoint: "Ponto final" +objectStorageEndpointDesc: "Especifique vazio para S3, caso contrário, especifique o ponto final para cada serviço. Especifique como''ou': '." +objectStorageRegion: "Região" +objectStorageRegionDesc: "Especifique uma região como 'xx-east-1'. Caso seu serviço não tenha o conceito de região, ele deve estar vazio ou 'us-east-1'." +objectStorageUseSSL: "Usar SSL" +objectStorageUseSSLDesc: "Desative-o se não quiser usar https para conexões de API" +objectStorageUseProxy: "Usar proxy" +objectStorageUseProxyDesc: "Se você não usa proxy para conexão de API, desative-o." +objectStorageSetPublicRead: "Definir 'public-read' ao fazer o upload" +serverLogs: "Registro do servidor" +deleteAll: "Apagar Tudo" +showFixedPostForm: "Exibir o formulário de postagem na parte superior da linha do tempo" +newNoteRecived: "Nova nota recebida" +sounds: "Sons" +listen: "Ouvir" +none: "Nenhum" +showInPage: "Ver na página" +popout: "Sair" +volume: "Volume" +masterVolume: "volume principal" +details: "Detalhes" +output: "Resultado" +smtpHost: "hospedeiro" +smtpUser: "Nome de usuário" +smtpPass: "Senha" +clearCache: "Limpar memória transitória" +info: "Informações" +user: "Usuários" +searchByGoogle: "Buscar" +file: "Ficheiros" +_email: + _follow: + title: "Você tem um novo seguidor" +_mfm: + mention: "Menção" + quote: "Citar" + emoji: "Emoji personalizado" + search: "Buscar" +_theme: + keys: + mention: "Menção" + renote: "Repostar" +_sfx: + note: "Posts" + notification: "Notificações" + chat: "Chat" +_widgets: + notifications: "Notificações" + timeline: "Timeline" + activity: "atividade" + federation: "União" + jobQueue: "Fila de trabalhos" +_cw: + show: "Carregar mais" +_visibility: + home: "casa" + followers: "Seguidores" +_profile: + name: "Nome" + username: "Nome de usuário" +_exportOrImport: + followingList: "Seguindo" + muteList: "Silenciar" + blockingList: "Bloquear" + userLists: "Listas" +_charts: + federation: "União" +_timelines: + home: "casa" +_pages: + blocks: + image: "imagem" + _button: + _action: + _pushEvent: + event: "Nome do evento" + message: "Mostrar mensagem quando ativado" + variable: "Variável a mandar" + no-variable: "Nenhum" + callAiScript: "Invocar AiScript" + _callAiScript: + functionName: "Nome da função" + radioButton: "Escolha" + _radioButton: + values: "Lista de escolhas separadas por quebras de texto" + script: + categories: + logical: "Operação lógica" + operation: "Cálculos" + comparison: "Comparação" + list: "Listas" + blocks: + _strReplace: + arg2: "Texto que irá ser substituído" + arg3: "Substituir com" + strReverse: "Virar texto" + join: "Sequência de texto" + _join: + arg1: "Listas" + arg2: "Separador" + add: "Somar" + _add: + arg1: "A" + arg2: "B" + subtract: "Subtrair" + _subtract: + arg1: "A" + arg2: "B" + multiply: "Multiplicar" + _multiply: + arg1: "A" + arg2: "B" + divide: "Dividir" + _divide: + arg1: "A" + arg2: "B" + mod: "O resto de" + _mod: + arg1: "A" + arg2: "B" + round: "Arredondar decimal" + _round: + arg1: "Numérico" + eq: "A e B são iguais" + _eq: + arg1: "A" + arg2: "B" + notEq: "A e B são diferentes" + _notEq: + arg1: "A" + arg2: "B" + and: "A e B" + _and: + arg1: "A" + arg2: "B" + or: "A OU B" + _or: + arg1: "A" + arg2: "B" + lt: "< A é menor do que B" + _lt: + arg1: "A" + arg2: "B" + gt: "> A é maior do que B" + _gt: + arg1: "A" + arg2: "B" + ltEq: "<= A é maior ou igual a B" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: ">= A é maior ou igual a B" + _gtEq: + arg1: "A" + arg2: "B" + if: "Galho" + _if: + arg1: "Se" + arg2: "Então" + arg3: "Se não" + not: "NÃO" + _not: + arg1: "NÃO" + random: "Aleatório" + _random: + arg1: "Probabilidade" + rannum: "Numeral aleatório" + _rannum: + arg1: "Valor mínimo" + arg2: "Valor máximo" + randomPick: "Escolher aleatoriamente de uma lista" + _randomPick: + arg1: "Listas" + dailyRandom: "Aleatório (Muda uma vez por dia para cada usuário)" + _dailyRandom: + arg1: "Probabilidade" + dailyRannum: "Numeral aleatório (Muda uma vez por dia para cada usuário)" + _dailyRannum: + arg1: "Valor mínimo" + arg2: "Valor máximo" + dailyRandomPick: "Escolher aleatoriamente de uma lista (Muda uma vez por dia para cada usuário)" + _dailyRandomPick: + arg1: "Listas" + seedRandom: "Aleatório (com semente)" + _seedRandom: + arg1: "Semente" + arg2: "Probabilidade" + seedRannum: "Número aleatório (com semente)" + _seedRannum: + arg1: "Semente" + arg2: "Valor mínimo" + arg3: "Valor máximo" + seedRandomPick: "Escolher aleatoriamente de uma lista (com uma semente)" + _seedRandomPick: + arg1: "Semente" + arg2: "Listas" + DRPWPM: "Escolher aleatoriamente de uma lista ponderada (Muda uma vez por dia para cada usuário)" + _DRPWPM: + arg1: "Lista de texto" + pick: "Escolhe a partir da lista" + _pick: + arg1: "Listas" + arg2: "Posição" + listLen: "Pegar comprimento da lista" + _listLen: + arg1: "Listas" + number: "Numérico" + stringToNumber: "Texto para numérico" + _stringToNumber: + arg1: "Texto" + numberToString: "Numérico para texto" + _numberToString: + arg1: "Numérico" + splitStrByLine: "Dividir texto por quebras" + _splitStrByLine: + arg1: "Texto" + ref: "Variável" + aiScriptVar: "Variável AiScript" + fn: "Função" + _fn: + slots: "Espaços" + slots-info: "Separar cada espaço com uma quebra de texto" + arg1: "Resultado" + for: "Repetição 'for'" + _for: + arg1: "Número de repetições" + arg2: "Ação" + typeError: "Espaço {slot} aceita valores de tipo \"{expect}\", mas o valor dado é do tipo \"{actual}\"!" + thereIsEmptySlot: "O espaço {slot} está vazio!" + types: + string: "Texto" + number: "Numérico" + array: "Listas" + stringArray: "Lista de texto" + emptySlot: "Espaço vazio" + enviromentVariables: "Variáveis de ambiente" + pageVariables: "Variáveis de página" +_relayStatus: + requesting: "Pendente" + accepted: "Aprovado" + rejected: "Recusado" +_notification: + fileUploaded: "Carregamento de arquivo efetuado com sucesso" + youGotMention: "{name} te mencionou" + youGotReply: "{name} te respondeu" + youGotQuote: "{name} te citou" + youGotPoll: "{name} votou em sua enquete" + youGotMessagingMessageFromUser: "{name} te mandou uma mensagem de bate-papo" + youGotMessagingMessageFromGroup: "Uma mensagem foi mandada para o grupo {name}" + youWereFollowed: "Você tem um novo seguidor" + youReceivedFollowRequest: "Você recebeu um pedido de seguimento" + yourFollowRequestAccepted: "Seu pedido de seguimento foi aceito" + youWereInvitedToGroup: "{userName} te convidou para um grupo" + pollEnded: "Os resultados da enquete agora estão disponíveis" + emptyPushNotificationMessage: "As notificações de alerta foram atualizadas" + _types: + all: "Todos" + follow: "Seguindo" + mention: "Menção" + reply: "Respostas" + renote: "Repostar" + quote: "Citar" + reaction: "Reações" + pollVote: "Votações em enquetes" + pollEnded: "Enquetes terminando" + receiveFollowRequest: "Recebeu pedidos de seguimento" + followRequestAccepted: "Aceitou pedidos de seguimento" + groupInvited: "Convites de grupo" + app: "Notificações de aplicativos conectados" + _actions: + followBack: "te seguiu de volta" + reply: "Responder" + renote: "Repostar" +_deck: + alwaysShowMainColumn: "Sempre mostrar a coluna principal" + columnAlign: "Alinhar colunas" + addColumn: "Adicionar coluna" + swapLeft: "Trocar de posição com a coluna à esquerda" + swapRight: "Trocar de posição com a coluna à direita" + swapUp: "Trocar de posição com a coluna acima" + swapDown: "Trocar de posição com a coluna abaixo" + popRight: "Acoplar coluna à direita" + profile: "Perfil" + _columns: + main: "Principal" + widgets: "Widgets" + notifications: "Notificações" + tl: "Timeline" + antenna: "Antenas" + list: "Listas" + mentions: "Menções" + direct: "Notas diretas" diff --git a/locales/ro-RO.yml b/locales/ro-RO.yml new file mode 100644 index 0000000..d775524 --- /dev/null +++ b/locales/ro-RO.yml @@ -0,0 +1,727 @@ +--- +_lang_: "Română" +headlineIceshrimp: "O rețea conectată prin note" +introIceshrimp: "Bine ai venit! Iceshrimp este un serviciu de microblogging open source și decentralizat.\nCreează \"note\" cu care să îți poți împărți gândurile cu oricine din jurul tău. 📡\nCu \"reacții\" îți poți expirma rapid părerea despre notele oricui. 👍\nHai să explorăm o lume nouă! 🚀" +monthAndDay: "{day}/{month}" +search: "Caută" +notifications: "Notificări" +username: "Nume de utilizator" +password: "Parolă" +forgotPassword: "Am uitat parola" +fetchingAsApObject: "Se aduce din Fediverse" +ok: "OK" +gotIt: "Am înțeles!" +cancel: "Anulează" +enterUsername: "Introdu numele de utilizator" +renotedBy: "Re-notat de {user}" +noNotes: "Nicio notă" +noNotifications: "Nicio notificare" +instance: "Instanță" +settings: "Setări" +basicSettings: "Setări generale" +otherSettings: "Alte Setări" +openInWindow: "Deschide într-o fereastră" +profile: "Profil" +timeline: "Cronologie" +noAccountDescription: "Acest utilizator încă nu a scris un bio." +login: "Autentifică-te" +loggingIn: "Se autentifică" +logout: "Deconectează-te" +signup: "Înregistrează-te" +uploading: "Se încarcă" +save: "Salvează" +users: "Utilizatori" +addUser: "Adăugă utilizator" +favorite: "Adaugă la favorite" +favorites: "Favorite" +unfavorite: "Elimină din favorite" +favorited: "Adăugat la favorite." +alreadyFavorited: "Deja adăugat la favorite." +cantFavorite: "Nu se poate adăuga la favorite." +pin: "Fixează pe profil" +unpin: "Anulati fixare" +copyContent: "Copiază conținutul" +copyLink: "Copiază link-ul" +delete: "Şterge" +deleteAndEdit: "Șterge și editează" +deleteAndEditConfirm: "Ești sigur că vrei să ștergi această notă și să o editezi? Vei pierde reacțiile, re-notele și răspunsurile acesteia." +addToList: "Adaugă în listă" +sendMessage: "Trimite un mesaj" +copyUsername: "Copiază numele de utilizator" +searchUser: "Caută un utilizator" +reply: "Răspunde" +loadMore: "Incarcă mai mult" +showMore: "Arată mai mult" +showLess: "Închide" +youGotNewFollower: "te-a urmărit" +receiveFollowRequest: "Cerere de urmărire primită" +followRequestAccepted: "Cerere de urmărire acceptată" +mention: "Mențiune" +mentions: "Mențiuni" +directNotes: "Note directe" +importAndExport: "Importă / Exportă" +import: "Importă" +export: "Exportă" +files: "Fișiere" +download: "Descarcă" +driveFileDeleteConfirm: "Ești sigur ca vrei să ștergi fișierul \"{name}\"? Notele atașate fișierului vor fi șterse și ele." +unfollowConfirm: "Ești sigur ca vrei să nu mai urmărești pe {name}?" +exportRequested: "Ai cerut un export. S-ar putea să ia un pic. Va fi adăugat in Drive-ul tău odată completat." +importRequested: "Ai cerut un import. S-ar putea să ia un pic." +lists: "Liste" +noLists: "Nu ai nici o listă" +note: "Notă" +notes: "Note" +following: "Urmărești" +followers: "Urmăritori" +followsYou: "Te urmărește" +createList: "Creează listă" +manageLists: "Gestionează listele" +error: "Eroare" +somethingHappened: "A survenit o eroare" +retry: "Reîncearcă" +pageLoadError: "A apărut o eroare la încărcarea paginii." +pageLoadErrorDescription: "De obicei asta este cauzat de o eroare de rețea sau cache-ul browser-ului. Încearcă să cureți cache-ul și apoi să încerci din nou puțin mai târziu." +serverIsDead: "Serverul nu răspunde. Te rugăm să aștepți o perioadă și să încerci din nou." +youShouldUpgradeClient: "Pentru a vedea această pagină, te rugăm să îți actualizezi clientul." +enterListName: "Introdu un nume pentru listă" +privacy: "Confidenţialitate" +makeFollowManuallyApprove: "Fă cererile de urmărire să necesite aprobare" +defaultNoteVisibility: "Vizibilitate implicită" +follow: "Urmărești" +followRequest: "Trimite cerere de urmărire" +followRequests: "Cereri de urmărire" +unfollow: "Nu mai urmări" +followRequestPending: "Cerere de urmărire în așteptare" +enterEmoji: "Introdu un emoji" +renote: "Re-notează" +unrenote: "Ia înapoi re-nota" +renoted: "Re-notat." +cantRenote: "Această postare nu poate fi re-notată." +cantReRenote: "O re-notă nu poate fi re-notată." +quote: "Citează" +pinnedNote: "Notă fixată" +pinned: "Fixat pe profil" +you: "Tu" +clickToShow: "Click pentru a afișa" +sensitive: "NSFW" +add: "Adaugă" +reaction: "Reacție" +reactionSetting: "Reacții care să apară in selectorul de reacții" +reactionSettingDescription2: "Trage pentru a rearanja, apasă pe \"+\" pentru a adăuga." +rememberNoteVisibility: "Amintește setarea de vizibilitate a notelor" +attachCancel: "Înlătură atașament" +markAsSensitive: "Marchează ca NSFW" +unmarkAsSensitive: "Demarchează ca NSFW" +enterFileName: "Introduceţi numele fişierului" +mute: "Amuțește" +unmute: "Înlătură amuțirea" +block: "Blochează" +unblock: "Deblochează" +suspend: "Suspendă" +unsuspend: "Anulează suspendare" +blockConfirm: "Ești sigur că vrei să blochezi acest cont?" +unblockConfirm: "Ești sigur ca vrei să deblochezi acest cont?" +suspendConfirm: "Ești sigur ca vrei să suspendezi acest cont?" +unsuspendConfirm: "Ești sigur ca vrei să nu mai suspendezi acest cont?" +selectList: "Selectează o listă" +selectAntenna: "Selectează o antenă" +selectWidget: "Selectați un widget" +editWidgets: "Editează widget-urile" +editWidgetsExit: "Terminat" +customEmojis: "Emoji personalizat" +emoji: "Emoji" +emojis: "Emoji-uri" +emojiName: "Numele emoji-ului" +emojiUrl: "URL-ul emoji-ului" +addEmoji: "Adaugă un emoji" +settingGuide: "Setări recomandate" +cacheRemoteFiles: "Ține fișierele externe in cache" +cacheRemoteFilesDescription: "Când această setare este dezactivată, fișierele externe sunt încărcate direct din instanța externă. Dezactivarea va scădea utilizarea spațiului de stocare, dar va crește traficul, deoarece thumbnail-urile nu vor fi generate." +flagAsBot: "Marchează acest cont ca bot" +flagAsBotDescription: "Activează această opțiune dacă acest cont este controlat de un program. Daca e activată, aceasta va juca rolul unui indicator pentru dezvoltatori pentru a preveni interacțiunea în lanțuri infinite cu ceilalți boți și ajustează sistemele interne al Iceshrimp pentru a trata acest cont drept un bot." +flagAsCat: "Marchează acest cont ca pisică" +flagAsCatDescription: "Activează această opțiune dacă acest cont este o pisică." +flagShowTimelineReplies: "Arată răspunsurile în cronologie" +flagShowTimelineRepliesDescription: "Dacă e activată vor fi arătate în cronologie răspunsurile utilizatorilor către alte notele altor utilizatori." +autoAcceptFollowed: "Aprobă automat cererile de urmărire de la utilizatorii pe care îi urmărești" +addAccount: "Adaugă un cont" +loginFailed: "Autentificare eșuată" +showOnRemote: "Vezi mai multe pe instanța externă" +general: "General" +wallpaper: "Imagine de fundal" +setWallpaper: "Setați imaginea de fundal" +removeWallpaper: "Șterge imagine de fundal" +searchWith: "Caută: {q}" +youHaveNoLists: "Nu ai nici o listă" +followConfirm: "Ești sigur ca vrei să urmărești pe {name}?" +proxyAccount: "Cont proxy" +proxyAccountDescription: "Un cont proxy este un cont care se comportă ca un urmăritor extern pentru utilizatorii puși sub anumite condiții. De exemplu, când un cineva adaugă un utilizator extern intr-o listă, activitatea utilizatorului extern nu va fi adusă în instanță daca nici un utilizator local nu urmărește acel utilizator, așa că în schimb contul proxy îl va urmări." +host: "Gazdă" +selectUser: "Selectează un utilizator" +recipient: "Destinatar" +annotation: "Adnotări" +federation: "Federație" +instances: "Instanțe" +registeredAt: "Înregistrat în" +latestRequestSentAt: "Ultima cerere trimisă" +latestRequestReceivedAt: "Ultima cerere primită" +latestStatus: "Ultimul status" +storageUsage: "Utilizare stocare" +charts: "Diagrame" +perHour: "Pe oră" +perDay: "Pe zi" +stopActivityDelivery: "Nu mai trimite activități" +blockThisInstance: "Blochează această instanță" +operations: "Operațiuni" +software: "Software" +version: "Versiune" +metadata: "Metadata" +monitor: "Monitor" +jobQueue: "coada de job-uri" +cpuAndMemory: "CPU și memorie" +network: "Rețea" +disk: "Disk" +instanceInfo: "Informații despre instanță" +statistics: "Statistici" +clearQueue: "Șterge coada" +clearQueueConfirmTitle: "Ești sigur că vrei să cureți coada?" +clearQueueConfirmText: "Orice notă rămasă în coadă nu va fi federată. De obicei această operație nu este necesară." +clearCachedFiles: "Golește cache-ul" +clearCachedFilesConfirm: "Ești sigur că vrei să ștergi toate fișierele externe din cache?" +blockedInstances: "Instanțe blocate" +blockedInstancesDescription: "Scrie hostname-urile instanțelor pe care dorești să le blochezi. Instanțele listate nu vor mai putea să comunice cu această instanță." +muteAndBlock: "Amuțiri și Blocări" +mutedUsers: "Utilizatori amuțiți" +blockedUsers: "Utilizatori blocați" +noUsers: "Niciun utilizator" +editProfile: "Editează profilul" +noteDeleteConfirm: "Ești sigur că vrei să ștergi această notă?" +pinLimitExceeded: "Nu poți mai fixa mai multe note" +intro: "Iceshrimp s-a instalat! Te rog crează un utilizator admin." +done: "Gata" +processing: "Se procesează" +preview: "Previzualizare" +default: "Prestabilit" +noCustomEmojis: "Nu e niciun emoji" +noJobs: "Nu e niciun job" +federating: "Federație" +blocked: "Blocat" +suspended: "Suspendat" +all: "Tot" +subscribing: "Abonare" +publishing: "Publicare" +notResponding: "Nu răspunde" +instanceFollowing: "Urmărind în instanță" +instanceFollowers: "Urmăritori ai instanței" +instanceUsers: "Utilizatori ai acestei instanțe" +changePassword: "Schimbă parolă" +security: "Securitate" +retypedNotMatch: "Intrările nu corespund" +currentPassword: "Parola curentă" +newPassword: "Parola nouă" +newPasswordRetype: "Rescrie parola nouă" +attachFile: "Atașează fișiere" +more: "Mai mult!" +featured: "Evidențiat" +usernameOrUserId: "Nume sau ID de utilizator" +noSuchUser: "Utilizatorul nu a fost găsit" +lookup: "Privire" +announcements: "Anunțuri" +imageUrl: "URL-ul imaginii" +remove: "Şterge" +removed: "Șterș cu succes" +removeAreYouSure: "Ești sigur că vrei să înlături {x}?" +deleteAreYouSure: "Ești sigur că vrei să ștergi {x}?" +resetAreYouSure: "Sigur vrei să resetezi?" +saved: "Salvat" +messaging: "Chat" +upload: "Încarcă" +keepOriginalUploading: "Păstrează imaginea originală" +keepOriginalUploadingDescription: "Salvează imaginea originala încărcată fără modificări. Dacă e oprită, o versiune pentru afișarea pe web va fi generată la încărcare." +fromDrive: "Din Drive" +fromUrl: "Din URL" +uploadFromUrl: "Încarcă dintr-un URL" +uploadFromUrlDescription: "URL-ul fișierului pe care dorești să îl încarci" +uploadFromUrlRequested: "Încărcare solicitată" +uploadFromUrlMayTakeTime: "S-ar putea să ia puțin până se finalizează încărcarea." +explore: "Explorează" +messageRead: "Citit" +noMoreHistory: "Nu există mai mult istoric" +startMessaging: "Începe un chat nou" +nUsersRead: "citit de {n}" +agreeTo: "Sunt de acord cu {0}" +tos: "Termenii de utilizare" +start: "Să începem" +home: "Acasă" +remoteUserCaution: "Deoarece acest utilizator este dintr-o instanță externă, informația afișată poate fi incompletă." +activity: "Activitate" +images: "Imagini" +birthday: "Zi de naștere" +yearsOld: "{age} ani" +registeredDate: "Data înregistrării" +location: "Locație" +theme: "Teme" +themeForLightMode: "Temă folosită pentru Modul Luminat" +themeForDarkMode: "Temă folosită pentru Modul Întunecat" +light: "Luminos" +dark: "Întunecat" +lightThemes: "Teme luminoase" +darkThemes: "Teme întunecate" +syncDeviceDarkMode: "Sincronizează Modul Întunecat cu setările dispozitivului" +drive: "Drive" +fileName: "Nume fișier" +selectFile: "Alege un fisier" +selectFiles: "Alege fișiere" +selectFolder: "Selectează un folder" +selectFolders: "Selectează folderele" +renameFile: "Redenumește fișier" +folderName: "Nume folder" +createFolder: "Crează folder" +renameFolder: "Redenumește acest folder" +deleteFolder: "Șterge acest folder" +addFile: "Adăugați un fișier" +emptyDrive: "Drive-ul tău e gol" +emptyFolder: "Folder-ul acesta este gol" +unableToDelete: "Nu se poate șterge" +inputNewFileName: "Introdu un nou nume de fișier" +inputNewDescription: "Introdu o descriere nouă" +inputNewFolderName: "Introdu un nume de folder nou" +circularReferenceFolder: "Destinația folderului este un subfolder al folderului pe care dorești să îl muți." +hasChildFilesOrFolders: "Acest folder nu este gol, așa că nu poate fi șters." +copyUrl: "Copiază URL" +rename: "Redenumește" +avatar: "Avatar" +banner: "Banner" +nsfw: "NSFW" +whenServerDisconnected: "Când pierzi conexiunea cu serverul" +disconnectedFromServer: "Conecțiunea cu serverul a fost pierdută" +reload: "Reîncarcă" +doNothing: "Ignoră" +reloadConfirm: "Ai dori să reîmprospătezi cronologia?" +watch: "Vezi" +unwatch: "Oprește-te din văzut" +accept: "Acceptă" +reject: "Respinge" +normal: "Normal" +instanceName: "Numele instanței" +instanceDescription: "Descrierea instanței" +maintainerName: "Administrator" +maintainerEmail: "Email-ul administratorului" +tosUrl: "URL-ul Termenilor de utilizare" +thisYear: "An" +thisMonth: "Lună" +today: "Azi" +dayX: "{day}" +monthX: "{month}" +yearX: "{year}" +pages: "Pagini" +integration: "Integrare" +connectService: "Conectează" +disconnectService: "Deconectează" +enableLocalTimeline: "Activează cronologia locală" +enableGlobalTimeline: "Activeaza cronologia globală" +disablingTimelinesInfo: "Administratorii și Moderatorii vor avea mereu access la toate cronologiile, chiar dacă nu sunt activate." +registration: "Inregistrare" +enableRegistration: "Activează înregistrările pentru utilizatori noi" +invite: "Invită" +driveCapacityPerLocalAccount: "Capacitatea Drive-ului per utilizator local" +driveCapacityPerRemoteAccount: "Capacitatea Drive-ului per utilizator extern" +inMb: "În megabytes" +iconUrl: "URL-ul iconiței" +bannerUrl: "URL-ul imaginii de banner" +backgroundImageUrl: "URL-ul imaginii de fundal" +basicInfo: "Informații de bază" +pinnedUsers: "Utilizatori fixați" +pinnedUsersDescription: "Scrie utilizatorii, separați prin pauză de rând, care vor fi fixați pe pagina \"Explorează\"." +pinnedPages: "Pagini fixate" +pinnedPagesDescription: "Introdu linkurile Paginilor pe care le vrei fixate in vâruful paginii acestei instanțe, separate de pauze de rând." +pinnedClipId: "ID-ul clip-ului pe care să îl fixezi" +pinnedNotes: "Notă fixată" +hcaptcha: "hCaptcha" +enableHcaptcha: "Activează hCaptcha" +hcaptchaSiteKey: "Site key" +hcaptchaSecretKey: "Secret key" +recaptcha: "reCAPTCHA" +enableRecaptcha: "Activează reCAPTCHA" +recaptchaSiteKey: "Site key" +recaptchaSecretKey: "Secret key" +avoidMultiCaptchaConfirm: "Folosirea mai multor sisteme Captcha poate cauza interferență între acestea. Ai dori să dezactivezi alte sisteme Captcha acum active? Dacă preferi să rămână activate, apasă Anulare." +antennas: "Antene" +manageAntennas: "Gestionează Antenele" +name: "Nume" +antennaSource: "Sursa antenei" +antennaKeywords: "Cuvinte cheie ascultate" +antennaExcludeKeywords: "Cuvinte cheie excluse" +antennaKeywordsDescription: "Separă cu spații pentru o condiție ȘI sau cu o întrerupere de rând pentru o condiție SAU." +notifyAntenna: "Notifică-mă pentru note noi" +withFileAntenna: "Doar note cu fișiere" +enableServiceworker: "Activează ServiceWorker" +antennaUsersDescription: "Scrie un nume de utilizator per linie" +caseSensitive: "Sensibil la majuscule și minuscule" +withReplies: "Include răspunsuri" +connectedTo: "Următoarele conturi sunt conectate" +notesAndReplies: "Note și răspunsuri" +withFiles: "Incluzând fișiere" +silence: "Amuțește" +silenceConfirm: "Ești sigur că vrei să amuțești acest utilizator?" +unsilence: "Anulează amuțirea" +unsilenceConfirm: "Ești sigur că vrei să anulezi amuțirea acestui utilizator?" +popularUsers: "Utilizatori populari" +recentlyUpdatedUsers: "Utilizatori activi recent" +recentlyRegisteredUsers: "Utilizatori ce s-au alăturat recent" +recentlyDiscoveredUsers: "Utilizatori descoperiți recent" +exploreUsersCount: "Aici sunt {count} utilizatori" +exploreFediverse: "Explorează Fediverse-ul" +popularTags: "Taguri populare" +userList: "Liste" +about: "Despre" +aboutIceshrimp: "Despre Iceshrimp" +administrator: "Administrator" +token: "Token" +twoStepAuthentication: "Autentificare în doi pași" +moderator: "Moderator" +nUsersMentioned: "Menționat de {n} utilizatori" +securityKey: "Cheie de securitate" +securityKeyName: "Numele cheii" +registerSecurityKey: "Înregistrează o cheie de securitate" +lastUsed: "Ultima utilizată" +unregister: "Dezînregistrează" +passwordLessLogin: "Autentificare fără parolă" +resetPassword: "Resetează parola" +newPasswordIs: "Noua parolă este \"{password}\"" +reduceUiAnimation: "Redu animațiile interfeței" +share: "Distribuie" +notFound: "Nu a fost găsit" +notFoundDescription: "N-a fost găsită nicio pagină cu acest URL." +uploadFolder: "Folder implicit pentru încărcări" +cacheClear: "Golește cache-ul" +markAsReadAllNotifications: "Marchează toate notificările drept citit" +markAsReadAllUnreadNotes: "Marchează toate notele drept citit" +markAsReadAllTalkMessages: "Marchează toate mesajele drept citit" +help: "Ajutor" +inputMessageHere: "Introdu un mesaj aici" +close: "Închide" +group: "Grup" +groups: "Grupuri" +createGroup: "Crează un grup" +ownedGroups: "Grupuri deținute" +joinedGroups: "Grupuri alăturate" +invites: "Invită" +groupName: "Numele grupului" +members: "Membri" +transfer: "Transferă" +messagingWithUser: "Chat privat" +messagingWithGroup: "Chat de grup" +title: "Titlu" +text: "Text" +enable: "Activează" +next: "Următorul" +retype: "Introdu din nou" +noteOf: "Notă de {user}" +inviteToGroup: "Invită în grup" +quoteAttached: "Citat" +quoteQuestion: "Vrei să adaugi ca citat?" +noMessagesYet: "Niciun mesaj încă" +newMessageExists: "Ai mesaje noi" +onlyOneFileCanBeAttached: "Poți atașa un singur fișier la un mesaj" +signinRequired: "Te rog autentifică-te" +invitations: "Invită" +invitationCode: "Cod de invitație" +checking: "Se verifică..." +available: "Disponibil" +unavailable: "Indisponibil" +usernameInvalidFormat: "Poți folosi litere mari și mici, numere și underscore-uri." +tooShort: "Prea scurt" +tooLong: "Prea lung" +weakPassword: "Parolă slabă" +normalPassword: "Parolă medie" +strongPassword: "Parolă puternică" +passwordMatched: "Se potrivește!" +passwordNotMatched: "Nu se potrivește" +signinWith: "Autentifică-te cu {x}" +signinFailed: "Nu se poate autentifica. Numele de utilizator sau parola introduse sunt incorecte." +tapSecurityKey: "Apasă pe cheia ta de securitate." +or: "Sau" +language: "Limbă" +uiLanguage: "Limba interfeței" +groupInvited: "Ai fost invitat într-un grup" +aboutX: "Despre {x}" +useOsNativeEmojis: "Folosește emojiuri native OS-ului" +disableDrawer: "Nu folosi meniuri în stil sertar" +youHaveNoGroups: "Nu ai niciun grup" +joinOrCreateGroup: "Primește o invitație într-un grup sau creează unul nou." +noHistory: "Nu există istoric" +signinHistory: "Istoric autentificări" +disableAnimatedMfm: "Dezactivează MFM cu animații" +doing: "Se procesează..." +category: "Categorie" +tags: "Etichete" +docSource: "Sursa acestui document" +createAccount: "Creează un cont" +existingAccount: "Cont existent" +regenerate: "Regenerează" +fontSize: "Mărimea fontului" +noFollowRequests: "Nu ai nicio cerere de urmărire în așteptare" +openImageInNewTab: "Deschide imaginile în taburi noi" +dashboard: "Panou de control" +local: "Local" +remote: "Extern" +total: "Total" +weekOverWeekChanges: "Schimbări până săptămâna trecută" +dayOverDayChanges: "Schimbări până ieri" +appearance: "Aspect" +clientSettings: "Setări client" +accountSettings: "Setări cont" +promotion: "Promovat" +promote: "Promovează" +numberOfDays: "Numărul zilelor" +hideThisNote: "Ascunde această notă" +showFeaturedNotesInTimeline: "Arată notele recomandate în cronologii" +objectStorage: "Object Storage" +useObjectStorage: "Folosește Object Storage" +objectStorageBaseUrl: "URL de bază" +objectStorageBaseUrlDesc: "URL-ul este folosit pentru referință. Specifică URL-ul CDN-ului sau Proxy-ului tău dacă folosești unul. Pentru S3 folosește 'https://.s3.amazonaws.com' și pentru GCS sau servicii echivalente folosește 'https://storage.googleapis.com/', etc." +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "Te rog specifică numele bucket-ului furnizorului tău." +objectStoragePrefix: "Prefix" +objectStoragePrefixDesc: "Fișierele vor fi stocate sub directoare cu acest prefix." +objectStorageEndpoint: "Endpoint" +objectStorageEndpointDesc: "Lasă acest câmp gol dacă folosești AWS S3, dacă nu specifică endpoint-ul ca '' sau ':', depinzând de ce serviciu folosești." +objectStorageRegion: "Regiune" +objectStorageRegionDesc: "Specifică o regiune precum 'xx-east-1'. Dacă serviciul tău nu face distincția între regiuni lasă acest câmp gol sau introdu 'us-east-1'." +objectStorageUseSSL: "Folosește SSl" +objectStorageUseSSLDesc: "Oprește această opțiune dacă nu vei folosi HTTPS pentru conexiunile API-ului" +objectStorageUseProxy: "Conectează-te prin Proxy" +objectStorageUseProxyDesc: "Oprește această opțiune dacă vei nu folosi un Proxy pentru conexiunile API-ului" +objectStorageSetPublicRead: "Setează \"public-read\" pentru încărcare" +serverLogs: "Loguri server" +deleteAll: "Șterge tot" +showFixedPostForm: "Arată caseta de postare în vârful cronologie" +newNoteRecived: "Sunt note noi" +sounds: "Sunete" +listen: "Ascultă" +none: "Nimic" +showInPage: "Arată în pagină" +popout: "Scoate în afară" +volume: "Volum" +masterVolume: "Volumul principal" +details: "Detalii" +chooseEmoji: "Alege un emoji" +unableToProcess: "Această operație nu poate fi completată" +recentUsed: "Folosit recent" +install: "Instalează" +uninstall: "Dezinstalează" +installedApps: "Aplicații autorizate" +nothing: "Nu e nimic de văzut aici" +installedDate: "Autorizat la data de" +lastUsedDate: "Folosit ultima oara la" +state: "Stare" +sort: "Sortează" +ascendingOrder: "Crescător" +descendingOrder: "Descrescător" +scratchpad: "Scratchpad" +scratchpadDescription: "Scratchpad-ul oferă un mediu de experimentare în AiScript. Poți scrie, executa și verifica rezultatele acestuia interacționând cu Iceshrimp în el." +output: "Ieșire" +script: "Script" +disablePagesScript: "Dezactivează AiScript în Pagini" +updateRemoteUser: "Actualizează informațiile utilizatorului extern" +deleteAllFiles: "Șterge toate fișierele" +deleteAllFilesConfirm: "Ești sigur că vrei să ștergi toate fișierele?" +removeAllFollowing: "Dezurmărește toți utilizatorii urmăriți" +removeAllFollowingDescription: "Asta va dez-urmări toate conturile din {host}. Te rog execută asta numai dacă instanța, de ex., nu mai există." +userSuspended: "Acest utilizator a fost suspendat." +userSilenced: "Acest utilizator a fost setat silențios." +yourAccountSuspendedTitle: "Acest cont a fost suspendat" +yourAccountSuspendedDescription: "Acest cont a fost suspendat din cauza încălcării termenilor de serviciu al serverului sau ceva similar. Contactează administratorul dacă ai dori să afli un motiv mai detaliat. Te rog nu crea un cont nou." +menu: "Meniu" +divider: "Separator" +addItem: "Adaugă element" +relays: "Relee" +addRelay: "Adaugă Releu" +inboxUrl: "URL-ul inbox-ului" +addedRelays: "Relee adăugate" +serviceworkerInfo: "Trebuie să fie activat pentru notificări push." +deletedNote: "Notă ștearsă" +invisibleNote: "Note ascunse" +enableInfiniteScroll: "Încarcă mai mult automat" +visibility: "Vizibilitate" +poll: "Sondaj" +useCw: "Ascunde conținutul" +enablePlayer: "Deschide player-ul video" +disablePlayer: "Închide player-ul video" +expandTweet: "Expandează tweet" +themeEditor: "Editor de teme" +description: "Descriere" +describeFile: "Adaugă titrări" +enterFileDescription: "Introdu titrările" +author: "Autor" +leaveConfirm: "Ai schimbări nesalvate. Vrei să renunți la ele?" +manage: "Gestionare" +plugins: "Pluginuri" +deck: "Deck" +undeck: "Părăsește Deck" +useBlurEffectForModal: "Folosește efect de blur pentru modale" +width: "Lăţime" +height: "Înălţime" +large: "Mare" +medium: "Mediu" +small: "Mic" +generateAccessToken: "Generează token de acces" +permission: "Permisiuni" +enableAll: "Actevează tot" +disableAll: "Dezactivează tot" +tokenRequested: "Acordă acces la cont" +pluginTokenRequestedDescription: "Acest plugin va putea să folosească permisiunile setate aici." +notificationType: "Tipul notificării" +edit: "Editează" +emailServer: "Server email" +enableEmail: "Activează distribuția de emailuri" +emailConfigInfo: "Folosit pentru a confirma emailul tău în timpul logări dacă îți uiți parola" +email: "Email" +emailAddress: "Adresă de email" +smtpConfig: "Configurare Server SMTP" +smtpHost: "Gazdă" +smtpPort: "Port" +smtpUser: "Nume de utilizator" +smtpPass: "Parolă" +emptyToDisableSmtpAuth: "Lasă username-ul și parola necompletate pentru a dezactiva verificarea SMTP" +smtpSecure: "Folosește SSL/TLS implicit pentru conecțiunile SMTP" +smtpSecureInfo: "Oprește opțiunea asta dacă STARTTLS este folosit" +testEmail: "Testează livrarea emailurilor" +wordMute: "Cuvinte pe mut" +regexpError: "Eroare de Expresie Regulată" +regexpErrorDescription: "A apărut o eroare în expresia regulată pe linia {line} al cuvintelor {tab} setate pe mut:" +instanceMute: "Instanțe pe mut" +userSaysSomething: "{name} a spus ceva" +makeActive: "Activează" +display: "Arată" +copy: "Copiază" +metrics: "Metrici" +overview: "Privire de ansamblu" +logs: "Log-uri" +delayed: "Întârziate" +database: "Baza de date" +channel: "Canale" +create: "Crează" +notificationSetting: "Setări notificări" +notificationSettingDesc: "Selectează tipurile de notificări care să fie arătate" +useGlobalSetting: "Folosește setările globale" +useGlobalSettingDesc: "Dacă opțiunea e pornită, notificările contului tău vor fi folosite. Dacă e oprită, configurația va fi individuală." +other: "Altele" +regenerateLoginToken: "Regenerează token de login" +regenerateLoginTokenDescription: "Regenerează token-ul folosit intern în timpul logări. În mod normal asta nu este necesar. Odată regenerat, toate dispozitivele vor fi delogate." +setMultipleBySeparatingWithSpace: "Separă mai multe intrări cu spații." +fileIdOrUrl: "Introdu ID sau URL" +behavior: "Comportament" +sample: "exemplu" +abuseReports: "Rapoarte" +reportAbuse: "Raportează" +reportAbuseOf: "Raportează {name}" +fillAbuseReportDescription: "Te rog scrie detaliile legate de acest raport. Dacă este despre o notă specifică, te rog introdu URL-ul ei." +abuseReported: "Raportul tău a fost trimis. Mulțumim." +reporter: "Raportorul" +reporteeOrigin: "Originea raportatului" +reporterOrigin: "Originea raportorului" +forwardReport: "Redirecționează raportul către instanța externă" +forwardReportIsAnonymous: "În locul contului tău, va fi afișat un cont anonim, de sistem, ca raportor către instanța externă." +send: "Trimite" +abuseMarkAsResolved: "Marchează raportul ca rezolvat" +openInNewTab: "Deschide în tab nou" +openInSideView: "Deschide în vedere laterală" +defaultNavigationBehaviour: "Comportament de navigare implicit" +editTheseSettingsMayBreakAccount: "Editarea acestor setări îți pot defecta contul." +waitingFor: "Așteptând pentru {x}" +random: "Aleator" +system: "Sistem" +switchUi: "Schimbă UI" +desktop: "Desktop" +clearCache: "Golește cache-ul" +info: "Despre" +user: "Utilizatori" +administration: "Gestionare" +middle: "Mediu" +sent: "Trimite" +searchByGoogle: "Caută" +file: "Fișiere" +_email: + _follow: + title: "te-a urmărit" +_mfm: + mention: "Mențiune" + quote: "Citează" + emoji: "Emoji personalizat" + search: "Caută" +_theme: + description: "Descriere" + keys: + mention: "Mențiune" + renote: "Re-notează" + divider: "Separator" +_sfx: + note: "Note" + notification: "Notificări" + chat: "Chat" +_widgets: + notifications: "Notificări" + timeline: "Cronologie" + activity: "Activitate" + federation: "Federație" + jobQueue: "coada de job-uri" +_cw: + show: "Incarcă mai mult" +_visibility: + home: "Acasă" + followers: "Urmăritori" +_profile: + name: "Nume" + username: "Nume de utilizator" +_exportOrImport: + followingList: "Urmărești" + muteList: "Amuțește" + blockingList: "Blochează" + userLists: "Liste" +_charts: + federation: "Federație" +_timelines: + home: "Acasă" +_pages: + blocks: + image: "Imagini" + script: + categories: + list: "Liste" + blocks: + _join: + arg1: "Liste" + _randomPick: + arg1: "Liste" + _dailyRandomPick: + arg1: "Liste" + _seedRandomPick: + arg2: "Liste" + _pick: + arg1: "Liste" + _listLen: + arg1: "Liste" + types: + array: "Liste" +_notification: + youWereFollowed: "te-a urmărit" + youWereInvitedToGroup: "Ai fost invitat într-un grup" + _types: + follow: "Urmărești" + mention: "Mențiune" + renote: "Re-notează" + quote: "Citează" + reaction: "Reacție" + _actions: + reply: "Răspunde" + renote: "Re-notează" +_deck: + _columns: + notifications: "Notificări" + tl: "Cronologie" + antenna: "Antene" + list: "Liste" + mentions: "Mențiuni" diff --git a/locales/ru-RU.yml b/locales/ru-RU.yml new file mode 100644 index 0000000..2a11ac0 --- /dev/null +++ b/locales/ru-RU.yml @@ -0,0 +1,2016 @@ +_lang_: "Русский" +headlineIceshrimp: "Сеть, сплетённая из заметок" +introIceshrimp: "Iceshrimp - это децентрализованная платформа социальных сетей с открытым + исходным кодом, которая свободна навсегда! 🚀" +monthAndDay: "{day}.{month}" +search: "Поиск" +notifications: "Уведомления" +username: "Имя пользователя" +password: "Пароль" +forgotPassword: "Пароль забыт" +fetchingAsApObject: "Приём с других сайтов" +ok: "Окей" +gotIt: "Ясно!" +cancel: "Отмена" +enterUsername: "Введите имя пользователя" +renotedBy: "{user} делится" +noNotes: "Нет ни одного поста" +noNotifications: "Нет ни одного уведомления" +instance: "Сервер" +settings: "Настройки" +basicSettings: "Основные настройки" +otherSettings: "Прочие настройки" +openInWindow: "Открывать в плавающих окнах" +profile: "Профиль" +timeline: "Лента" +noAccountDescription: "Пользователь ничего не написал про себя." +login: "Войти" +loggingIn: "Выполняется вход" +logout: "Выйти" +signup: "Регистрация" +uploading: "Загрузка..." +save: "Сохранить" +users: "Пользователи" +addUser: "Добавить пользователя" +favorite: "В избранное" +favorites: "Избранное" +unfavorite: "Убрать из избранного" +favorited: "Добавлено в избранное." +alreadyFavorited: "Уже есть в избранном." +cantFavorite: "Не удалось добавить в избранное." +pin: "Закрепить в профиле" +unpin: "Открепить от профиля" +copyContent: "Скопировать содержимое" +copyLink: "Скопировать ссылку" +delete: "Удалить" +deleteAndEdit: "Удалить и отредактировать" +deleteAndEditConfirm: "Удалить этот пост и создать отредактированный? Все реакции, + ссылки и ответы на существующий будут потеряны." +addToList: "Добавить в список" +sendMessage: "Отправить сообщение" +copyUsername: "Скопировать имя пользователя" +searchUser: "Поиск людей" +reply: "Ответить" +loadMore: "Показать еще" +showMore: "Показать еще" +showLess: "Закрыть" +youGotNewFollower: "Новый подписчик" +receiveFollowRequest: "Получен запрос на подписку" +followRequestAccepted: "Запрос на подписку принят" +mention: "Упоминание" +mentions: "Упоминания" +directNotes: "Личные сообщения" +importAndExport: "Импорт и экспорт" +import: "Импорт" +export: "Экспорт" +files: "Файлы" +download: "Скачать" +driveFileDeleteConfirm: "Удалить файл «{name}»? Он будет удален со всех постов которые + содержат его как вложение." +unfollowConfirm: "Удалить из подписок пользователя {name}?" +exportRequested: "Вы запросили экспорт. Это может занять некоторое время. Результат + будет добавлен на «Диск»." +importRequested: "Вы запросили импорт. Это может занять некоторое время." +lists: "Списки" +noLists: "Нет ни одного списка" +note: "Пост" +notes: "Посты" +following: "Подписки" +followers: "Подписчики" +followsYou: "Читает вас" +createList: "Создать список" +manageLists: "Управление списками" +error: "Ошибка" +somethingHappened: "Что-то пошло не так" +retry: "Повторить попытку" +pageLoadError: "Не удалось загрузить страницу." +pageLoadErrorDescription: "Обычно это случается из-за сбоев в сети или кэша браузера. + Попробуйте очистить кэш, или подождать пару минут, а потом попытаться загрузить + страницу снова." +serverIsDead: "Ответа от сервера нет. Пожалуйста, подождите немного и повторите попытку." +youShouldUpgradeClient: "Чтобы просмотреть эту страницу, пожалуйста, обновите ее." +enterListName: "Название списка" +privacy: "Конфиденциальность" +makeFollowManuallyApprove: "Принимать подписчиков вручную" +defaultNoteVisibility: "Видимость постов по умолчанию" +follow: "Подписка" +followRequest: "Запрос на подписку" +followRequests: "Запросы на подписку" +unfollow: "Отписаться" +followRequestPending: "Нерассмотренный запрос на подписку" +enterEmoji: "Введите эмодзи" +renote: "Репост" +unrenote: "Отмена репоста" +renoted: "Репост совершён." +cantRenote: "Это нельзя репостить." +cantReRenote: "Невозможно репостить репост." +quote: "Цитата" +pinnedNote: "Закреплённый пост" +pinned: "Закрепить в профиле" +you: "Вы" +clickToShow: "Нажмите для просмотра" +sensitive: "Содержимое не для всех" +add: "Добавить" +reaction: "Реакции" +reactionSetting: "Реакции, отображаемые в палитре" +reactionSettingDescription2: "Расставляйте перетаскиванием, удаляйте нажатием, добавляйте + кнопкой «+»." +rememberNoteVisibility: "Запоминать видимость постов" +attachCancel: "Удалить вложение" +markAsSensitive: "Отметить как «не для всех»" +unmarkAsSensitive: "Снять отметку «не для всех»" +enterFileName: "Введите имя файла" +mute: "Скрыть" +unmute: "Отменить скрытие" +block: "Заблокировать" +unblock: "Разблокировать" +suspend: "Заморозить" +unsuspend: "Разморозить" +blockConfirm: "Заблокировать этот аккаунт?" +unblockConfirm: "Разблокировать этот аккаунт?" +suspendConfirm: "Заморозить этот аккаунт?" +unsuspendConfirm: "Разморозить этот аккаунт?" +selectList: "Выберите список" +selectAntenna: "Выберите антенну" +selectWidget: "Выберите виджет" +editWidgets: "Редактировать виджеты" +editWidgetsExit: "Готово" +customEmojis: "Собственные эмодзи" +emoji: "Эмодзи" +emojis: "Эмодзи" +emojiName: "Название эмодзи" +emojiUrl: "URL эмодзи" +addEmoji: "Добавить эмодзи" +settingGuide: "Рекомендуемые настройки" +cacheRemoteFiles: "Кешировать внешние файлы" +cacheRemoteFilesDescription: "Когда эта настройка отключена, файлы с других сайтов + будут загружаться прямо оттуда. Это сэкономит место на сервере, но увеличит трафик, + так как не будут создаваться эскизы." +flagAsBot: "Аккаунт бота" +flagAsBotDescription: "Включите, если этот аккаунт управляется программой. Это позволит + системе Iceshrimp учитывать это, а также поможет разработчикам других ботов предотвратить + бесконечные циклы взаимодействия." +flagAsCat: "Аккаунт кота" +flagAsCatDescription: "Вы получите кошачьи ушки и будете говорить как кот!" +flagShowTimelineReplies: "Показывать ответы на посты в ленте" +flagShowTimelineRepliesDescription: "Если этот параметр включен, то в ленте, в дополнение + к постам пользователя, отображаются ответы на другие посты пользователя." +autoAcceptFollowed: "Принимать подписчиков автоматически" +addAccount: "Добавить учётную запись" +loginFailed: "Неудачная попытка входа" +showOnRemote: "Открыть оригинал" +general: "Общее" +wallpaper: "Обои" +setWallpaper: "Установить обои" +removeWallpaper: "Удалить обои" +searchWith: "Найденное «{q}»" +youHaveNoLists: "У вас нет ни одного списка" +followConfirm: "Подписаться на {name}?" +proxyAccount: "Учётная запись прокси" +proxyAccountDescription: "Учетная запись прокси предназначена служить подписчиком + на пользователей с других сайтов. Например, если пользователь добавит кого-то с + другого сайта а список, деятельность того не отобразится, пока никто с этого же + сайта не подписан на него. Чтобы это стало возможным, на него подписывается прокси." +host: "Хост" +selectUser: "Выберите пользователя" +recipient: "Кому" +annotation: "Описание" +federation: "Федерация" +instances: "Серверы" +registeredAt: "Первое наблюдение" +latestRequestSentAt: "Последний отправленный запрос" +latestRequestReceivedAt: "Последний полученный запрос" +latestStatus: "Последний статус" +storageUsage: "Использовано" +charts: "Диаграммы" +perHour: "По часам" +perDay: "По дням" +stopActivityDelivery: "Остановить отправку обновлений активности" +blockThisInstance: "Блокировать этот сервер" +operations: "Операции" +software: "Программы" +version: "Версия" +metadata: "Метаданные" +monitor: "Монитор" +jobQueue: "Очередь заданий" +cpuAndMemory: "Процессор и память" +network: "Сеть" +disk: "Диск" +instanceInfo: "Информация о сервере" +statistics: "Статистика" +clearQueue: "Очистить очередь" +clearQueueConfirmTitle: "Очистить очередь?" +clearQueueConfirmText: "Всё, что осталось в очереди, не будет доставлено. Обычно эта + операция НЕ нужна." +clearCachedFiles: "Очистить кэш" +clearCachedFilesConfirm: "Удалить все закэшированные файлы с других сайтов?" +blockedInstances: "Заблокированные серверы" +blockedInstancesDescription: "Введите список серверов, которые хотите заблокировать. + Они больше не смогут обмениваться с вашим сервером." +muteAndBlock: "Скрытие и блокировка" +mutedUsers: "Скрытые пользователи" +blockedUsers: "Заблокированные пользователи" +noUsers: "Нет ни одного пользователя" +editProfile: "Редактировать профиль" +noteDeleteConfirm: "Вы хотите удалить этот пост?" +pinLimitExceeded: "Нельзя закрепить ещё больше постов" +intro: "Установка Iceshrimp завершена! А теперь создайте учетную запись администратора." +done: "Готово" +processing: "Обработка" +preview: "Предпросмотр" +default: "По умолчанию" +defaultValueIs: "По умолчанию: {value}" +noCustomEmojis: "Собственные эмодзи отсутствуют" +noJobs: "Нет заданий" +federating: "Федерируется" +blocked: "Заблокировано" +suspended: "Заморожено" +all: "Всё" +subscribing: "Подписка" +publishing: "Публикация" +notResponding: "Нет ответа" +instanceFollowing: "Подписанные на сервере" +instanceFollowers: "Подписчики сервера" +instanceUsers: "Пользователи сервера" +changePassword: "Изменить пароль" +security: "Безопасность" +retypedNotMatch: "Не совпадают." +currentPassword: "Текущий пароль" +newPassword: "Новый пароль" +newPasswordRetype: "Новый пароль (ещё раз)" +attachFile: "Прикрепить файлы" +more: "Ещё!" +featured: "Горячее" +usernameOrUserId: "Имя или идентификатор пользователя" +noSuchUser: "Таких пользователей не найдено" +lookup: "Запрос" +announcements: "Оповещения" +imageUrl: "Ссылка на изображение" +remove: "Удалить" +removed: "\uFEFFУдалено" +removeAreYouSure: "Хотите удалить «{x}»?" +deleteAreYouSure: "Хотите удалить «{x}»?" +resetAreYouSure: "На самом деле сбросить?" +saved: "Сохранено" +messaging: "Сообщения" +upload: "Загрузить" +keepOriginalUploading: "Сохранить исходное изображение" +keepOriginalUploadingDescription: "Сохраняет исходную версию при загрузке изображений. + Если выключить, то при загрузке браузер генерирует изображение для публикации." +fromDrive: "С «диска»" +fromUrl: "По ссылке" +uploadFromUrl: "Загрузить по ссылке" +uploadFromUrlDescription: "Ссылка на файл, который хотите загрузить" +uploadFromUrlRequested: "Загрузка выбранного" +uploadFromUrlMayTakeTime: "Загрузка может занять некоторое время." +explore: "Обзор" +messageRead: "Прочитали" +noMoreHistory: "История закончилась" +startMessaging: "Начать общение" +nUsersRead: "Прочитали {n}" +agreeTo: "Я соглашаюсь с {0}" +tos: "Пользовательское соглашение" +start: "Начать" +home: "Главная" +remoteUserCaution: "Это пользователь с другого сайта, поэтому информация может быть + неточной." +activity: "Активность" +images: "Изображения" +birthday: "День рождения" +yearsOld: "Возраст: {age}" +registeredDate: "Дата регистрации" +location: "Местоположение" +theme: "Тема" +themeForLightMode: "Тема для светлого режима" +themeForDarkMode: "Тема для тёмного режима" +light: "Светлый" +dark: "Тёмный" +lightThemes: "Светлые темы" +darkThemes: "Тёмные темы" +syncDeviceDarkMode: "Синхронизировать с темным режимом устройства" +drive: "Диск" +fileName: "Имя файла" +selectFile: "Выберите файл" +selectFiles: "Выберите файлы" +selectFolder: "Выберите папку" +selectFolders: "Выберите папки" +renameFile: "Переименовать файл" +folderName: "Имя папки" +createFolder: "Создать папку" +renameFolder: "Переименовать папку" +deleteFolder: "Удалить папку" +addFile: "Добавить файл" +emptyDrive: "Диск пуст" +emptyFolder: "Папка пуста" +unableToDelete: "Удаление невозможно" +inputNewFileName: "Введите имя нового файла" +inputNewDescription: "Введите новую подпись" +inputNewFolderName: "Пожалуйста, введите новое имя папки" +circularReferenceFolder: "Вы пытаетесь переместить папку внутрь себя." +hasChildFilesOrFolders: "Эта папка не пуста и не может быть удалена." +copyUrl: "Копировать ссылку" +rename: "Переименовать" +avatar: "Аватар" +banner: "Шапка" +nsfw: "Содержимое не для всех" +whenServerDisconnected: "Когда соединение с сервером потеряно" +disconnectedFromServer: "Разорвано соединение с сервером" +reload: "Перезагрузить" +doNothing: "Ничего не делать" +reloadConfirm: "Перезагрузить ленту?" +watch: "Следить" +unwatch: "Отписаться" +accept: "Принять" +reject: "Отклонить" +normal: "Стабильно" +instanceName: "Название сервера" +instanceDescription: "Описание сервера" +maintainerName: "Имя администратора" +maintainerEmail: "Электронная почта администратора" +tosUrl: "Ссылка на пользовательское соглашение" +thisYear: "Этот год" +thisMonth: "Этот месяц" +today: "Этот день" +dayX: "{day} день" +monthX: "{month} месяц" +yearX: "{year} год" +pages: "Страницы" +integration: "Интеграции" +connectService: "Подключиться" +disconnectService: "Отключиться" +enableLocalTimeline: "Включить локальную ленту" +enableGlobalTimeline: "Включить глобальную ленту" +disablingTimelinesInfo: "У администраторов и модераторов есть доступ ко всем лентам, + даже если они отключены." +registration: "Регистрация" +enableRegistration: "Разрешить регистрацию" +invite: "Пригласить" +driveCapacityPerLocalAccount: "Объём диска на одного локального пользователя" +driveCapacityPerRemoteAccount: "Объём диска на одного пользователя с другого сайта" +inMb: "В мегабайтах" +iconUrl: "Ссылка на аватар" +bannerUrl: "Ссылка на изображение в шапке" +backgroundImageUrl: "Ссылка на фоновое изображение" +basicInfo: "Общая информация" +pinnedUsers: "Прикреплённый пользователь" +pinnedUsersDescription: "Перечислите по одному имени пользователя в строке. Пользователи, + перечисленные здесь, будут привязаны к закладке \"Изучение\"." +pinnedPages: "Закрепленные страницы" +pinnedPagesDescription: "Если хотите закрепить страницы на главной сайта, сюда можно + добавить пути к ним, каждый в отдельной строке." +pinnedClipId: "Идентификатор закреплённой подборки" +pinnedNotes: "Закреплённые посты" +hcaptcha: "hCaptcha" +enableHcaptcha: "Включить hCaptcha" +hcaptchaSiteKey: "Ключ сайта" +hcaptchaSecretKey: "Секретный ключ" +recaptcha: "reCAPTCHA" +enableRecaptcha: "Включить reCAPTCHA" +recaptchaSiteKey: "Ключ сайта" +recaptchaSecretKey: "Секретный ключ" +avoidMultiCaptchaConfirm: "Несколько способов проверки могут мешать друг другу. Подтвердите, + если хотите отключить другие способы. Или нажмите «Отмена», чтобы оставить их включёнными." +antennas: "Антенны" +manageAntennas: "Настройки антенн" +name: "Название" +antennaSource: "Источник антенны" +antennaKeywords: "Ключевые слова" +antennaExcludeKeywords: "Исключения" +antennaKeywordsDescription: "Пишите слова через пробел в одной строке, чтобы ловить + их появление вместе; на отдельных строках располагайте слова, или группы слов, чтобы + ловить любые из них." +notifyAntenna: "Уведомлять о новых постах" +withFileAntenna: "Только посты с вложениями" +enableServiceworker: "Включить ServiceWorker" +antennaUsersDescription: "Пишите каждое название аккаута на отдельной строке" +caseSensitive: "С учётом регистра" +withReplies: "Включая ответы" +connectedTo: "Вы подключены к следующим аккаунтам" +notesAndReplies: "Посты и ответы" +withFiles: "Посты с файлами" +silence: "Заглушить" +silenceConfirm: "Вы уверены что хотите заглушить этого пользователя?" +unsilence: "Снять глушение" +unsilenceConfirm: "Снять глушение с этого пользователя? Уверены?" +popularUsers: "Популярные пользователи" +recentlyUpdatedUsers: "Активные последнее время" +recentlyRegisteredUsers: "Недавно зарегистрированные пользователи" +recentlyDiscoveredUsers: "Недавно обнаруженные пользователи" +exploreUsersCount: "Пользователей: {count}" +exploreFediverse: "Исследуйте Fediverse" +popularTags: "Популярные теги" +userList: "Списки" +about: "Описание" +aboutIceshrimp: "О Iceshrimp" +administrator: "Администратор" +token: "Токен" +twoStepAuthentication: "Двухфакторная аутентификация" +moderator: "Модератор" +moderation: "Модерация" +nUsersMentioned: "Упомянуло пользователей: {n}" +securityKey: "Ключ безопасности" +securityKeyName: "Имя ключа" +registerSecurityKey: "Зарегистрировать защитный ключ" +lastUsed: "Последнее использование" +unregister: "Отписаться" +passwordLessLogin: "Настроить вход без пароля" +resetPassword: "Сброс пароля" +newPasswordIs: "Новый пароль — «{password}»" +reduceUiAnimation: "Уменьшить анимацию в пользовательском интерфейсе" +share: "Поделиться" +notFound: "Не найдено" +notFoundDescription: "Страница по указанной ссылке не найдена." +uploadFolder: "Место загрузки по умолчанию" +cacheClear: "Очистка кэша" +markAsReadAllNotifications: "Отметить все уведомления как прочитанные" +markAsReadAllUnreadNotes: "Отметить все посты как прочитанные" +markAsReadAllTalkMessages: "Отметить все реплики как прочитанные" +help: "Помощь" +inputMessageHere: "Введите сообщение здесь" +close: "Закрыть" +group: "Группа" +groups: "Группы" +createGroup: "Создать группу" +ownedGroups: "Собственные группы" +joinedGroups: "Участие в группах" +invites: "Приглашения" +groupName: "Название группы" +members: "Участники" +transfer: "Отдать" +messagingWithUser: "Общение с другим пользователем" +messagingWithGroup: "Общение в группе" +title: "Заголовок" +text: "Текст" +enable: "Включить" +next: "Дальше" +retype: "Введите ещё раз" +noteOf: "Что пишет {user}" +inviteToGroup: "Пригласить в группу" +quoteAttached: "Цитата" +quoteQuestion: "Хотите добавить цитату?" +noMessagesYet: "Пока ни одного сообщения" +newMessageExists: "Новое сообщение" +onlyOneFileCanBeAttached: "К сообщению можно прикрепить только один файл" +signinRequired: "Пожалуйста, войдите" +invitations: "Приглашения" +invitationCode: "Код приглашения" +checking: "Проверка..." +available: "Доступно" +unavailable: "Не доступно" +usernameInvalidFormat: "Можно использовать только латинские буквы (A—Z, a—z), цифры + (0—9) и знак подчёркивания (_)." +tooShort: "Слишком короткий" +tooLong: "Слишком длинный" +weakPassword: "Слабый пароль" +normalPassword: "Годный пароль" +strongPassword: "Надёжный пароль" +passwordMatched: "Совпали" +passwordNotMatched: "Не совпадают" +signinWith: "Использовать {x} для входа" +signinFailed: "Невозможно войти в систему. Введенное вами имя пользователя или пароль + неверны." +tapSecurityKey: "Нажмите на свой электронный ключ" +or: "или" +language: "Язык" +uiLanguage: "Язык интерфейса" +groupInvited: "Приглашение в группу" +aboutX: "Описание {x}" +useOsNativeEmojis: "Использовать эмодзи операционной системы" +disableDrawer: "Не использовать выдвижные меню" +youHaveNoGroups: "У вас нет ни одной группы" +joinOrCreateGroup: "Получайте приглашения в группы или создавайте свои собственные." +noHistory: "История пока пуста" +signinHistory: "Журнал посещений" +disableAnimatedMfm: "Отключение анимированной разметки MFM" +doing: "В процессе..." +category: "Категория" +tags: "Метки" +docSource: "Источник документа" +createAccount: "Новая учётная запись" +existingAccount: "Существующая учётная запись" +regenerate: "Создать повторно" +fontSize: "Размер шрифта" +noFollowRequests: "Нерассмотренные запросы на подписку отсутствуют" +openImageInNewTab: "Открыть изображение в новой вкладке" +dashboard: "Панель управления" +local: "С этого сайта" +remote: "С других сайтов" +total: "Всего" +weekOverWeekChanges: "За неделю" +dayOverDayChanges: "За день" +appearance: "Внешний вид" +clientSettings: "Настройки клиента" +accountSettings: "Настройки учетной записи" +promotion: "Продвинуто" +promote: "Продвинуть" +numberOfDays: "Количество дней" +hideThisNote: "Спрятать эту запись" +showFeaturedNotesInTimeline: "Показывать в ленте посты из «Горячего»" +objectStorage: "Хранилище" +useObjectStorage: "Использовать объектное хранилище" +objectStorageBaseUrl: "Базовый адрес" +objectStorageBaseUrlDesc: "URL используемый для примера. Укажите URL-адрес вашего + CDN или прокси, если вы используете любой из них.\nДля S3 используйте 'https://.s3.amazonaws.com', + а для GCS и подобных сервисов используйте 'https://storage.googleapis.com/', + и т.п." +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "Укажите название контейнера (Bucket) который используется + на выбранном сервисе." +objectStoragePrefix: "Префикс" +objectStoragePrefixDesc: "Файлы будут храниться в директории, соответствующей указанному + здесь префиксу пути." +objectStorageEndpoint: "Конечная точка" +objectStorageEndpointDesc: "Если используете AWS S3, оставьте пустым. В остальных + случаях укажите конечную точку (endpoint) в форме «» или «:», + так, как это описано в руководстве той службы, которую собираетесь использовать." +objectStorageRegion: "Регион" +objectStorageRegionDesc: "Укажите регион, например xx-east-1. Если ваша служба не + различает регионы, оставьте поле пустым, или впишите us-east-1." +objectStorageUseSSL: "Использовать SSL" +objectStorageUseSSLDesc: "Отключите, если не собираетесь использовать протокол HTTPS + для обмена по API" +objectStorageUseProxy: "Использовать прокси" +objectStorageUseProxyDesc: "Отключите, если не будете испоьзовать прокси для соединений + по протоколу ObjectStorage" +objectStorageSetPublicRead: "Устанавливать public-read при загрузке на сервер" +serverLogs: "Журнал сервера" +deleteAll: "Удалить всё" +showFixedPostForm: "Показывать поле для ввода нового поста наверху ленты" +newNoteRecived: "Появился новый пост" +sounds: "Звуки" +listen: "Слушать" +none: "Ничего" +showInPage: "Показать страницу" +popout: "Развернуть" +volume: "Громкость" +masterVolume: "Основная регулировка громкости" +details: "Подробнее" +chooseEmoji: "Выберите эмодзи" +unableToProcess: "Не удаётся завершить операцию" +recentUsed: "Последние использованные" +install: "Установить" +uninstall: "Удалить" +installedApps: "Установленные приложения" +nothing: "Ничего нет" +installedDate: "Дата установки" +lastUsedDate: "Дата использования" +state: "Состояние" +sort: "Сортировать" +ascendingOrder: "по возрастанию" +descendingOrder: "По убыванию" +scratchpad: "Когтеточка" +scratchpadDescription: "«Когтеточка» — это место для опытов с AiScript. Здесь можно + писать программы, взаимодействующие с Iceshrimp, запускать и смотреть что из этого + получается." +output: "Выходы" +script: "Скрипт" +disablePagesScript: "Отключить скрипты на «Страницах»" +updateRemoteUser: "Обновить данные пользователя с его сервера" +deleteAllFiles: "Удалить все файлы" +deleteAllFilesConfirm: "Вы хотите удалить все файлы?" +removeAllFollowing: "Удалить всех подписчиков" +removeAllFollowingDescription: "Отменить все подписки с домена {host}? Пожалуйста, + применяйте это действие, если сервер больше не существует." +userSuspended: "Эта учётная запись заморожена." +userSilenced: "Этот пользователь был заглушен." +yourAccountSuspendedTitle: "Эта учетная запись заблокирована" +yourAccountSuspendedDescription: "Эта учетная запись была заблокирована из-за нарушения + условий предоставления услуг сервера. Свяжитесь с администратором, если вы хотите + узнать более подробную причину. Пожалуйста, не создавайте новую учетную запись." +menu: "Меню" +divider: "Линия-разделитель" +addItem: "Добавить элемент" +relays: "Ретрансляторы" +addRelay: "Добавить ретранслятор" +inboxUrl: "URL ящика входящих сообщений" +addedRelays: "Добавленные ретрансляторы" +serviceworkerInfo: "Нужно включить, чтобы работали push-уведомления." +deletedNote: "Удалённый пост" +invisibleNote: "Личное сообщение" +enableInfiniteScroll: "Включить бесконечную прокрутку" +visibility: "Видимость" +poll: "Опрос" +useCw: "Скрывать содержимое под предупреждением" +enablePlayer: "Включить проигрыватель" +disablePlayer: "Выключить проигрыватель" +expandTweet: "Развернуть твит" +themeEditor: "Редактор темы оформления" +description: "Описание" +describeFile: "Добавить подпись" +enterFileDescription: "Введите подпись" +author: "Автор" +leaveConfirm: "Вы не сохранили изменения. Хотите выйти и потерять их?" +manage: "Управление" +plugins: "Расширения" +preferencesBackups: "Резервная копия" +deck: "Пульт" +undeck: "Покинуть пульт" +useBlurEffectForModal: "Размывка под формой поверх всего" +useFullReactionPicker: "Полнофункциональный выбор реакций" +width: "Ширина" +height: "Высота" +large: "Крупно" +medium: "Средне" +small: "Мелко" +generateAccessToken: "Создать токен доступа" +permission: "Разрешения" +enableAll: "Включить все" +disableAll: "Выключить всё" +tokenRequested: "Открыть доступ к учётной записи" +pluginTokenRequestedDescription: "Это расширение сможет пользоваться разрешениями, + установленными здесь." +notificationType: "Тип уведомления" +edit: "Изменить" +emailServer: "Сервер электронной почты" +enableEmail: "Включить обмен электронной почтой" +emailConfigInfo: "Используется для подтверждения адреса электронной почты и сброса + пароля" +email: "Электронная почта" +emailAddress: "Адрес электронной почты" +smtpConfig: "Конфигурация SMTP-сервера" +smtpHost: "Хост" +smtpPort: "Порт" +smtpUser: "Имя пользователя" +smtpPass: "Пароль" +emptyToDisableSmtpAuth: "Не заполняйте имя пользователя и пароль, чтобы отключить + аутентификацию в SMTP" +smtpSecure: "Использовать SSL/TLS для SMTP-соединений" +smtpSecureInfo: "Выключите при использовании STARTTLS" +testEmail: "Проверка доставки электронной почты" +wordMute: "Скрытие слов" +regexpError: "Ошибка в регулярном выражении" +instanceMute: "Глушение серверов" +userSaysSomething: "{name} что-то сообщает" +makeActive: "Активировать" +display: "Отображение" +copy: "Копировать" +metrics: "Метрики" +overview: "Обзор" +logs: "Журналы" +delayed: "Задержка" +database: "База данных" +channel: "Каналы" +create: "Создать" +notificationSetting: "Настройки уведомлений" +notificationSettingDesc: "Выберите тип уведомлений для отображения." +useGlobalSetting: "Использовать глобальные настройки" +useGlobalSettingDesc: "Если включено, будут использоваться настройки учётной записи. + Если включить, этот виджет можно будет настроить индивидуально." +other: "Другие" +regenerateLoginToken: "Создать новый токен для входа" +regenerateLoginTokenDescription: "Создаёт новый токен, используемый внутри программы + во время входа. Обычно в этом нет необходимости. При создании все устройства будут + отключены." +setMultipleBySeparatingWithSpace: "Можно написать несколько через пробел." +fileIdOrUrl: "Идентификатор файла или ссылка" +behavior: "Поведение" +sample: "Пример" +abuseReports: "Жалобы" +reportAbuse: "Жалоба" +reportAbuseOf: "Пожаловаться на пользователя {name}" +fillAbuseReportDescription: "Опишите, пожалуйста, причину жалобы подробнее. Если речь + о конкретном посте, будьте добры приложить ссылку на неё." +abuseReported: "Жалоба отправлена. Большое спасибо за информацию." +reporteeOrigin: "О ком сообщено" +reporterOrigin: "Кто сообщил" +forwardReport: "Переслать отчет на удалённый сервер" +forwardReportIsAnonymous: "Удаленный сервер не сможет увидеть вашу личную информацию + — отчёт будет отображаться как отправленный от анонимной системная учетной записи." +send: "Отправить" +abuseMarkAsResolved: "Отметить жалобу как решённую" +openInNewTab: "Открыть в новой вкладке" +openInSideView: "Открывать в боковой колонке" +defaultNavigationBehaviour: "Поведение навигации по умолчанию" +editTheseSettingsMayBreakAccount: "От изменений в этих настройках ваша учётная запись + может поломаться." +instanceTicker: "Информация про записи на сервере" +waitingFor: "Ждём, когда {x} ответит" +random: "Случайные" +system: "Система" +switchUi: "Выбор вида" +desktop: "Компьютер" +clip: "Подборка" +createNew: "Новый документ" +optional: "Необязательно" +createNewClip: "Новая подборка" +public: "Общедоступно" +i18nInfo: "Iceshrimp переводят на разные языки добровольцы со всего света. Ваша помощь + тоже пригодится здесь: {link}." +manageAccessTokens: "Управление токенами доступа" +accountInfo: "Сведения об учётной записи" +notesCount: "Количество постов" +repliesCount: "Сколько раз пользователь кому-то ответил" +renotesCount: "Сколько раз пользователь делился постами" +repliedCount: "Сколько раз ответили пользователю" +renotedCount: "Сколько раз делились постами пользователя" +followingCount: "Количество подписок" +followersCount: "Количество подписавшихся" +sentReactionsCount: "Количество реакций пользователя" +receivedReactionsCount: "Количество реакций на посты пользователя" +pollVotesCount: "Сколько раз пользователь участвовал в опросах" +pollVotedCount: "Сколько раз участвовали в опросах пользователя" +yes: "Да" +no: "Нет" +driveFilesCount: "Количество файлов на диске" +driveUsage: "Занято места на диске" +noCrawle: "Запретить паукам индексировать сайт" +noCrawleDescription: "Просьба поисковым системам не ходить по вашему профилю, по постам, + страницам и не индексировать их." +lockedAccountInfo: "Даже если вы вручную подтверждаете подписки, кто угодно может + читать ваши посты, если вы не отмечаете их «для подписчиков»." +alwaysMarkSensitive: "Отмечать файлы как «содержимое не для всех» по умолчанию" +loadRawImages: "Сразу показывать изображения в полном размере" +disableShowingAnimatedImages: "Не проигрывать анимацию" +verificationEmailSent: "Вам отправлено письмо для подтверждения. Пройдите, пожалуйста, + по ссылке из письма, чтобы завершить проверку." +notSet: "Не настроено" +emailVerified: "Адрес электронной почты подтверждён" +noteFavoritesCount: "Количество добавленного в избранное" +pageLikesCount: "Количество понравившихся страниц" +pageLikedCount: "Количество страниц, понравившихся другим" +contact: "Как связаться" +useSystemFont: "Использовать шрифт, предлагаемый системой" +clips: "Подборки" +experimentalFeatures: "Экспериментальные функции" +developer: "Разработчик" +makeExplorable: "Опубликовать профиль в «Обзоре»" +makeExplorableDescription: "Если выключить, ваш профиль не будет показан в разделе + «Обзор»." +showGapBetweenNotesInTimeline: "Показывать разделитель между постами в ленте" +duplicate: "Дубликат" +left: "Влево" +center: "По центру" +wide: "Толстый" +narrow: "Тонкий" +reloadToApplySetting: "Это настройка вступает в силу при загрузке страницы. Перезагрузить + сейчас?" +needReloadToApply: "Изменения вступят в силу после перезагрузки страницы." +showTitlebar: "Показать заголовок" +clearCache: "Очистить кэш" +onlineUsersCount: "Пользователей сейчас в сети: {n}" +nUsers: "Пользователей: {n}" +nNotes: "Постов: {n}" +sendErrorReports: "Посылать отчёты о сбоях" +sendErrorReportsDescription: "Если включено, когда возникнет какая-нибудь техническая + проблема, подробные сведения об этом будут отправлены разработчикам Iceshrimp.\n Это + очень помогает делать программу лучше. В отчёты попадают тип и версия ОС, браузера, + журнал действий (что привело к сбою) и тому подобное." +myTheme: "Личная тема" +backgroundColor: "Фон" +accentColor: "Акцент" +textColor: "Текст" +saveAs: "Сохранить под названием…" +advanced: "Для продвинутых" +value: "Значения" +createdAt: "Создано" +updatedAt: "Обновлено" +saveConfirm: "Сохранить изменения?" +deleteConfirm: "Удалить?" +invalidValue: "Недопустимое значение." +registry: "Реестр" +closeAccount: "Закрыть учётную запись" +currentVersion: "Используемая версия" +latestVersion: "Самая свежая версия" +youAreRunningUpToDateClient: "У вас самая свежая версия клиента." +newVersionOfClientAvailable: "Доступна более свежая версия клиента." +usageAmount: "Использовано" +capacity: "Ёмкость" +inUse: "Занято" +editCode: "Редактировать исходный текст" +apply: "Применить" +receiveAnnouncementFromInstance: "Получать оповещения с этого сервера" +emailNotification: "Уведомления по электронной почте" +publish: "Опубликовать" +inChannelSearch: "Поиск по каналу" +useReactionPickerForContextMenu: "Открывать палитру реакций правой кнопкой" +typingUsers: "{users} печатает" +jumpToSpecifiedDate: "Перейти к заданной дате" +showingPastTimeline: "Отображается старая лента" +clear: "Очистить" +markAllAsRead: "Отметить всё как прочитанное" +goBack: "Выход" +unlikeConfirm: "В самом деле отменить «нравится»?" +fullView: "Полный вид" +quitFullView: "Закрыть полный вид" +addDescription: "Добавить описание" +userPagePinTip: "Можно добавить сюда посты, выбрав нужный, и включив в её меню пункт + «Закрепить в профиле»." +notSpecifiedMentionWarning: "В этом посте есть упоминание тех, кто не включён в адресаты" +info: "Описание" +userInfo: "Сведения о пользователе" +unknown: "Неизвестно" +onlineStatus: "Присутствие в сети" +hideOnlineStatus: "Скрыть присутствие" +hideOnlineStatusDescription: "Сокрытие присутствия делает некоторые функции, такие + как поиск, менее удобными." +online: "В сети" +active: "Действует" +offline: "Не в сети" +notRecommended: "Не рекомендуется" +botProtection: "Ботозащита" +instanceBlocking: "Управление федерацией" +selectAccount: "Выберите учётную запись" +switchAccount: "Сменить учётную запись" +enabled: "Включено" +disabled: "Отключено" +quickAction: "Быстрое действие" +user: "Пользователи" +administration: "Управление" +accounts: "Учётные записи" +switch: "Переключение" +noMaintainerInformationWarning: "Не заполнены сведения об администраторах." +noBotProtectionWarning: "Ботозащита не настроена." +configure: "Настроить" +postToGallery: "Опубликовать в галерею" +gallery: "Галерея" +recentPosts: "Недавние публикации" +popularPosts: "Популярные публикации" +shareWithNote: "Поделиться постом" +ads: "Реклама" +expiration: "Опрос длится" +memo: "Памятка" +priority: "Приоритет" +high: "Высокий" +middle: "Средне" +low: "Низкий" +emailNotConfiguredWarning: "Не указан адрес электронной почты." +ratio: "Соотношение" +previewNoteText: "Предварительный просмотр" +customCss: "Индивидуальный CSS" +customCssWarn: "Используйте эту настройку только если знаете, что делаете. Ошибки + здесь чреваты тем, что сайт перестанет нормально работать у вас." +global: "Всеобщая" +squareAvatars: "Квадратные аватарки" +sent: "Отправить" +received: "Получено" +searchResult: "Результаты поиска" +hashtags: "Хэштег" +troubleshooting: "Разрешение проблем" +useBlurEffect: "Размытие в интерфейсе" +learnMore: "Подробнее" +iceshrimpUpdated: "Iceshrimp обновился!" +whatIsNew: "Показать изменения" +translate: "Перевод" +translatedFrom: "Перевод. Язык оригинала — {x}" +accountDeletionInProgress: "В настоящее время выполняется удаление учетной записи" +usernameInfo: "Имя, которое отличает вашу учетную запись от других на этом сервере. + Вы можете использовать алфавит (a~z, A~Z), цифры (0~9) или символы подчеркивания + (_). Имена пользователей не могут быть изменены позже." +aiChanMode: "ИИ режим" +keepCw: "Сохраняйте Предупреждения о содержимом" +pubSub: "Учётные записи Pub/Sub" +lastCommunication: "Последнее сообщение" +resolved: "Решено" +unresolved: "Без решения" +breakFollow: "Отписка" +itsOn: "Включено" +itsOff: "Выключено" +emailRequiredForSignup: "Для регистрации учётной записи нужен адрес электронной почты" +unread: "Непрочитанное" +filter: "Фильтры" +controlPanel: "Панель управления" +manageAccounts: "Управление аккаунтом" +makeReactionsPublic: "Опубликовать список реакций" +makeReactionsPublicDescription: "Список сделанных вами реакций доступен для просмотра + всем желающим." +classic: "Центрированный" +muteThread: "Заглушить цепочку" +unmuteThread: "Отменить глушение цепочки" +ffVisibility: "Видимость подписок и подписчиков" +ffVisibilityDescription: "Здесь можно настроить, кто будет видеть ваши подписки и + подписчиков." +continueThread: "Показать следующие ответы" +deleteAccountConfirm: "Учётная запись будет безвозвратно удалена. Подтверждаете?" +incorrectPassword: "Пароль неверен." +voteConfirm: "Отдать голос за «{choice}»?" +hide: "Спрятать" +leaveGroup: "Покинуть группу" +leaveGroupConfirm: "Покинуть группу «{name}»?" +useDrawerReactionPickerForMobile: "Выдвижная палитра на мобильном устройстве" +clickToFinishEmailVerification: "Пожалуйста, нажмите [{ok}], чтобы завершить подтверждение + адреса электронной почты." +overridedDeviceKind: "Тип устройства" +smartphone: "Смартфон" +tablet: "Планшет" +auto: "Автоматически" +themeColor: "Цвет темы сервера" +size: "Размер" +numberOfColumn: "Количество столбцов" +searchByGoogle: "Поиск" +instanceDefaultLightTheme: "Светлая тема по умолчанию для всего сервера" +instanceDefaultDarkTheme: "Темная тема по умолчанию для всего сервера" +indefinitely: "вечно" +file: "Файлы" +recommended: "Рекомендуем" +check: "Проверить" +driveCapOverrideLabel: "Изменение лимита дискового пространства для этого пользователя" +reverse: "Переворот" +colored: "Выделена цветом" +label: "Метка" +localOnly: "Локально" +beta: "Бета" +enableAutoSensitive: "Автоматическое определение NSFW" +enableAutoSensitiveDescription: "Если доступно, используйте машинное обучение для + автоматической установки флага NSFW на носителе. Даже если эта функция отключена, + она может быть установлена автоматически в зависимости от инстанта." +account: "Учётные записи" +_sensitiveMediaDetection: + description: "Машинное обучение может быть использовано для автоматического обнаружения + чувствительных медиа для модерации. Нагрузка на сервер увеличивается незначительно." + setSensitiveFlagAutomatically: "Установить флаг NSFW" + sensitivity: Чувствительность обнаружения + sensitivityDescription: Снижение чувствительности приведет к меньшему количеству + ошибочных обнаружений (ложноположительных результатов), в то время как ее увеличение + приведет к меньшему количеству пропущенных обнаружений (ложноотрицательных результатов). + setSensitiveFlagAutomaticallyDescription: Результаты внутреннего обнаружения будут + сохранены, даже если эта опция отключена. + analyzeVideos: Включить анализ видео + analyzeVideosDescription: Анализирует видео в дополнение к изображениям. Это немного + увеличит нагрузку на сервер. +_emailUnavailable: + used: "Уже используется" + format: "Неверный формат" + disposable: "Временный адрес электронной почты не принимается" + mx: "Неверный почтовый сервер" + smtp: "Почтовый сервер не отвечает" +_ffVisibility: + public: "Общедоступны" + followers: "Показываются только подписчикам" + private: "Показываются только вам" +_signup: + almostThere: "Почти готово" + emailAddressInfo: "Введите ваш адрес электронной почты." + emailSent: "На указанный вами адрес электронной почты ({email}) отправлено письмо. + Перейдите по ссылке в письме, чтобы завершить регистрацию." +_accountDelete: + accountDelete: "Удалить свою учётную запись" + mayTakeTime: "Удаление учётной записи — ресурсозатратный процесс. Он может занять + много времени, если вы много писали и загружали файлов." + sendEmail: "Когда ваша учетная запись будет удалена, мы сообщим на указанную вами + электронную почту." + requestAccountDelete: "Запросить удаление вашей учетной записи" + started: "Процесс удаления начался." + inProgress: "Удаление в процессе" +_ad: + back: "Выход" + reduceFrequencyOfThisAd: "Реже показывать эту рекламу" +_forgotPassword: + enterEmail: "Введите адрес электронной почты, который ввели при регистрации. На + неё будет выслана ссылка для смены пароля." + ifNoEmail: "Если вы не ввели свой адрес электронной почты, свяжитесь с администратором + ресурса, чтобы сменить пароль." + contactAdmin: "Здесь не используются адреса электронной почты, так что свяжитесь + с администратором, чтобы поменять пароль." +_gallery: + my: "Личная" + liked: "Понравившееся" + like: "Нравится" + unlike: "Отменить «нравится»" +_email: + _follow: + title: "Новый подписчик" + _receiveFollowRequest: + title: "Новый запрос на подписку" +_plugin: + install: "Установка расширений" + installWarn: "Пожалуйста, не устанавливайте расширения, которым не доверяете." + manage: "Управление расширениями" +_registry: + scope: "Область" + key: "Ключ" + keys: "Ключ" + domain: "Домен" + createKey: "Новый ключ" +_aboutIceshrimp: + about: "Iceshrimp это форк Iceshrimp, сделанный ThatOneCalculator, разработка которого + началась с 2022." + contributors: "Основные соавторы" + allContributors: "Все соавторы" + source: "Исходный код" + translation: "Перевод Iceshrimp" + donate: "Пожертвование на Iceshrimp" + morePatrons: "Большое спасибо и многим другим, кто принял участие в этом проекте! + 🥰" + patrons: "Материальная поддержка" +_nsfw: + respect: "Скрывать содержимое не для всех" + ignore: "Показывать содержимое не для всех" + force: "Скрывать вообще все файлы" +_mfm: + cheatSheet: "Подсказка по разметке MFM" + intro: "MFM — язык оформления текста,используемый в Iceshrimp, Iceshrimp, Akkoma и готов + для применения во многих местах. На этой странице собраны и кратко изложены способы + его использовать." + dummy: "Iceshrimp расширяет границы Федиверса" + mention: "Упоминание" + mentionDescription: "При помощи знака «собака» перед именем можно упомянуть какого-нибудь + пользователя." + hashtag: "Хэштег" + hashtagDescription: "При помощи знака «решётка» перед словом задаётся хэштег." + url: "Простая ссылка (URL)" + urlDescription: "Ссылки могут отображаться непосредственно." + link: "Ссылка с пояснением" + linkDescription: "Можно ссылку оформить в виде произвольного текста." + bold: "Жирный шрифт" + boldDescription: "Выделяет текст, делая буквы жирнее." + small: "Мелкий шрифт" + smallDescription: "Делает текст маленьким и незаметным." + center: "Выровнять элементы по центру" + centerDescription: "Так можно выровнять что-то по центру." + inlineCode: "Программа (в тексте)" + inlineCodeDescription: "Подсвечивает фрагмент программы внутри сплошного текста." + blockCode: "Программа (блок)" + blockCodeDescription: "Оформляет текст программы в виде отдельного блокоа. Он может + состоять из множества строк." + inlineMath: "Математическое выражение (в тексте)" + inlineMathDescription: "Позволяет вставлять математические выражения внутрь текста + при помощи языка KaTeX" + blockMath: "Математическое выражение (блок)" + blockMathDescription: "Оформляет математическое выражение (KaTeX) на отдельной строке" + quote: "Цитата" + quoteDescription: "Так можно процитировать чей-то текст." + emoji: "Собственные эмодзи" + emojiDescription: "Можно вставить эмодзи в текст, окружив название двоеточиями." + search: "Поиск" + searchDescription: "Можно добавить форму для поиска, сразу задав, что искать." + flip: "Переворот" + flipDescription: "Позволяет отразить текст зеркально по вертикали или горизонтали." + jelly: "Анимация желе (шлёп-плёп)" + jellyDescription: "Напоминает горку джема, дёргающуюся от шлепков." + tada: "Анимация (та-дам!)" + tadaDescription: "Получается нечто выпрыгивающее, как бы крича: «а вот и я!»." + jump: "Анимация прыжков (прыг-скок)" + jumpDescription: "Побуждает радостно подпрыгивать." + bounce: "Анимация отскоков (бум-бум)" + bounceDescription: "Это будет скакать как мяч." + shake: "Анимация дрожи (б-р-р-р)" + shakeDescription: "Такое дрожит, словно от холода. Или от страха." + twitch: "Анимация тряски" + twitchDescription: "Заставляет трястись как одержимого." + spin: "Вращение" + spinDescription: "Так можно крутить содержимое в разных направлениях." + x2: "Крупный шрифт" + x2Description: "Увеличивает содержимое." + x3: "Ещё крупнее" + x3Description: "Сильнее увеличивает содержимое." + x4: "Совсем крупно" + x4Description: "Увеличивает содержимое совсем сильно." + blur: "Размытие" + blurDescription: "Размывает текст до нечитаемости, будто его поместили за матовое + стекло. Наведение указателя мыши на размытый текст возвращает чёткость." + font: "Шрифт" + fontDescription: "Так можно писать произвольным шрифтом." + rainbow: "Радуга" + rainbowDescription: "Заставлять содержимое отображаться в цветах радуги." + sparkle: "Искры" + sparkleDescription: "Добавляет эффект искрящихся частиц." + rotate: "Повернуть" + rotateDescription: "Поворачивает на заданный угол." + plain: Обычный текст + plainDescription: Деактивирует эффекты всех MFM, содержащихся в этом эффекте MFM. +_instanceTicker: + none: "Не показывать" + remote: "Только для других сайтов" + always: "Показывать всегда" +_serverDisconnectedBehavior: + reload: "Автоматическая перезагрузка" + dialog: "Предупреждение" + quiet: "Показать ненавязчивое предупреждение" + nothing: Ничего не делать +_channel: + create: "Создать канал" + edit: "Редактировать канал" + setBanner: "Установить баннер" + removeBanner: "Удалить баннер" + featured: "Актуальные" + owned: "Собственные" + following: "Подписки" + usersCount: "Участников: {n}" + notesCount: "Постов: {n}" +_menuDisplay: + sideFull: "Сторона" + sideIcon: "Сторона (иконки)" + top: "Вверх" + hide: "Спрятать" +_wordMute: + muteWords: "Скрыть слово" + muteWordsDescription: "Пишите слова через пробел в одной строке, чтобы фильтровать + их появление вместе; а если хотите фильтровать любое из них, пишите в отдельных + строках." + muteWordsDescription2: "Здесь можно использовать регулярные выражения — просто заключите + их между двумя дробными чертами (/)." + softDescription: "Соответствующие условиям посты будут спрятаны из вашей ленты." + hardDescription: "Соответстующие условиям посты вообще не будут попадать в вашу + ленту. Даже если вы поменяете условия, отсеенные таким образом посты уже не появятся." + soft: "Мягкий" + hard: "Жёсткий" + mutedNotes: "Скрытые посты" +_instanceMute: + heading: "Список заглушенных инстансов" + instanceMuteDescription2: Разделить переносом строки + instanceMuteDescription: Это будет скрывать все посты/репосты с указанных инстансов, + включая ответы пользователю с заглушенного инстанса. + title: Скрывает посты с указанных инстансов. +_theme: + explore: "Обзор" + install: "Установить тему" + manage: "Менеджер тем" + code: "Код темы" + description: "Описание" + installed: "Тема «{name}» установлена" + installedThemes: "Установленные темы" + builtinThemes: "Встроенные темы" + alreadyInstalled: "Тема уже установлена" + invalid: "Формат темы некорректный" + make: "Создать тему" + base: "Основа" + addConstant: "Добавить константу" + constant: "Константа" + defaultValue: "По умолчанию" + color: "Цвет" + refProp: "Ссылка на свойство" + refConst: "Ссылка на константу" + key: "Ключ" + func: "Функции" + funcKind: "Тип функции" + argument: "Аргумент" + basedProp: "Исходное свойство" + alpha: "Непрозрачность" + darken: "Затемнение" + lighten: "Осветление" + inputConstantName: "Введите имя для константы" + importInfo: "Если вы введете код темы здесь, вы можете импортировать его в редактор + тем" + deleteConstantConfirm: "Вы действительно хотите удалить константу {const}?" + keys: + accent: "Акцент" + bg: "Фон" + fg: "Текст" + focus: "Фокус" + indicator: "Индикатор" + panel: "Панель" + shadow: "Тень" + header: "Заголовок" + navBg: "Фон боковой панели" + navFg: "Текст на боковой панели" + navHoverFg: "Текст на боковой панели (под указателем)" + navActive: "Текст на боковой панели (активирован)" + navIndicator: "Индикатор на боковой панели" + link: "Ссылка" + hashtag: "Хэштег" + mention: "Упоминание" + mentionMe: "Упоминания вас" + renote: "Репост" + modalBg: "Фон формы поверх страницы" + divider: "Разделитель" + scrollbarHandle: "Ползунок прокрутки" + scrollbarHandleHover: "Ползунок прокрутки (под указателем)" + dateLabelFg: "Текст отметки даты" + infoBg: "Фон сообщения" + infoFg: "Текст сообщения" + infoWarnBg: "Фон предупреждения" + infoWarnFg: "Текст предупреждения" + cwBg: "Фон предупреждения о содержимом" + cwFg: "Текст предупреждения о содержимом" + cwHoverBg: "Фон предупреждения о содержимом (под указателем)" + toastBg: "Фон оповещения" + toastFg: "Текст оповещения" + buttonBg: "Фон кнопки" + buttonHoverBg: "Текст кнопки" + inputBorder: "Рамка поля ввода" + listItemHoverBg: "Фон пункта списка (под указателем)" + driveFolderBg: "Фон папки «Диска»" + wallpaperOverlay: "Слой обоев" + badge: "Значок" + messageBg: "Фон беседы" + accentDarken: "Фон (затемнённый)" + accentLighten: "Фон (осветлённый)" + fgHighlighted: "Подсвеченный текст" +_sfx: + note: "Новый пост" + noteMy: "Собственные посты" + notification: "Уведомления" + chat: "Сообщения" + chatBg: "Сообщения (фон)" + antenna: "Антенна" + channel: "Канал" +_ago: + future: "Из будущего" + justNow: "Только что" + secondsAgo: "{n} с назад" + minutesAgo: "{n} мин {n2} с назад" + hoursAgo: "{n} ч {n2} мин назад" + daysAgo: "{n} сут {n2} ч назад" + weeksAgo: "{n} нед. {n2} сут назад" + monthsAgo: "{n} мес {n2} нед. назад" + yearsAgo: "{n} г {n2} мес. назад" +_time: + second: "с" + minute: "мин" + hour: "ч" + day: "сут" +_tutorial: + title: "Как использовать Iceshrimp" + step1_1: "Добро пожаловать!" + step1_2: "Давайте настроим вас. Вы будете работать в кратчайшие сроки!" + step2_1: "Сначала, пожалуйста, заполните свой профиль." + step2_2: "Предоставив некоторую информацию о себе, другим людям будет легче понять, + хотят ли они видеть ваши записи или следить за вами." + step3_1: "Теперь пора следить за некоторыми людьми!" + step3_2: "Ваша домашняя и социальная ленты основаны на том, за кем вы следите, поэтому + для начала попробуйте следить за парой аккаунтов.\nНажмите на кружок с плюсом + в правом верхнем углу профиля, чтобы следить за ним." + step4_1: "Давайте выйдем на вас." + step4_2: "Для своего первого сообщения некоторые люди любят делать {introduction} + сообщение или простое \"Hello world!\"" + step5_1: "Временные рамки, везде временные рамки!" + step5_2: "В вашем экземпляре включены {timelines} различных временных линий." + step5_3: "Главная {icon} лента - это лента, где вы можете видеть сообщения ваших + подписок и других на этом инстансе. Если вы хотите чтобы главная лента показывала + только посты ваших подписок вы можете легко это изменить в настройках!" + step5_4: "Местная {icon} лента - это лента где вы можете видеть сообщения всех остальных + пользователей данного инстанса." + step5_5: "Лента Социальная {icon} - это лента, где вы можете видеть посты только + от аккаунтов, на которые вы подписаны." + step5_6: "Лента Рекомендованная {icon} это лента, где вы можете видеть посты с инстансов, + рекомендованных администраторами." + step5_7: "Глобальная {icon} лента - это место, где вы можете видеть сообщения от + всех других подключенных экземпляров." + step6_1: "Итак, что это за место?" + step6_2: "Ну, вы не просто присоединились к Кальки. Вы присоединились к порталу + в Fediverse, взаимосвязанной сети из тысяч серверов, называемых \"инстансами\"\ + ." + step6_3: "Каждый сервер работает по-своему, и не на всех серверах работает Iceshrimp. + Но этот работает! Это немного сложно, но вы быстро разберетесь." + step6_4: "Теперь идите, изучайте и развлекайтесь!" +_2fa: + alreadyRegistered: "Двухфакторная аутентификация уже настроена." + registerTOTP: "Зарегистрируйте ваше устройство" + registerSecurityKey: "Зарегистрировать ключ" + step1: "Прежде всего, установите на устройство приложение для аутентификации, например, + {a} или {b}." + step2: "Далее отсканируйте отображаемый QR-код при помощи приложения." + step3: "И наконец, введите код, который покажет приложение." + step4: "Теперь при каждом входе на сайт вам нужно будет вводить код из приложения + аналогичным образом." + securityKeyInfo: "Вы можете настроить вход с помощью аппаратного ключа безопасности, + поддерживающего FIDO2, или отпечатка пальца или PIN-кода на устройстве." + step2Url: 'Вы также можете ввести этот URL если используете программу на компьютере:' +_permissions: + "read:account": "Просматривать данные учётной записи" + "write:account": "Изменять данные учётной записи" + "read:blocks": "Смотреть список блокировок" + "write:blocks": "Изменять список блокировок" + "read:drive": "Смотреть содержимое «диска»" + "write:drive": "Изменять содержимое «диска»" + "read:favorites": "Смотреть список избранного" + "write:favorites": "Изменять список избранного" + "read:following": "Смотреть спискок подписок" + "write:following": "Изменять спискок подписок" + "read:messaging": "Смотреть сообщения" + "write:messaging": "Писать и удалять сообщения" + "read:mutes": "Смотреть спискок скрытых пользователей" + "write:mutes": "Изменять список скрытых пользователей" + "write:notes": "Писать и удалять посты" + "read:notifications": "Смотреть уведомления" + "write:notifications": "Изменять уведомления" + "read:reactions": "Смотреть реакции" + "write:reactions": "Изменять реакции" + "write:votes": "Голосовать" + "read:pages": "Смотреть страницы" + "write:pages": "Изменять и удалять страницы" + "read:page-likes": "Смотреть добавления страниц в избранное" + "write:page-likes": "Изменять добавления страниц в избранное" + "read:user-groups": "Смотреть группы пользователей" + "write:user-groups": "Изменять и удалять группы пользователей" + "read:channels": "Смотреть каналы" + "write:channels": "Изменять каналы" + "read:gallery": "Просмотр галереи" + "write:gallery": "Редактирование галереи" + "read:gallery-likes": "Просмотр списка понравившегося в галерее" + "write:gallery-likes": "Изменение списка понравившегося в галерее" +_auth: + shareAccess: "Дать доступ для «{name}» к вашей учётной записи?" + shareAccessAsk: "Уверены, что хотите дать приложению доступ к своей учётной записи?" + permissionAsk: "Приложение запрашивает следующие разрешения" + pleaseGoBack: "Вернитесь, пожалуйста, в приложение" + callback: "Возврат в приложение" + denied: "Доступ закрыт" + copyAsk: Пожалуйста, вставьте следующий код авторизации в приложение +_antennaSources: + all: "Все посты" + homeTimeline: "Посты тех на которых вы подписаны" + users: "Посты выбранных пользователей" + userList: "Посты пользователей из выбранных списков" + userGroup: "Посты от пользователей из заданной группы" + instances: Посты от всех пользователей на инстансе +_weekday: + sunday: "Воскресенье" + monday: "Понедельник" + tuesday: "Вторник" + wednesday: "Среда" + thursday: "Четверг" + friday: "Пятница" + saturday: "Суббота" +_widgets: + memo: "Напоминания" + notifications: "Уведомления" + timeline: "Лента" + calendar: "Календарь" + trends: "Актуальное" + clock: "Часы" + rss: "Просмотр RSS" + activity: "Активность" + photos: "Фото" + digitalClock: "Цифровые часы" + federation: "Федерация" + postForm: "Форма отправки" + slideshow: "Показ слайдов" + button: "Кнопка" + onlineUsers: "Пользователи сейчас с сети" + jobQueue: "Очередь заданий" + serverMetric: "Показатели сервера" + aiscript: "Консоль AiScript" + aichan: "Ай" + rssTicker: RSS-тикер + unixClock: UNIX часы + userList: Список пользователей + _userList: + chooseList: Выберите список +_cw: + hide: "Спрятать" + show: "Показать еще" + chars: "знаков: {count}" + files: "файлов: {count}" +_poll: + noOnlyOneChoice: "Нужно хотя бы два варианта" + choiceN: "Выбор {n}" + noMore: "Больше вариантов добавить нельзя" + canMultipleVote: "Можно выбрать несколько вариантов" + expiration: "Опрос длится" + infinite: "вечно" + at: "Заканчивается..." + after: "Заканчивается после..." + deadlineDate: "Дата окончания" + deadlineTime: "Время" + duration: "Длительность" + votesCount: "Голосов: {n}" + totalVotes: "Голосов всего: {n}" + vote: "Проголосовать" + showResult: "Смотреть результаты" + voted: "Голос отдан" + closed: "Завершено" + remainingDays: "Осталось {d} сут {h} ч" + remainingHours: "Осталось {h} ч {m} мин" + remainingMinutes: "Осталось {m} мин {s} с" + remainingSeconds: "Осталось {s} с" +_visibility: + public: "Общедоступно" + publicDescription: "Открыто для всех" + home: "Скрытый" + homeDescription: "Не для общих лент" + followers: "Для подписчиков" + followersDescription: "Только вашим подписчикам" + specified: "Личное" + specifiedDescription: "Тем, кого укажете" + localOnly: "Локально" + localOnlyDescription: "Только для этого сайта" +_postForm: + replyPlaceholder: "Ответ на пост..." + quotePlaceholder: "Пояснение к цитате..." + channelPlaceholder: "Отправить в канал..." + _placeholders: + a: "Как дела?" + b: "Что интересного вокруг?" + c: "Что грызёт тебя, дружище?" + d: "Есть что сказать?" + e: "Напишите что-нибудь…" + f: "В ожидании, когда вы напишете…" +_profile: + name: "Имя" + username: "Имя пользователя" + description: "О себе" + youCanIncludeHashtags: "Можете использовать здесь хэштеги." + metadata: "Дополнительные сведения" + metadataEdit: "Редактировать дополнительные сведения" + metadataDescription: "Можно добавить до четырёх дополнительных граф в профиль. Вы + можете добавить тег {a} или тег {l} с {rel}, чтобы подтвердить ссылку в своем + профиле!" + metadataLabel: "Метка" + metadataContent: "Содержимое" + changeAvatar: "Поменять аватар" + changeBanner: "Поменять изображение в шапке" + locationDescription: Если вы сначала введете свой город, другим пользователям будет + показано ваше местное время. +_exportOrImport: + allNotes: "Все посты" + followingList: "Подписки" + muteList: "Скрытые" + blockingList: "Заблокированные" + userLists: "Списки" + excludeMutingUsers: "За исключением заглушенных пользователей" + excludeInactiveUsers: "Без неактивных учётных записей" +_charts: + federation: "Федерация" + apRequest: "Запросы" + usersIncDec: "Изменение числа пользователей" + usersTotal: "Количество пользователей" + activeUsers: "Активные пользователи" + notesIncDec: "Изменение числа постов" + localNotesIncDec: "Изменения числа локальных постов" + remoteNotesIncDec: "Изменения числа постов с других сайтов" + notesTotal: "Общее количество постов" + filesIncDec: "Изменения числа файлов" + filesTotal: "Суммарное количество файлов" + storageUsageIncDec: "Изменения заполнения хранилища" + storageUsageTotal: "Суммарное заполнение хранилища" +_instanceCharts: + requests: "Запросы" + users: "Изменение числа пользователей" + usersTotal: "Суммарное количество пользователей" + notes: "Изменение числа постов" + notesTotal: "Суммарное количество постов" + ff: "Изменения числа подписчиков " + ffTotal: "Суммарное количество подписчиков" + cacheSize: "Изменения размера кэша" + cacheSizeTotal: "Суммарный размер кэша" + files: "Изменения числа файлов" + filesTotal: "Суммарное количество файлов" +_timelines: + home: "Персональная" + local: "Местная" + social: "Социальная" + global: "Всеобщая" + recommended: Рекомендованная +_pages: + newPage: "Создать страницу" + editPage: "Править страницу" + readPage: "Читать страницу" + created: "Страница успешно создана" + updated: "Страница успешно обновлена" + deleted: "Страница успешно удалена" + pageSetting: "Настройки страницы" + nameAlreadyExists: "Указанный адрес страницы уже существует" + invalidNameTitle: "Указанный адрес страницы недопустим" + invalidNameText: "Проверьте, что не оставили поле пустым" + editThisPage: "Правка этой страницы" + viewSource: "Просмотр исходника" + viewPage: "Смотреть страницы" + like: "Нравится" + unlike: "Отменить «нравится»" + my: "Свои страницы" + liked: "Понравившиеся страницы" + featured: "Популярные" + inspector: "Инспектор" + contents: "Содержимое" + content: "Содержимое" + variables: "Переменные" + title: "Заголовок" + url: "Адрес страницы" + summary: "Краткое содержание" + alignCenter: "Выровнять элементы по центру" + hideTitleWhenPinned: "Скрыть заголовок страницы при привязке к профилю" + font: "Шрифт" + fontSerif: "Антиква (с засечками)" + fontSansSerif: "Гротеск (без засечек)" + eyeCatchingImageSet: "Добавить картинку для привлечения внимания" + eyeCatchingImageRemove: "Убрать картинку для привлечения внимания" + chooseBlock: "Добавить блок" + selectType: "Выберите вид" + enterVariableName: "Ведите имя переменной" + variableNameIsAlreadyUsed: "Это имя уже есть у другой переменной" + contentBlocks: "Содержательные" + inputBlocks: "Для ввода" + specialBlocks: "Особые" + blocks: + text: "Текст" + textarea: "Текст в рамке" + section: "Раздел" + image: "Изображения" + button: "Кнопка" + if: "Условный" + _if: + variable: "Переменная" + post: "Создание поста" + _post: + text: "Текст" + attachCanvasImage: "Прикрепить изображение с холста" + canvasId: "Метка холста" + textInput: "Поле ввода текста" + _textInput: + name: "Имя переменной" + text: "Подпись" + default: "Исходное содержимое" + textareaInput: "Многострочное поле ввода текста" + _textareaInput: + name: "Имя переменной" + text: "Подпись" + default: "Исходное содержимое" + numberInput: "Поле для ввода числа" + _numberInput: + name: "Имя переменной" + text: "Подпись" + default: "Исходное значение" + canvas: "Холст" + _canvas: + id: "Метка холста" + width: "Ширина" + height: "Высота" + note: "Встроенный пост" + _note: + id: "Идентификатор поста" + idDescription: "Можно также вставить ссылку на пост." + detailed: "Подробный вид" + switch: "Выключатель" + _switch: + name: "Имя переменной" + text: "Подпись" + default: "Исходное содержимое" + counter: "Кнопка со счётчиком" + _counter: + name: "Имя переменной" + text: "Надпись" + inc: "Увеличивать на" + _button: + text: "Надпись" + colored: "Выделена цветом" + action: "Действие по нажатию" + _action: + dialog: "Показать всплывающий текст" + _dialog: + content: "Всплывающий текст" + resetRandom: "Сброс генератора случайности" + pushEvent: "Вызвать событие" + _pushEvent: + event: "Имя события" + message: "Сообщение при нажатии" + variable: "Передать переменную с событием" + no-variable: "нет" + callAiScript: "Вызвать AiScript" + _callAiScript: + functionName: "Имя функции" + radioButton: "Кнопка-переключатель" + _radioButton: + name: "Имя переменной" + title: "Заголовок" + values: "Значения" + default: "Исходное значение" + script: + categories: + flow: "Управление исполнением" + logical: "Логические" + operation: "Арифметические" + comparison: "Сравнение" + random: "Случайные" + value: "Значения" + fn: "Функции" + text: "Текстовые" + convert: "Преобразование" + list: "Список" + blocks: + text: "Строка текста" + multiLineText: "Многострочный текст" + textList: "Список строк текста" + _textList: + info: "Пишите каждый пункт с новой строки" + strLen: "Длина текста" + _strLen: + arg1: "Текст" + strPick: "Взять знак из текста" + _strPick: + arg1: "Текст" + arg2: "Позиция знака" + strReplace: "Замена текста" + _strReplace: + arg1: "Текст, в котором заменять" + arg2: "Заменяемый текст" + arg3: "Менять на" + strReverse: "В обратном порядке" + _strReverse: + arg1: "Текст" + join: "Объединение" + _join: + arg1: "Списки" + arg2: "Разделитель" + add: "Добавить" + _add: + arg1: "A" + arg2: "B" + subtract: "Вычитание" + _subtract: + arg1: "A" + arg2: "B" + multiply: "Умножение" + _multiply: + arg1: "A" + arg2: "B" + divide: "Деление" + _divide: + arg1: "A" + arg2: "B" + mod: "Остаток от деления" + _mod: + arg1: "A" + arg2: "B" + round: "Округление до целого" + _round: + arg1: "Число" + eq: "A равно B" + _eq: + arg1: "А" + arg2: "B" + notEq: "A не равно B" + _notEq: + arg1: "A" + arg2: "B" + and: "A и B" + _and: + arg1: "A" + arg2: "B" + or: "A или B" + _or: + arg1: "A" + arg2: "B" + lt: "A < B (меньше)" + _lt: + arg1: "A" + arg2: "B" + gt: "A > B (больше)" + _gt: + arg1: "A" + arg2: "B" + ltEq: "A ⩽ B (меньше или равно)" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: "A ⩾ B (больше или равно)" + _gtEq: + arg1: "A" + arg2: "B" + if: "Условный" + _if: + arg1: "Условие" + arg2: "Если правда" + arg3: "Если ложь" + not: "Отрицание" + _not: + arg1: "Условие" + random: "Случайность" + _random: + arg1: "Вероятность" + rannum: "Случайное число" + _rannum: + arg1: "Минимум" + arg2: "Максимум" + randomPick: "Случайный выбор из списка" + _randomPick: + arg1: "Списки" + dailyRandom: "Случайность (на день для пользователя)" + _dailyRandom: + arg1: "Вероятность" + dailyRannum: "Случайное число (на день для пользователя)" + _dailyRannum: + arg1: "Минимум" + arg2: "Максимум" + dailyRandomPick: "Случайный выбор из списка (на день для пользователя)" + _dailyRandomPick: + arg1: "Списки" + seedRandom: "Псевдослучайность (заданная зерном)" + _seedRandom: + arg1: "Зерно" + arg2: "Вероятность" + seedRannum: "Псевдослучайное число (заданное зерном)" + _seedRannum: + arg1: "Зерно" + arg2: "Минимум" + arg3: "Максимум" + seedRandomPick: "Псевдослучайный выбор из списка (заданный зерном)" + _seedRandomPick: + arg1: "Зерно" + arg2: "Списки" + DRPWPM: "Случайный выбор из взвешенного списка (на день для пользователя)" + _DRPWPM: + arg1: "Список строк текста" + pick: "Выбор из списка" + _pick: + arg1: "Списки" + arg2: "Индекс" + listLen: "Количество элементов в списке" + _listLen: + arg1: "Списки" + number: "Число" + stringToNumber: "Число из текста" + _stringToNumber: + arg1: "Текст" + numberToString: "Число в текст" + _numberToString: + arg1: "Число" + splitStrByLine: "Разделение текста на строки" + _splitStrByLine: + arg1: "Текст" + ref: "Переменная" + aiScriptVar: "Переменная AiScript" + fn: "Свои функции" + _fn: + slots: "Аргументы" + slots-info: "Напишите имя каждого аргумента с новой строки" + arg1: "Формула" + for: "Цикл" + _for: + arg1: "Количество повторений" + arg2: "Действие" + typeError: "Аргумент {slot} должен быть иметь тип «{expect}», а передали «{actual}»!" + thereIsEmptySlot: "Аргумент {slot} не заполнен!" + types: + string: "Текст" + number: "Число" + boolean: "Логический" + array: "Списки" + stringArray: "Список строк текста" + emptySlot: "Пустой аргумент" + enviromentVariables: "Переменная окружения" + pageVariables: "Элемент страницы" + argVariables: "Аргументы" +_relayStatus: + requesting: "В ожидании одобрения" + accepted: "Одобрено" + rejected: "Отказано" +_notification: + fileUploaded: "Файл успешно загружен" + youGotMention: "{name} упоминает вас" + youGotReply: "{name} отвечает вам" + youGotQuote: "{name} цитирует вас" + youRenoted: "{name} репостит ваш пост" + youGotPoll: "{name} участвует в вашем опросе" + youGotMessagingMessageFromUser: "{name} пишет вам" + youGotMessagingMessageFromGroup: "Новое сообщение в группе «{name}»" + youWereFollowed: "У вас новый подписчик" + youReceivedFollowRequest: "У вас новый запрос на подписку" + yourFollowRequestAccepted: "Ваш запрос на подписку одобрен" + youWereInvitedToGroup: "{userName} пригласил вас в группу" + _types: + all: "Все" + follow: "Подписки" + mention: "Упоминания" + reply: "Ответы" + renote: "Репосты" + quote: "Цитаты" + reaction: "Реакции" + pollVote: "Голосования" + receiveFollowRequest: "Получен запрос на подписку" + followRequestAccepted: "Запрос на подписку одобрен" + groupInvited: "Приглашение в группы" + app: "Уведомления из приложений" + pollEnded: Опрос закончен + _actions: + reply: "Ответить" + renote: "Репост" + followBack: Подписался на вас обратно + emptyPushNotificationMessage: Пуш уведомления были обновлены + pollEnded: Результаты опроса стали доступны +_deck: + alwaysShowMainColumn: "Всегда показывать главную колонку" + columnAlign: "Выравнивание колонок" + addColumn: "Добавить колонку" + configureColumn: "Настройки колонок" + swapLeft: "Переставить левее" + swapRight: "Переставить правее" + swapUp: "Переставить выше" + swapDown: "Переставить ниже" + stackLeft: "В столбик влево" + popRight: "Из столбика вправо" + profile: "Воркспейс" + _columns: + main: "Основная" + widgets: "Виджеты" + notifications: "Уведомления" + tl: "Лента" + antenna: "Антенны" + list: "Списки" + mentions: "Упоминания" + direct: "Личное" + deleteProfile: Удалить воркспейс + introduction: Создайте идеальный интерфейс для себя, свободно расположив столбцы! + introduction2: Нажмите на + в правой части экрана, чтобы добавлять новые столбцы + в любое удобное для вас время. + widgetsIntroduction: Пожалуйста, выберите "Редактировать виджеты" в меню столбца + и добавьте виджет. + newProfile: Новый воркспейс + renameProfile: Переименовать воркспейс + nameAlreadyExists: Воркспейс с таким именем уже существует. +enableRecommendedTimeline: Включить рекомендованную ленту +regexpErrorDescription: 'Произошла ошибка в регулярном выражении на строке {line} + вашего {tab} списка скрытых слов:' +confirmToUnclipAlreadyClippedNote: Этот пост уже в подборке "{name}. Хотите ли вы + вместо этого удалить пост из подборки? +unclip: Удалить из подборки +secureMode: Безопасный Режим (Авторизованное Получение) +instanceSecurity: Безопасность сервера +seperateRenoteQuote: Разделить кнопки репоста и цитаты +accountMoved: 'Пользователь переместился на новый аккаунт:' +manageGroups: Управлять группами +allowedInstancesDescription: Список хостов, разрешённых для федерации, каждый разделён + новой строкой (применяется только в приватном режиме). +noThankYou: Нет, спасибо +addInstance: Добавить сервер +flagSpeakAsCat: Говорить как кот +flagSpeakAsCatDescription: Ваши будут посты няифицированы в режиме кота +selectInstance: Выбрать сервер +antennaInstancesDescription: Список серверов, каждый с новой строки +privateMode: Приватный режим +privateModeInfo: Только серверы в белом списке могут федерировать с вашим сервером. + Все посты будут скрыты из публичного доступа. +allowedInstances: Белый список серверов +userSaysSomethingReason: '{name} сказал {reason}' +renoteMute: Заглушить репосты +renoteUnmute: Разглушить репосты +hiddenTags: Скрытые хештеги +noInstances: Нет серверов +secureModeInfo: Не отправлять ответ на запросы с других серверов без подтверждения. +instanceDefaultThemeDescription: Введите код темы в формате объекта. +tenMinutes: 10 минут +oneHour: Один час +thereIsUnresolvedAbuseReportWarning: Есть не рассмотренные жалобы. +cropImage: Обрезать изображение +requireAdminForView: Вы должны войти с аккаунта администратора что просмотреть это. +refreshInterval: 'Интервал обновления ' +slow: Медленно +fast: Быстро +sensitiveMediaDetection: Обнаружение NSFW медиа +remoteOnly: Только другие сайты +navbar: Панель навигации +customMOTD: Своё MOTD (сообщения на заставке) +customMOTDDescription: Пользовательские сообщения для MOTD (заставки), разделенные + разрывами строк, будут отображаться случайным образом каждый раз, когда пользователь + загружает / перезагружает страницу. +recommendedInstancesDescription: Рекомендуемые инстансы, разделенные разрывами строк, + должны отображаться на рекомендуемой ленте. +caption: Автоматическая подпись +splash: Заставка +updateAvailable: Возможно, доступно обновление! +move: Переместить +swipeOnDesktop: Разрешить свайпы в мобильном стиле на десктопе +showAds: Показывать рекламу +noEmailServerWarning: Почтовый сервер не настроен. +type: Тип +numberOfPageCacheDescription: Увеличение этого числа повысит удобство для пользователей, + но приведет к увеличению нагрузки на сервер, а также к использованию большего объема + памяти. +statusbar: Панель статуса +speed: Скорость +oneDay: Один день +oneWeek: Одна неделя +failedToFetchAccountInformation: Не удалось получить информацию о аккаунте +cropImageAsk: Желаете ли вы обрезать это изображение? +recentNHours: Последние {n} часов +recentNDays: Последние {n} дней +typeToConfirm: Введите {x} чтобы подтвердить +document: Документация +logoutConfirm: Действительно выйти? +failedToUpload: Не удалось загрузить +pushNotification: Пуш уведомления +subscribePushNotification: Включить пуш уведомления +unsubscribePushNotification: Отключить пуш уведомления +pushNotificationAlreadySubscribed: Пуш уведомления уже включены +sendPushNotificationReadMessage: Удалять пуш уведомления после того как соответствующие + уведомления или сообщения были прочитаны +customSplashIcons: Свои иконки для заставки (URL) +customSplashIconsDescription: URL-адреса для пользовательских значков заставки, разделенных + разрывами строк, будут отображаться случайным образом каждый раз, когда пользователь + загружает / перезагружает страницу. Пожалуйста, убедитесь, что изображения находятся + на статическом URL-адресе, предпочтительно все с размером 192x192. +logoImageUrl: URL изображения логотипа +showAdminUpdates: Указать, что доступна новая версия Iceshrimp (только для администратора) +replayTutorial: Перезапустить туториал +migration: Миграция +showLocalPosts: 'Показать локальные посты в:' +homeTimeline: Домашняя лента +socialTimeline: Социальная лента +driveCapOverrideCaption: Сбросить до настроек по умолчанию введя значение 0 или меньше. +deleteAccount: Удалить аккаунт +numberOfPageCache: Число кэшируемых страниц +pushNotificationNotSupported: Ваш браузер или инстанс не поддерживает пуш уведомления +sendPushNotificationReadMessageCaption: Уведомление содержащее текст "{emptyPushNotificationMessage}" + будет показано на короткое время. Это может увеличить расход батареи вашего устройства, + если это применимо. +cannotUploadBecauseNoFreeSpace: Загрузка не удалась из-за нехватки места на Диске. +cannotUploadBecauseInappropriate: Этот файл не может быть загружен потому что его + части были обнаружены как потенциальное NSFW. +adminCustomCssWarn: Этот параметр следует использовать только в том случае, если вы + знаете, что он делает. Ввод неправильных значений может привести к тому, что ВСЕ + клиенты перестанут нормально функционировать. Пожалуйста, убедитесь, что ваш CSS + работает должным образом, протестировав его в настройках вашего пользователя. +showUpdates: Показывать всплывающее окно при обновлении Iceshrimp +recommendedInstances: Рекомендованные инстансы +defaultReaction: Эмодзи реакция по умолчанию для выходящих и исходящих постов +license: Лицензия +indexPosts: Индексировать посты +indexFrom: Индексировать начиная с идентификатора поста и далее +indexFromDescription: оставьте пустым для индексации каждого поста +indexNotice: Теперь индексирование. Вероятно, это займет некоторое время, пожалуйста, + не перезагружайте свой сервер по крайней мере в течение часа. +customKaTeXMacro: Кастомные KaTex макросы +enableCustomKaTeXMacro: Включить кастомные KaTeX макросы +noteId: Идентификатор поста +_preferencesBackups: + inputName: Введите имя для этой резервной копии + list: Созданные резервные копии + loadFile: Загрузить из файла + apply: Применить для этого устройства + save: Сохранить изменения + saveNew: Сохранить новую резервную копию + applyConfirm: Вы действительно хотите применить резервную копию "{name}" на этом + устройстве? Существует настройки на этом устройстве будут перезаписаны. + renameConfirm: Переименовать резервную копию "{old}" в "{new}"? + saveConfirm: Сохранить резервную как {name}? + cannotSave: Сохранение не удалось + nameAlreadyExists: Резервная копия с именем "{name}" уже существующует. Выберите + другое имя. + deleteConfirm: Удалить резервную копию {name}? + noBackups: Нет резервных копий. Вы может сделать резервную копию настроек клиента + на этом сервере используя "Создать новую резервную копию". + createdAt: 'Создано: {date} {time}' + updatedAt: 'Обновлено: {date} {time}' + cannotLoad: Загрузка не удалась + invalidFile: Неправильный формат файла +enableEmojiReactions: Включить эмодзи реакции +migrationConfirm: "Вы абсолютно уверены что хотите мигрировать ваш аккаунт на {account}? + Как только вы сделаете, вы не сможете отменить это и не сможете нормально использовать + аккаунт снова.\nТакже, пожалуйста, убедитесь, что вы установили эту текущую учетную + запись в качестве учетной записи, с которой вы переходите." +reporter: Автор жалобы +mutePeriod: Длительность глушения +reflectMayTakeTime: Это может занять некоторое время чтобы вступило в силу. +rateLimitExceeded: Превышен лимит +pleaseSelect: Выберите вариант +shuffle: Перемешать +moveFrom: Переместится на этот аккаунт с старого аккаунта +moveFromLabel: 'Аккаунт с которого перемещаетесь:' +moveAccountDescription: Этот процесс необратим. Убедитесь что вы сделали псевдоним + для этого аккаунта до перемещения. Пожалуйста введите аккаунт в формате @person@instance.com +moveTo: Переместить текущий аккаунт на новый аккаунт +_messaging: + groups: Группы + dms: Личные +isSystemAccount: Эта учетная запись создана и автоматически управляется системой. + Не рекомендуется модерировать, редактировать, удалять или каким либо другим образом + вмешивайтся в эту учётную запись — это может привести к поломке сервера. +activeEmailValidationDescription: Включить более строгую проверки адресов электронной + почты,что включает в себя проверку наличия одноразовых адресов и того, действительно + ли с ними можно связаться. Если флажок снят, проверяется только формат адреса. +moveToLabel: 'Аккаунт на который вы перемещаетесь:' +lastActiveDate: Последний раз использовался в +enterSendsMessage: Нажать Return в Сообщениях чтобы отправить сообщение (если выключено, + то Ctrl + Return) +moveAccount: Переместить аккаунт! +breakFollowConfirm: Вы действительно хотите удалить подписчика? +showEmojisInReactionNotifications: Показывать эмодзи в уведомлениях о реакциях +hiddenTagsDescription: 'Список хештегов (без #), которые вы желаете скрыть из "актуальное" + и "обзор". Скрытые хэштеги по-прежнему можно обнаружить в других местах.' +moveFromDescription: Это установит псевдоним для старого аккаунта, так что вы сможете + переместить тот аккаунт на текущий. Делайте это ДО перемещения со старого аккаунта. + Пожалуйста введите аккаунт в формате @person@instance.com +customKaTeXMacroDescription: 'Настройте макросы чтобы легко писать математические + выражения! Обозначение соответствует определениям команд LaTeX и записывается как + \newcommand{\название}{содержание} или \newcommand{\название}[количество аргументов]{содержание}. + Для примера, \add{3}[2]{#1 + #2} будет раскрывать \add{3}{foo} до 3 + foo. Фигурные + скобки, окружающие имя макроса, можно заменить на круглые или квадратные скобки. + Это влияет на квадратные скобки, используемые для аргументов. Для каждой строки + может быть определен один (и только один) макрос, и вы не можете прерывать строку + в середине определения. Недопустимые строки просто игнорируются. Поддерживаются + только простые функции подстановки строк; расширенный синтаксис, такой как условное + ветвление, здесь использоваться не может.' +cannotUploadBecauseExceedsFileSizeLimit: Этот файл не может быть загружен так как + он превышает максимально разрешённый размер. +apps: Приложения +silenceThisInstance: Заглушить сервер +silencedInstances: Заглушенные серверы +editNote: Редактировать заметку +edited: 'Редактировано в {date} {time}' +deleted: Удалённое +removeReaction: Удалить вашу реакцию +searchPlaceholder: Искать в Iceshrimp +jumpToPrevious: Перейти к предыдущему +listsDesc: Списки позволяют вам создавать ленты с постами указанных пользователей. + Их можно найти на странице «Лента». +silenced: Игнорируется +antennasDesc: "Антенны отображают новые посты, отвечающие указанным критериям!\n К + ним можно перейти со страницы «Лента»." +expandOnNoteClickDesc: Если отключено, вы всё равно сможете открыть пост, воспользовавшись + меню на правой кнопке мыши или кликнув по времени публикации поста. +accessibility: Доступность +silencedInstancesDescription: Список адресов серверов, которые вы хотите заглушить. + Аккаунты на указанных серверах будут считаться «Заглушёнными», смогут только отправлять + запросы на подписку и не смогут упоминать локальных пользователей, если на них не + подписались. Эта настройка не влияет на заблокированные серверы. +clipsDesc: Подборки это категоризированные закладки, которыми можно делиться. Вы можете + создавать подборки из меню у конкретных постов. +alt: ALT +video: Видео +audio: Аудио +selectChannel: Выберите канал +expandOnNoteClick: Открывать пост по клику +channelFederationWarn: Каналы пока не федерируются с другими серверами +image: Изображение +cw: Предупреждение о содержании +xl: Очень крупно diff --git a/locales/sk-SK.yml b/locales/sk-SK.yml new file mode 100644 index 0000000..1c51bea --- /dev/null +++ b/locales/sk-SK.yml @@ -0,0 +1,1733 @@ +--- +_lang_: "Slovenčina" +headlineIceshrimp: "Sieť prepojená poznámkami" +introIceshrimp: "Vitajte! Iceshrimp je otvorená a decentralizovaná mikroblogovacia služba.\n\"Poznámkami\" môžete zdieľať svoje myšlienky so všetkými okolo. 📡\nPomocou \"reakcií\" môžete rýchlo vyjadri svoje pocity o každého poznámkach. 👍\nPoďte objavovať svet! 🚀" +monthAndDay: "{day}. {month}." +search: "Hľadať" +notifications: "Oznámenia" +username: "Meno používateľa" +password: "Heslo" +forgotPassword: "Zabudnuté heslo" +fetchingAsApObject: "Načítam údaje z Fediverzu" +ok: "OK" +gotIt: "Rozumiem!" +cancel: "Zrušiť" +enterUsername: "Zadajte meno používateľa" +renotedBy: "{user} preposlal/a" +noNotes: "Žiadne poznámky" +noNotifications: "Žiadne oznámenia" +instance: "Inštancia" +settings: "Nastavenia" +basicSettings: "Všeobecné nastavenia" +otherSettings: "Rozšírené nastavenia" +openInWindow: "Otvoriť v novom okne" +profile: "Profil" +timeline: "Časová os" +noAccountDescription: "Tento používateľ zatiaľ nenapísal o sebe." +login: "Prihlásiť sa" +loggingIn: "Prebieha prihlasovanie" +logout: "Odhlásiť" +signup: "Registrovať" +uploading: "Nahrávanie..." +save: "Uložiť" +users: "Používatelia" +addUser: "Pridať používateľa" +favorite: "Páči sa mi" +favorites: "Obľúbené" +unfavorite: "Nepáči sa mi" +favorited: "Pridané do obľúbených" +alreadyFavorited: "Už je medzi obľúbenými" +cantFavorite: "Nepodarilo sa pridať medzi obľúbené." +pin: "Pripnúť" +unpin: "Odopnúť" +copyContent: "Kopírovať obsah" +copyLink: "Kopírovať odkaz" +delete: "Odstrániť" +deleteAndEdit: "Odstrániť a upraviť" +deleteAndEditConfirm: "Naozaj chcete odstrániť túto poznámku a upraviť ju? Stratíte tým všetky reakcie a odpovede na ňu." +addToList: "Pridať do zoznamu" +sendMessage: "Odoslať správu" +copyUsername: "Kopírovať meno používateľa" +searchUser: "Hľadať používateľov" +reply: "Odpovedať" +loadMore: "Zobraziť viac" +showMore: "Zobraziť viac" +showLess: "Zavrieť" +youGotNewFollower: "Máte nového sledujúceho" +receiveFollowRequest: "Žiadosť o sledovanie prijatá" +followRequestAccepted: "Žiadosť o sledovanie akceptovaná" +mention: "Zmienka" +mentions: "Zmienky" +directNotes: "Priame poznámky" +importAndExport: "Import a export" +import: "Importovať" +export: "Exportovať" +files: "Súbor/y" +download: "Stiahnuť" +driveFileDeleteConfirm: "Naozaj chcete odstrániť súbor \"{name}\"? Poznámky s týmto súborom sa odstránia tiež." +unfollowConfirm: "Naozaj už nechcete sledovať {name}?" +exportRequested: "Vyžiadali ste export. Môže to chvíľu trvať. Po skončení pribudne na vašom disku." +importRequested: "Požiadali ste o export. Môže to chvíľu trvať." +lists: "Zoznamy" +noLists: "Nemáte žiadne zoznamy" +note: "Poznámka" +notes: "Poznámky" +following: "Sledujete" +followers: "Sledujúci" +followsYou: "Sledujú vás" +createList: "Vytvoriť zoznam" +manageLists: "Spravovať zoznamy" +error: "Chyba" +somethingHappened: "Ups. Niečo sa nepodarilo." +retry: "Opakovať" +pageLoadError: "Nepodarilo sa načítať stránku" +pageLoadErrorDescription: "Toto môže byť spôsobené problémami so sieťou alebo cachou prehliadača. Skúste vyčistiť cache a potom skúsiť znova po chvíli." +serverIsDead: "Tento server nereaguje. Prosím chvíľu počkajte a skúste znova." +youShouldUpgradeClient: "Na pozretie tejto stránky prosím obnovte svojho klienta." +enterListName: "Zadajte názov zoznamu" +privacy: "Súkromie" +makeFollowManuallyApprove: "Žiadosti o sledovanie treba schváliť" +defaultNoteVisibility: "Predvolená viditeľnosť" +follow: "Sledovať" +followRequest: "Požiadať o sledovanie" +followRequests: "Žiadosti o sledovanie" +unfollow: "Nesledovať" +followRequestPending: "Žiadosť o sledovanie čaká" +enterEmoji: "Zadajte emoji" +renote: "Preposlať" +unrenote: "Vrátiť preposlanie" +renoted: "Preposlané." +cantRenote: "Tento príspevok sa nedá preposlať." +cantReRenote: "Odpoveď nemôže byť odstránená." +quote: "Citovať" +pinnedNote: "Pripnuté poznámky" +pinned: "Pripnúť" +you: "Vy" +clickToShow: "Kliknutím zobrazíte" +sensitive: "NSFW" +add: "Pridať" +reaction: "Reakcie" +reactionSetting: "Reakcie zobrazené vo výbere reakcií" +reactionSettingDescription2: "Ťahaním preusporiadate, kliknutím odstránite, Stlačením \"+\" pridáte" +rememberNoteVisibility: "Zapamätať nastavenia viditeľnosti poznámky" +attachCancel: "Odstrániť prílohu" +markAsSensitive: "Označiť ako NSFW" +unmarkAsSensitive: "Odznačiť NSFW" +enterFileName: "Zadajte názov súboru" +mute: "Vypnúť zvuk" +unmute: "Zapnúť zvuk" +block: "Zablokovať" +unblock: "Odblokovať" +suspend: "Zmraziť" +unsuspend: "Odmraziť" +blockConfirm: "Naozaj chcete zablokovať tento účet?" +unblockConfirm: "Naozaj chcete odblokovať tento účet?" +suspendConfirm: "Naozaj chcete zmraziť tento účet?" +unsuspendConfirm: "Naozaj chcete odmraziť tento účet?" +selectList: "Vyberte zoznam" +selectAntenna: "Vyberte anténu" +selectWidget: "Vyberte widget" +editWidgets: "Upraviť widget" +editWidgetsExit: "Hotovo" +customEmojis: "Vlastné emoji" +emoji: "Emoji" +emojis: "Emoji" +emojiName: "Názov emoji" +emojiUrl: "URL obrázku" +addEmoji: "Pridať emoji" +settingGuide: "Odporúčané nastavenia" +cacheRemoteFiles: "Cachovanie vzdialených súborov" +cacheRemoteFilesDescription: "Zakázanie tohoto nastavenia spôsobí, že vzdialené súbory budú odkazované priamo, namiesto ukladania do cache. Ušetrí sa tak miesto na serveri, ale zvýši sa dátový tok, pretože sa negenerujú miniatúry." +flagAsBot: "Tento účet je bot" +flagAsBotDescription: "Ak je tento účet ovládaný programom, zaškrtnite túto voľbu. Ostatní uvidia, že je to bot a zabráni nekonečným interakciám s ďalšími botmi a upraví interné systémy Iceshrimp, aby ho považoval za bota." +flagAsCat: "Tento účet je mačka" +flagAsCatDescription: "Zvoľte túto voľbu, aby bol tento účet označený ako mačka." +flagShowTimelineReplies: "Zobraziť odpovede na poznámky v časovej osi" +flagShowTimelineRepliesDescription: "Keď je zapnuté, na časovej osi sa zobrazia odpovede k poznámkam používateľov okrem samotných poznámok." +autoAcceptFollowed: "Automaticky prijať sledovanie od účtov, ktoré sledujete" +addAccount: "Pridať účet" +loginFailed: "Prihlásenie sa nepodarilo." +showOnRemote: "Zobraziť na vzdialenom serveri" +general: "Všeobecné" +wallpaper: "Tapeta" +setWallpaper: "Nastaviť tapetu" +removeWallpaper: "Odstrániť tapetu" +searchWith: "Hľadať: {q}" +youHaveNoLists: "Nemáte žiadne zoznamy" +followConfirm: "Naozaj chcete sledovať {name}?" +proxyAccount: "Proxy účet" +proxyAccountDescription: "Proxy účet je účet, ktorý za určitých podmienok sleduje používateľov na diaľku vaším menom. Napríklad keď používateľ zaradí vzdialeného používateľa do zoznamu, pokiaľ nikto nesleduje používateľa na zozname, aktivita nebude doručená na server, takže namiesto toho bude používateľa sledova proxy účet." +host: "Host" +selectUser: "Vyberte používateľa" +recipient: "Prijímateľ" +annotation: "Komentáre" +federation: "Federácia" +instances: "Inštancia" +registeredAt: "Registrácia" +latestRequestSentAt: "Posledná odoslaná požiadavka" +latestRequestReceivedAt: "Posledná prijatá požiadavka" +latestStatus: "Posledný status" +storageUsage: "Využité úložisko" +charts: "Grafy" +perHour: "za hodinu" +perDay: "za deň" +stopActivityDelivery: "Zastaviť posielanie aktivít" +blockThisInstance: "Blokovať tento server" +operations: "Operácie" +software: "Softvér" +version: "Verzia" +metadata: "Metadáta" +monitor: "Monitor" +jobQueue: "Fronta úloh" +cpuAndMemory: "CPU a pamäť" +network: "Sieť" +disk: "Disk" +instanceInfo: "Informácie o serveri" +statistics: "Štatistiky" +clearQueue: "Vyčistiť frontu" +clearQueueConfirmTitle: "Naozaj chcete zrušiť všetky úlohy vo fronte?" +clearQueueConfirmText: "Všetky nedoručené poznámky čakajúce vo fronte nebudú federované. Zvyčajne táto operácia nie je potrebná." +clearCachedFiles: "Vyprázdniť cache" +clearCachedFilesConfirm: "Naozaj chcete odstrániť všetky nacachované vzdialené súbory?" +blockedInstances: "Blokované servery" +blockedInstancesDescription: "Zoznam blokovaných serverov na riadkoch. Blokované servery nebudú môcť komunikovať s týmto serverom." +muteAndBlock: "Umlčania a blokácie" +mutedUsers: "Umlčaní používatelia" +blockedUsers: "Blokovaní používatelia" +noUsers: "Žiadni používatelia" +editProfile: "Upraviť profil" +noteDeleteConfirm: "Naozaj chcete odstrániť túto poznámku?" +pinLimitExceeded: "Ďalšie poznámky už nemôžete pripnúť." +intro: "Inštalácia Iceshrimp je dokončená! Prosím vytvorte administrátora." +done: "Hotovo" +processing: "Pracujem..." +preview: "Náhľad" +default: "Predvolené" +defaultValueIs: "Predvolené: {value}" +noCustomEmojis: "Žiadne emoji" +noJobs: "Žiadne úlohy" +federating: "Federácia" +blocked: "Blokované" +suspended: "Zmrazené" +all: "Všetko" +subscribing: "Odoberanie" +publishing: "Zverejňovanie" +notResponding: "Neodpovedá" +instanceFollowing: "Sledujem na serveri" +instanceFollowers: "Sledujúci zo servera" +instanceUsers: "Používatelia servera" +changePassword: "Zmeniť heslo" +security: "Zabezpečenie" +retypedNotMatch: "Zadané vstupy nesúhlasia" +currentPassword: "Aktuálne heslo" +newPassword: "Nové heslo" +newPasswordRetype: "Nové heslo (znovu)" +attachFile: "Priložiť súbor" +more: "Viac!" +featured: "Obľúbené poznámky" +usernameOrUserId: "Meno používateľa alebo ID používateľa" +noSuchUser: "Používateľ sa nenašiel" +lookup: "Vyhľadať" +announcements: "Oznamy" +imageUrl: "URL obrázku" +remove: "Odstrániť" +removed: "Odstránené" +removeAreYouSure: "Naozaj chcete odstrániť \"{x}\"?" +deleteAreYouSure: "Naozaj chcete odstrániť \"{x}\"?" +resetAreYouSure: "Naozaj resetovať?" +saved: "Uložené" +messaging: "Chat" +upload: "Nahrať súbor" +keepOriginalUploading: "Zachovať pôvodný obrázok" +keepOriginalUploadingDescription: "Uloží pôvodný obrázok ako je. Ak je vypnuté, verzia pre web sa vygeneruje pri nahratí." +fromDrive: "Z disku" +fromUrl: "Z URL" +uploadFromUrl: "Nahrať z URL adresy" +uploadFromUrlDescription: "URL adresa nahrávaného súboru" +uploadFromUrlRequested: "Upload vyžiadaný" +uploadFromUrlMayTakeTime: "Nahrávanie môže nejaký čas trvať." +explore: "Objavovať" +messageRead: "Prečítané" +noMoreHistory: "To je všetko" +startMessaging: "Začať chat" +nUsersRead: "prečítané {n} používateľmi" +agreeTo: "Súhlasím s {0}" +tos: "Podmienky používania" +start: "Začať" +home: "Domov" +remoteUserCaution: "Tieto informácie nemusia byť aktuálne, keďže používateľ je na vzdialenom serveri." +activity: "Aktivita" +images: "Obrázky" +birthday: "Dátum narodenia" +yearsOld: "{age} rokov" +registeredDate: "Dátum registrácie" +location: "Lokalita" +theme: "Téma" +themeForLightMode: "Téma pri svetlom režime" +themeForDarkMode: "Téma pri tmavom režime" +light: "Svetlá" +dark: "Tmavá" +lightThemes: "Svetlá téma" +darkThemes: "Tmavá téma" +syncDeviceDarkMode: "Synchronizovať tmavú tému s nastavení vášho systému" +drive: "Disk" +fileName: "Názov súboru" +selectFile: "Vyberte súbor" +selectFiles: "Vyberte súbory" +selectFolder: "Vyberte priečinok" +selectFolders: "Vyberte priečinky" +renameFile: "Premenovať súbor" +folderName: "Názov priečinka" +createFolder: "Vytvoriť priečinok" +renameFolder: "Premenovať priečinok" +deleteFolder: "Odstrániť priečinok" +addFile: "Pridať súbor" +emptyDrive: "Váš disk je prázdny" +emptyFolder: "Tento priečinok je prázdny" +unableToDelete: "Nedá sa odstrániť" +inputNewFileName: "Zadajte nový názov" +inputNewDescription: "Zadajte nový popis" +inputNewFolderName: "Zadajte nový názov priečinka" +circularReferenceFolder: "Cieľový priečinok je podpriečinkom priečinka, ktorý chcete presunúť." +hasChildFilesOrFolders: "Nemôžete odstrániť priečinok sú súbormi." +copyUrl: "Kopírovať URL" +rename: "Premenovať" +avatar: "Avatar" +banner: "BAnner" +nsfw: "NSFW" +whenServerDisconnected: "Keď sa stratí spojenie so serverom" +disconnectedFromServer: "Spojenie so serverom bolo prerušené" +reload: "Obnoviť" +doNothing: "Ignorovať" +reloadConfirm: "Chcete obnoviť časovú os?" +watch: "Sledovať" +unwatch: "Nesledovať" +accept: "Súhlasím" +reject: "Nesúhlasím" +normal: "Normálne" +instanceName: "Názov servera" +instanceDescription: "Popis servera" +maintainerName: "Správca" +maintainerEmail: "E-mailová adresa správcu" +tosUrl: "URL zmluvných podmienok" +thisYear: "Rok" +thisMonth: "Mesiac" +today: "Dnes" +dayX: "{day}" +monthX: "{month}" +yearX: "{year}" +pages: "Stránky" +integration: "Integrácia" +connectService: "Pripojiť" +disconnectService: "Odpojiť" +enableLocalTimeline: "Povoliť lokálnu časovú os" +enableGlobalTimeline: "Povoliť globálnu časovú os" +disablingTimelinesInfo: "Administrátori a moderátori majú vždy prístup ku všetkým časovým osiam, aj keď sú vypnuté." +registration: "Registrácia" +enableRegistration: "Povoliť registráciu nových používateľov" +invite: "Pozvať" +driveCapacityPerLocalAccount: "Kapacita disku pre používateľa" +driveCapacityPerRemoteAccount: "Kapacita disku pre vzdialeného používateľa" +inMb: "V megabajtoch" +iconUrl: "Favicon URL" +bannerUrl: "URL obrázku bannera" +backgroundImageUrl: "URL obrázku pozadia" +basicInfo: "Základné informácie" +pinnedUsers: "Pripnutí používatelia" +pinnedUsersDescription: "Zoznam mien používateľov oddelených riadkami, ktorý budú pripnutí v záložke \"Objavovať\"." +pinnedPages: "Pripnuté stránky" +pinnedPagesDescription: "Na každý riadok zadajte cesty stránok, ktoré chcete pripnúť na vrch stránky tohoto servera." +pinnedClipId: "ID pripnutého klipu" +pinnedNotes: "Pripnuté poznámky" +hcaptcha: "hCaptcha" +enableHcaptcha: "Zapnúť hCaptchu" +hcaptchaSiteKey: "Site key" +hcaptchaSecretKey: "Secret key" +recaptcha: "reCAPTCHA" +enableRecaptcha: "Zapnúť ReCAPTCHA" +recaptchaSiteKey: "Site key" +recaptchaSecretKey: "Secret key" +avoidMultiCaptchaConfirm: "Použitie viacerých Captcha systémov môže sposobiť problémy. Chcete radšej vypnúť ostatné Captcha systémy? Môžete ich povoliť viaceré stlačení Zrušiť." +antennas: "Antény" +manageAntennas: "Spravovať antény" +name: "Názov" +antennaSource: "Zdroj antény" +antennaKeywords: "Počúvané kľúčové slová" +antennaExcludeKeywords: "Vylúčené kľúčové slová" +antennaKeywordsDescription: "Oddeľte medzerami pre podmienku AND alebo novými riadkami pre podmienku OR." +notifyAntenna: "Upozorniť na nové poznámky" +withFileAntenna: "Len poznámky so súbormi" +enableServiceworker: "Povoliť Service Worker" +antennaUsersDescription: "Zoznam používateľov jeden na riadok" +caseSensitive: "Rozlišuje malé a veľké písmená" +withReplies: "Vrátane odpovedí" +connectedTo: "Nasledujúce účty sú pripojené" +notesAndReplies: "Poznámky a odpovede" +withFiles: "Vrátane súborov" +silence: "Ticho" +silenceConfirm: "Naozaj chcete utíšiť tohoto používateľa?" +unsilence: "Vrátiť utíšenie" +unsilenceConfirm: "Naozaj chcete vrátiť utíšenie tohoto používateľa?" +popularUsers: "Populárni používatelia" +recentlyUpdatedUsers: "Používatelia s najnovšou aktivitou" +recentlyRegisteredUsers: "Najnovší používatelia" +recentlyDiscoveredUsers: "Naposledy objavení používatelia" +exploreUsersCount: "Existuje {count} používateľov" +exploreFediverse: "Objavovať Fediverzum" +popularTags: "Populárne značky" +userList: "Zoznamy" +about: "Informácie" +aboutIceshrimp: "O Iceshrimp" +administrator: "Administrátor" +token: "Token" +twoStepAuthentication: "Dvojfaktorová autentifikácia" +moderator: "Moderátor" +moderation: "Moderovanie" +nUsersMentioned: "{n} používateľov spomenulo" +securityKey: "Bezpečnostný kľúč" +securityKeyName: "Názov kľúča" +registerSecurityKey: "Registrovať bezpečnostný kľúč" +lastUsed: "Naposledy použité" +unregister: "Odregistrovať" +passwordLessLogin: "Nastaviť bezheslové prihlásenie" +resetPassword: "Resetovať heslo" +newPasswordIs: "Nové heslo je \"{password}\"" +reduceUiAnimation: "Menej UI animácií" +share: "Zdieľať" +notFound: "Nenájdené" +notFoundDescription: "Nenašla sa žiadna stránka na zadanej URL." +uploadFolder: "Predvolený priečinok pre nahrávanie" +cacheClear: "Vyčistiť cache" +markAsReadAllNotifications: "Označiť všetky oznámenia ako prečítané" +markAsReadAllUnreadNotes: "Označiť všetky poznámky ako prečítané" +markAsReadAllTalkMessages: "Označiť všetky správy ako prečítané" +help: "Pomoc" +inputMessageHere: "Sem napíšte správu" +close: "Zavrieť" +group: "Skupina" +groups: "Skupiny" +createGroup: "Vytvoriť skupinu" +ownedGroups: "Vlastnené skupiny" +joinedGroups: "Členstvo v skupinách" +invites: "Pozvať" +groupName: "Názov skupiny" +members: "Členovia" +transfer: "Presun" +messagingWithUser: "Súkromný chat" +messagingWithGroup: "Skupinový chat" +title: "Nadpis" +text: "Text" +enable: "Povoliť" +next: "Ďalší" +retype: "Zadajte znovu" +noteOf: "Poznámky používateľa {user}" +inviteToGroup: "Pozvať do skupiny" +quoteAttached: "Citované" +quoteQuestion: "Pripojiť ako citát?" +noMessagesYet: "Zatiaľ žiadne správy" +newMessageExists: "Máte novú správu" +onlyOneFileCanBeAttached: "Ku správe môžete priložiť len jeden súbor" +signinRequired: "Prihláste sa, prosím!" +invitations: "Pozvať" +invitationCode: "Kód pozvánky" +checking: "Overujem..." +available: "Dostupné" +unavailable: "Nedostupné" +usernameInvalidFormat: "Povolené sú písmená, čísla a _." +tooShort: "Príliš krátke" +tooLong: "Príliš dlhé" +weakPassword: "Slabé heslo" +normalPassword: "Dobré heslo" +strongPassword: "Silné heslo" +passwordMatched: "Heslá sú rovnaké" +passwordNotMatched: "Heslá nie sú rovnaké" +signinWith: "Prihlásiť sa použitím {x}" +signinFailed: "Nedá sa prihlásiť. Skontrolujte prosím meno používateľa a heslo." +tapSecurityKey: "Ťuknite na bezpečnostný kľúč" +or: "Alebo" +language: "Jazyk" +uiLanguage: "Jazyk používateľského prostredia" +groupInvited: "Pozvať do skupiny" +aboutX: "O {x}" +useOsNativeEmojis: "Používať natívne emoji z OS" +disableDrawer: "Nepoužívať šuflíkové menu" +youHaveNoGroups: "Nemáte žiadne skupiny" +joinOrCreateGroup: "Požiadajte o pozvanie do existujúcej skupiny alebo vytvorte novú." +noHistory: "Žiadna história" +signinHistory: "História prihlásení" +disableAnimatedMfm: "Vypnúť MFM s animáciou" +doing: "Pracujem..." +category: "Kategórie" +tags: "Značky" +docSource: "Zdroj tohoto dokumentu" +createAccount: "Vytvoriť účet" +existingAccount: "Existujúci účet" +regenerate: "Pregenerovať" +fontSize: "Veľkosť písma" +noFollowRequests: "Nemáte nijaké čakajúce žiadosti o sledovanie" +openImageInNewTab: "Otvoriť obrázok v novom tabe" +dashboard: "Prehľad" +local: "Lokálne" +remote: "Vzdialené" +total: "Celkom" +weekOverWeekChanges: "Medzitýždňové zmeny" +dayOverDayChanges: "Medzidenné zmeny" +appearance: "Vzhľad" +clientSettings: "Nastavenia klienta" +accountSettings: "Nastavenia účtu" +promotion: "Propagácia" +promote: "Propagovať" +numberOfDays: "Počet dní" +hideThisNote: "Skryť túto poznámku" +showFeaturedNotesInTimeline: "Zobraziť významné poznámky v časovej osi" +objectStorage: "Objektové úložisko" +useObjectStorage: "Použiť objektové úložisko" +objectStorageBaseUrl: "Základná URL" +objectStorageBaseUrlDesc: "URL použitá ako referencia. Zadajte URL svojho CDN alebo Proxy ak niektoré používate. S3: 'https://.s3.amazonaws.com', GCS: 'https://storage.googleapis.com/' atď." +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "Prosím zadajte názov bucketu od svojho poskytovateľa." +objectStoragePrefix: "Prefix" +objectStoragePrefixDesc: "Súbory budú ukladané do priečinkov pod týmto prefixom." +objectStorageEndpoint: "Endpoint" +objectStorageEndpointDesc: "Nechajte prázdne ak používate AWS S3, inak zadajte endpoint ako \"\" alebo \":\". Záleží to od služby, ktorú používate." +objectStorageRegion: "Región" +objectStorageRegionDesc: "Zadajte región ako 'xx-east-1'. Ak vaša služba nerozlišuje regióny, nechajte prázdne alebo zadajte 'us-east-1'." +objectStorageUseSSL: "Použiť SSL" +objectStorageUseSSLDesc: "Vypnite to ak nechcete použiť HTTPS na API spojenia." +objectStorageUseProxy: "Pripájať cez Proxy" +objectStorageUseProxyDesc: "Vypnite ak nechcete, aby spojenia na API išli cez Proxy" +objectStorageSetPublicRead: "Pri nahratí nastaviť \"public-read\"" +serverLogs: "Logy servera" +deleteAll: "Odstrániť všetko" +showFixedPostForm: "Zobraziť formulár na nové príspevky nad časovou osou" +newNoteRecived: "Sú nové poznámky" +sounds: "Zvuky" +listen: "Počúvať" +none: "Žiadne" +showInPage: "Zobraziť v stránke" +popout: "Pop-out" +volume: "Hlasitosť" +masterVolume: "Celková hlasitosť" +details: "Detaily" +chooseEmoji: "Vybrať emoji" +unableToProcess: "Operáciu sa nepodarilo dokončiť." +recentUsed: "Neposledy použité" +install: "Nainštalovať" +uninstall: "Odinštalovať" +installedApps: "Autorizované aplikácie" +nothing: "Nič tu nie je" +installedDate: "Dátum autorizácie" +lastUsedDate: "Naposledy použité" +state: "Status" +sort: "Zoradiť" +ascendingOrder: "Vzostupne" +descendingOrder: "Zostupne" +scratchpad: "Zápisník" +scratchpadDescription: "Zápisník poskytuje prostredia pre experimenty s AiScriptom. Môžete písať, spúšťať a skúšať vysledky pri interakcii s Iceshrimp." +output: "Výstup" +script: "Skript" +disablePagesScript: "Vypnúť AiScript na stránkach" +updateRemoteUser: "Aktualizovať informácie o vzdialenom účte" +deleteAllFiles: "Odstrániť všetky súbory" +deleteAllFilesConfirm: "Naozaj chcete odstrániť všetky súbory" +removeAllFollowing: "Zrušiť sledovani všetkých používateľov" +removeAllFollowingDescription: "Týmto zrušíte sledovanie všetkých používateľov z {host}. Spustite to prosím, keď server napríklad už neexistuje." +userSuspended: "Tento používateľ je zmrazený." +userSilenced: "Tento používateľ je umlčaný." +yourAccountSuspendedTitle: "Tento účet je zmrazený" +yourAccountSuspendedDescription: "Tento účet bol zmrazený, lebo porušoval zmluvné podmienky. Kontaktujte administrátora ak chcete viac podrobností. Prosím nevytvárajte nový účet." +menu: "Menu" +divider: "Oddeľovač" +addItem: "Pridať položku" +relays: "Prenos" +addRelay: "Pridať prenos" +inboxUrl: "Inbox URL" +addedRelays: "Pridané prenosy" +serviceworkerInfo: "Musí byť zapnuté pre push notifikácie." +deletedNote: "Odstránené príspevky" +invisibleNote: "Skryté príspevky" +enableInfiniteScroll: "Zapnúť nekonečné skrolovanie" +visibility: "Viditeľnosť" +poll: "Hlasovanie" +useCw: "Skryť obsah" +enablePlayer: "Otvoriť video prehrávač" +disablePlayer: "Zavrieť video prehrávač" +expandTweet: "Rozšíriť tweet" +themeEditor: "Editor tém" +description: "Popis" +describeFile: "Pridať nadpis" +enterFileDescription: "Zadajte nadpis" +author: "Autor" +leaveConfirm: "Máte neuložené zmeny. Chcete ich zahodiť?" +manage: "Administrácia" +plugins: "Pluginy" +preferencesBackups: "Zálohy nastavení" +deck: "Deck" +useBlurEffectForModal: "Použiť efekt rozmazania na okná" +useFullReactionPicker: "Použiť plnú veľkosť výberu reakcií" +width: "Šírka" +height: "Výška" +large: "Veľké" +medium: "Stredné" +small: "Malé" +generateAccessToken: "Vygenerovať prístupový token" +permission: "Oprávnenia" +enableAll: "Povoliť všetko" +disableAll: "Vypnúť všetko" +tokenRequested: "Povoliť prístup k účtu" +pluginTokenRequestedDescription: "Tento plugin bude môcť používať oprávnenia nastavené tu." +notificationType: "Typ oznámenia" +edit: "Upraviť" +emailServer: "Email server" +enableEmail: "Zapnúť email" +emailConfigInfo: "Používa sa na overenie emaily pri registrácii alebo pri zabudnutí hesla" +email: "Email" +emailAddress: "Emailová adresa" +smtpConfig: "Nastavenia SMTP servera" +smtpHost: "Host" +smtpPort: "Port" +smtpUser: "Meno používateľa" +smtpPass: "Heslo" +emptyToDisableSmtpAuth: "Vynechaním mena hesla vypnete SMTP verifikáciu" +smtpSecure: "Použiť implicitné SSL/TLS pre SMTP spojenia" +smtpSecureInfo: "Toto vypnite keď používate STARTTLS" +testEmail: "Doručenie testovacieho emailu" +wordMute: "Stíšenie slova" +regexpError: "Chyba v regulárnom výraze" +regexpErrorDescription: "Na riadku {line} sa vyskytla chyba v stíšenom slove {tab}." +instanceMute: "Stíšené servery" +userSaysSomething: "{name} niečo povedal/a" +makeActive: "Aktivovať" +display: "Zobraziť" +copy: "Kopírovať" +metrics: "Metriky" +overview: "Prehľad" +logs: "Logy" +delayed: "Oneskorené" +database: "Databáza" +channel: "Kanály" +create: "Vytvoriť" +notificationSetting: "Nastavenia oznámení" +notificationSettingDesc: "Vyberte typ oznámení na zobrazenie" +useGlobalSetting: "Použiť globálne nastavenie" +useGlobalSettingDesc: "Ak je zapnuté, použijú sa oznámenia vášho účtu. Ak je vypnuté, použijú sa jednotlivé nastavenia." +other: "Ostatní" +regenerateLoginToken: "Pregenerovať prihlasovací token" +regenerateLoginTokenDescription: "Pregeneruje token interne používaný počas prihlásenia. Normálne toto netreba robiť. Ak sa pregeneruje, všetky zariadenia sa odhlásia." +setMultipleBySeparatingWithSpace: "Viaceré položky oddeľte medzerami." +fileIdOrUrl: "ID alebo URL súboru" +behavior: "Správanie" +sample: "Ukážka" +abuseReports: "Nahlásenia" +reportAbuse: "Nahlásiť" +reportAbuseOf: "Nahlásiť {name}" +fillAbuseReportDescription: "Prosím vyplňte podrobnosti nahlásenia. Ak sa týka konkrétnej poznámky, prosím napíšte jej URL." +abuseReported: "Vaše nahlásenie je odoslané. Veľmi pekne ďakujeme." +reporter: "Nahlásil" +reporteeOrigin: "Pôvod nahláseného" +reporterOrigin: "Pôvod nahlasovača" +forwardReport: "Preposlať nahlásenie na server" +forwardReportIsAnonymous: "Namiesto vášho účtu bude zobrazený anonymný systémový účet na vzdialenom serveri ako autor nahlásenia." +send: "Poslať" +abuseMarkAsResolved: "Označiť nahlásenia ako vyriešené" +openInNewTab: "Otvoriť v novom tabe" +openInSideView: "Otvoriť v bočnom paneli" +defaultNavigationBehaviour: "Predvolené správanie navigácie" +editTheseSettingsMayBreakAccount: "Úpravou týchto nastavení si môžete pokaziť účet." +instanceTicker: "Informácie servera o poznámkach" +waitingFor: "Čaká sa na {x}" +random: "Náhodné" +system: "Systém" +switchUi: "Prepnúť UI" +desktop: "Desktop" +clip: "Klip" +createNew: "Vytvoriť nový" +optional: "Voliteľné" +createNewClip: "Vytvoriť nový klip" +unclip: "Odopnúť" +confirmToUnclipAlreadyClippedNote: "Táto poznámka je už pripnutá ako \"{name}\". Naozaj ju chcete odopnúť?" +public: "Verejné" +i18nInfo: "Iceshrimp je prekladaný do rôznych jazykov dobrovoľníkmi. Pomôcť môžete na {link}." +manageAccessTokens: "Spravovať prístupové tokeny" +accountInfo: "Informácie o účte" +notesCount: "Počet poznámok" +repliesCount: "Počet odoslaných odpovedí" +renotesCount: "Počet preposlaných poznámok" +repliedCount: "Počet odpovedí prijatých" +renotedCount: "Počet preposlaní prijatých" +followingCount: "Počet sledovaných účtov" +followersCount: "Počet sledujúcich" +sentReactionsCount: "Počet poslaných reakcií" +receivedReactionsCount: "Počet prijatých reakcií" +pollVotesCount: "Počet odoslaných hlasov" +pollVotedCount: "Počet prijatých hlasov" +yes: "Áno" +no: "Nie" +driveFilesCount: "Počet súborov na disku" +driveUsage: "Využité miesto na disku" +noCrawle: "Odmietať indexovanie crawlerov" +noCrawleDescription: "Požiadať vyhľadávače, aby neindexovali váš profil, poznámky, stránky, atď." +lockedAccountInfo: "Pokým nenastavíte viditeľnosť poznámok na \"Len pre sledujúcich\", vaše príspevky bude vidieť hocikto, aj keď vyžadujete manuálne potvrdenie sledovania." +alwaysMarkSensitive: "Predvolene označovať ako NSFW" +loadRawImages: "Načítať originálne obrázky namiesto miniatúr" +disableShowingAnimatedImages: "Neprehrávať animované obrázky" +verificationEmailSent: "Odoslali sme overovací email. Overenie dokončíte kliknutím na odkaz v emaili." +notSet: "Nenastavené" +emailVerified: "Email overený" +noteFavoritesCount: "Počet obľúbených poznámok" +pageLikesCount: "Počet obľúbených stránok" +pageLikedCount: "Počet prijatých \"páči sa mi\"" +contact: "Kontakt" +useSystemFont: "Použiť predvolené systémové písmo" +clips: "Klip" +experimentalFeatures: "Experimentálne funkcie" +developer: "Vývojár" +makeExplorable: "Spraviť účet viditeľný v \"Objavovať\"" +makeExplorableDescription: "Ak toto vypnete, váš účet sa nezobrazí v sekcii \"Objavovat\"." +showGapBetweenNotesInTimeline: "Zobraziť medzeru medzi príspevkami časovej osi." +duplicate: "Duplikovať" +left: "Naľavo" +center: "Stred" +wide: "Široko" +narrow: "Úzko" +reloadToApplySetting: "Toto nastavenia sa prejaví až po obnovení stránky. Obnoviť teraz?" +needReloadToApply: "Toto nastavenie sa prejaví až po obnovení stránky." +showTitlebar: "Zobraziť riadok s nadpisom" +clearCache: "Vyprázdniť cache" +onlineUsersCount: "{n} používateľov je online" +nUsers: "{n} používateľov" +nNotes: "{n} poznámok" +sendErrorReports: "Poslať nahlásenie chyby" +sendErrorReportsDescription: "Keď je zapnuté, v prípade problému sa odošlú podrobné informácie o chybe do Iceshrimp. Pomôžete tak zvýšiť kvalitu Iceshrimp.\nTieto informácie zahŕňajú verziu vášho OS, použitý prehliadač, históriu aktivít, atď." +myTheme: "Moja téma" +backgroundColor: "Pozadie" +accentColor: "Akcent" +textColor: "Text" +saveAs: "Uložiť ako..." +advanced: "Rozšírené" +value: "Hodnoty" +createdAt: "Vytvorené" +updatedAt: "Upravené" +saveConfirm: "Uložiť zmeny?" +deleteConfirm: "Naozaj odstrániť?" +invalidValue: "Nesprávna hodnota." +registry: "Register" +closeAccount: "Zavrieť účet" +currentVersion: "Aktuálna verzia" +latestVersion: "Najnovšia verzia" +youAreRunningUpToDateClient: "Používate najnovšiu verziu vášho klienta." +newVersionOfClientAvailable: "Je dostupná novšia verzia vášho klienta." +usageAmount: "Využitie" +capacity: "Kapacita" +inUse: "Použité" +editCode: "Upraviť kód" +apply: "Použiť" +receiveAnnouncementFromInstance: "Prijať notifikácie z tohoto servera" +emailNotification: "Emailové upozornenia" +publish: "Zverejniť" +inChannelSearch: "Hľadať v kanáli" +useReactionPickerForContextMenu: "Otvoriť výber reakcií na pravý klik" +typingUsers: "{users} píše" +jumpToSpecifiedDate: "Skočiť na konkrétny dátum" +showingPastTimeline: "Práve vidíte starú časovú os" +clear: "Vrátiť" +markAllAsRead: "Označiť všetko ako prečítané" +goBack: "Späť" +unlikeConfirm: "Naozaj odstrániť váš like?" +fullView: "Plný pohľad" +quitFullView: "Zavrieť plný pohľad" +addDescription: "Pridať popis" +userPagePinTip: "Tu môžete zobraziť poznámky zvolením \"Pripnúť na profil\" z menu jednotlivých poznámok." +notSpecifiedMentionWarning: "Táto poznámka obsahuje spomenutých používateľov, ktorí nie sú medzi adresátmi." +info: "Informácie" +userInfo: "Informácie o používateľovi" +unknown: "Neznáme" +onlineStatus: "Online status" +hideOnlineStatus: "Skryť online status" +hideOnlineStatusDescription: "Skrytie vášho online statusu zníži pohodlnosť niektorých funkcií ako napríklad vyhľadávanie." +online: "Online" +active: "Aktívny" +offline: "Offline" +notRecommended: "Neodporúčané" +botProtection: "Bot ochrana" +instanceBlocking: "Blokované servery" +selectAccount: "Vyberte účet" +switchAccount: "Prepnút účet" +enabled: "Zapnuté" +disabled: "Vypnuté" +quickAction: "Rýchle akcie" +user: "Používatelia" +administration: "Spravovanie" +accounts: "Účty" +switch: "Prepnúť" +noMaintainerInformationWarning: "Informácie správcu nie sú nastavené." +noBotProtectionWarning: "Ochrana proti botom nie je nastavená." +configure: "Konfigurovať" +postToGallery: "Vytvoriť nový príspevok v galérii" +gallery: "Galéria" +recentPosts: "Najnovšie príspevky" +popularPosts: "Populárne príspevky" +shareWithNote: "Zdieľať s poznámkou" +ads: "Reklamy" +expiration: "Ukončiť hlasovanie" +memo: "Memo" +priority: "Priorita" +high: "Vysoká" +middle: "Stredné" +low: "Málo" +emailNotConfiguredWarning: "Nie je nastavená emailová adresa." +ratio: "Pomer" +previewNoteText: "Zobraziť náhľad" +customCss: "Vlastné CSS" +customCssWarn: "Toto nastavenie by sa malo používať iba ak viete čo robíte. Zadanie nesprávnych hodnôt môže spôsobiť nenormálne správanie klienta." +global: "Globálne" +squareAvatars: "Zobrazovať štvorcové avatary" +sent: "Poslať" +received: "Prijaté" +searchResult: "Výsledky hľadania" +hashtags: "Hashtagy" +troubleshooting: "Riešenie problémov" +useBlurEffect: "Používať efekty rozmazania v UI" +learnMore: "Zistiť viac" +iceshrimpUpdated: "Iceshrimp sa aktualizoval!" +whatIsNew: "Čo je nové?" +translate: "Preložiť" +translatedFrom: "Preložené z {x}" +accountDeletionInProgress: "Odstraňovanie účtu prebieha" +usernameInfo: "Meno, ktoré odlišuje váš účet od ostatných na tomto serveri. Môžete použiť abecedu (a~z, A~Z), čísla (0~9) alebo podtržník (_). Používateľské mená sa nedajú neskôr zmeniť." +aiChanMode: "Ai režim" +keepCw: "Nechať varovania obsahu" +pubSub: "Pub/Sub účty" +lastCommunication: "Posledná komunikácia" +resolved: "Vyriešené" +unresolved: "Nevyriešené" +breakFollow: "Nesledovať" +itsOn: "Zapnuté" +itsOff: "Vypnuté" +emailRequiredForSignup: "Registrácia vyžaduje emailovú adresu" +unread: "Neprečítané" +filter: "Filter" +controlPanel: "Ovládací panel" +manageAccounts: "Správa účtov" +makeReactionsPublic: "Reakcie sú verejné" +makeReactionsPublicDescription: "Toto spraví všetky vaše minulé reakcie viditeľné verejnosti." +classic: "Klasika" +muteThread: "Ztíšiť vlákno" +unmuteThread: "Zrušiť stíšenie vlákna" +ffVisibility: "Viditeľnosť sledujúcich/sledovaných" +ffVisibilityDescription: "Umožňuje nastaviť kto vidí koho sledujete a kto vás sleduje." +continueThread: "Zobraziť pokračovanie vlákna" +deleteAccountConfirm: "Toto nezvrátiteľne vymaže váš účet. Pokračovať?" +incorrectPassword: "Nesprávne heslo." +voteConfirm: "Potvrdzujete svoj hlas za \"{choice}\"?" +hide: "Skryť" +leaveGroup: "Opustiť skupiny" +leaveGroupConfirm: "Naozaj chcete opustiť \"{name}\"?" +useDrawerReactionPickerForMobile: "Zobraziť výber reakcií ako šuflík na mobile" +clickToFinishEmailVerification: "Kliknutím na [{ok}] dokončíte overeniu emailu." +overridedDeviceKind: "Typ zariadenia" +smartphone: "Smartfón" +tablet: "Tablet" +auto: "Automaticky" +themeColor: "Farba témy" +size: "Veľkosť" +numberOfColumn: "Počet stĺpcov" +searchByGoogle: "Hľadať cez Google" +instanceDefaultLightTheme: "Predvolená svetlá téma" +instanceDefaultDarkTheme: "Predvolená tmavá téma" +instanceDefaultThemeDescription: "Vložte kód témy v objektovom formáte" +mutePeriod: "Trvanie stíšenia" +indefinitely: "Navždy" +tenMinutes: "10 minút" +oneHour: "1 hodina" +oneDay: "1 deň" +oneWeek: "1 týždeň" +reflectMayTakeTime: "Zmeny môžu chvíľu trvať kým sa prejavia." +failedToFetchAccountInformation: "Nepodarilo sa načítať informácie o účte." +rateLimitExceeded: "Prekročený limit rýchlosti" +cropImage: "Orezanie obrázku" +cropImageAsk: "Chcete orezať obrázok?" +file: "Súbor/y" +recentNHours: "Posledných {n} hodín" +recentNDays: "Posledných {n} dní" +noEmailServerWarning: "Nie je nastavený emailový server." +thereIsUnresolvedAbuseReportWarning: "Existuje nevyriešené nahlásenie zneužitia." +recommended: "Odporúčané" +driveCapOverrideLabel: "Zmena limitu úložiska pre tohoto používateľa" +driveCapOverrideCaption: "Ak je zadaná hodnota menšia alebo rovná 0, zruší sa." +isSystemAccount: "Tieto účty automaticky vytvoril a spravuje systém." +typeToConfirm: "Ak chcete vykonať túto operáciu, napíšte {x}" +deleteAccount: "Vymazať účet" +document: "Dokument" +numberOfPageCache: "Počet cachí pre stránky" +numberOfPageCacheDescription: "Zvýši rýchlosť ale tiež nároky na pamäť." +logoutConfirm: "Naozaj sa chcete odhlásiť?" +statusbar: "Stavový riadok" +pleaseSelect: "Prosím vyberte" +reverse: "Preklopiť" +colored: "Farebné" +refreshInterval: "Interval obnovenia" +label: "Popisok" +type: "Typ" +speed: "Rýchlosť" +slow: "Pomaly" +fast: "Rýchlo" +sensitiveMediaDetection: "Detekcia citlivých médií." +localOnly: "Iba lokálne" +remoteOnly: "Len vzdialené" +failedToUpload: "Nahrávanie zlyhalo" +cannotUploadBecauseInappropriate: "Nemožno nahrať, pretože pravdepodobne obsahuje nevhodný obsah." +cannotUploadBecauseNoFreeSpace: "Nemožno nahrať kvôli nedostatku voľného úložiska." +beta: "Beta" +enableAutoSensitive: "Automatická detekcia NSFW" +enableAutoSensitiveDescription: "Ak je zapnuté, príznak NSFW sa na médiách automaticky nastaví pomocou strojového učenia. Aj keď je táto funkcia vypnutá, v niektorých prípadoch sa môže nastaviť automaticky." +activeEmailValidationDescription: "Dôkladnejšie overí e-mailovú adresu používateľa tým, že zistí, či ide o vyradenú e-mailovú adresu a či sa s ňou dá skutočne komunikovať. Ak nie je začiarknuté, e-mailová adresa sa kontroluje len ako text." +navbar: "Navigačný panel" +account: "Účty" +move: "Pohyb" +_sensitiveMediaDetection: + description: "Strojové učenie sa použije na automatickú detekciu citlivých médií na účely ich moderovania. Mierne sa zvýši zaťaženie servera." + sensitivity: "Citlivosť detekcie" + sensitivityDescription: "Nižšia citlivosť znižuje počet falošne pozitívnych výsledkov (false positives). Vyššia citlivosť znižuje počet falošne negatívnych výsledkov (false negatives)." + setSensitiveFlagAutomatically: "Nastaviť príznak NSFW" + setSensitiveFlagAutomaticallyDescription: "Aj keď je toto nastavenie vypnuté, výsledok rozhodnutia je interne uložený." + analyzeVideos: "Zapnúť analýzu videa" + analyzeVideosDescription: "Okrem obrázkov zapne detekciu aj pre videá. Zaťaženie servera sa mierne zvýši." +_emailUnavailable: + used: "Táto emailová adresa sa už používa" + format: "Formát emailovej adresy je nesprávny" + disposable: "Jednorázové emailové adresy sa nemôžu používať." + mx: "Tento emailový server nefunguje." + smtp: "Tento emailový server neodpovedá." +_ffVisibility: + public: "Zverejniť" + followers: "Len viditeľní sledujúci" + private: "Súkromné" +_signup: + almostThere: "Skoro na konci" + emailAddressInfo: "Prosím zadajte svoju emailovú adresu!" + emailSent: "Na vašu emailovú adresu ({email}) sme odoslali email. Vytvorenie účtu dokončíte kliknutím na odkaz v emaili." +_accountDelete: + accountDelete: "Odstrániť účet" + mayTakeTime: "Keďže odstránenie účtu je náročný proces, môže to nejaký čas trvať. Záleží koľko obsahu ste vytvorili a koľko súborov ste nahrali." + sendEmail: "Po odstránení účtu vám pošleme email na emailovú adresu zadanú pri registrácii tohoto účtu." + requestAccountDelete: "Požiadať o zmazanie účtu" + started: "Odstraňovanie začalo." + inProgress: "Odstraňovanie prebieha" +_ad: + back: "Späť" + reduceFrequencyOfThisAd: "Túto reklamu zobrazovať menej" +_forgotPassword: + enterEmail: "Zadajte emailovú adresu, ktorú ste použili pri registrácii. Pošleme vám na ňu odkaz, cez ktorý si môžete obnoviť heslo." + ifNoEmail: "Ak ste pri registrácii nepoužili email, prosím kontaktujte administrátora." + contactAdmin: "Tento server nepodporuje používanie emailových adries, prosím kontaktuje administrátor, ktorý vám resetuje heslo." +_gallery: + my: "Moja galéria" + liked: "Obľúbené príspevky" + like: "Páči sa mi" + unlike: "Nepáči sa mi" +_email: + _follow: + title: "Máte nového sledujúceho" + _receiveFollowRequest: + title: "Dostali ste žiadosť o sledovanie" +_plugin: + install: "Inštalova pluginy" + installWarn: "Prosím neinštalujte nedôveryhodné pluginy." + manage: "Spravovanie pluginov" +_preferencesBackups: + list: "Vytvorené zálohy" + saveNew: "Uložiť novú" + loadFile: "Nahrať súbor" + apply: "Použiť na toto zariadenie" + save: "Uložiť" + inputName: "Názov zálohy" + cannotSave: "Nedá sa uložiť" + nameAlreadyExists: "Záloha s názvom \"{name}\" už existuje. Zadajte iný názov." + applyConfirm: "Chcete použiť zálohu '{name}' na aktuálne zariadenie? Aktuálne nastavenia zariadenia sa stratia." + saveConfirm: "Chcete prepísať {name}?" + deleteConfirm: "Naozaj chcete odstrániť \"{name}\"?" + renameConfirm: "Chcete zmeniť \"{old}\" na \"{new}\"?" + noBackups: "Nie je k dispozícii žiadna záloha. \"Uložiť novú\" umožňuje uložiť aktuálnu konfiguráciu zariadenia na server." + createdAt: "Dátum vytvorenia: {date} {time}" + updatedAt: "Dátum úpravy: {date} {time}" + cannotLoad: "Nedá sa nahrať" + invalidFile: "Neplatný formát súboru" +_registry: + scope: "Oblasť" + key: "Kľúč" + keys: "Kľúče" + domain: "Doména" + createKey: "Vytvoriť kľúč" +_aboutIceshrimp: + about: "Iceshrimp je open-source softvér, ktorý vyvíja syuilo od 2014." + contributors: "Hlavní prispievatelia" + allContributors: "Všetci prispievatelia" + source: "Zdrojový kód" + translation: "Preložiť Iceshrimp" + donate: "Podporiť Iceshrimp" + morePatrons: "Takisto oceňujeme podporu mnoých ďalších, ktorí tu nie sú uvedení. Ďakujeme! 🥰" + patrons: "Prispievatelia" +_nsfw: + respect: "Skryť NSFW médiá" + ignore: "Neskrývať NSFW médiá" + force: "Skryť všetky médiá" +_mfm: + cheatSheet: "MFM Cheatsheet" + intro: "MFM je Iceshrimp exkluzívny značkovací jazyk, ktorý sa dá používať na viacerých miestach. Tu môžete vidieť zoznam všetkej dostupnej MFM syntaxe." + dummy: "Iceshrimp rozširuje svet Fediverza" + mention: "Zmienka" + mentionDescription: "Používateľa spomeniete použítím zavináča a mena používateľa" + hashtag: "Hashtag" + hashtagDescription: "Môžete zadať hashtag použitím mriežky a textu" + url: "URL" + urlDescription: "URL sa dajú zobraziť." + link: "Odkaz" + linkDescription: "Jednotlivé časti texty sa dajú zobraziť ako URL." + bold: "Tučné" + boldDescription: "Zvýrazní písmená tým, že budú tučnejšie." + small: "Malé" + smallDescription: "Zobrazí obsah malý a tenký." + center: "Vystrediť prvky" + centerDescription: "Zobrazí obsah v strede" + inlineCode: "Kód (inline)" + inlineCodeDescription: "Zobrazí kód so zvýraznením syntaxe." + blockCode: "Kód (blok)" + blockCodeDescription: "Zobrazí viacriadkový kód so zvýraznením syntaxe v bloku." + inlineMath: "Vzorec (inline)" + inlineMathDescription: "Zobrazí matematický vzorec (KaTeX) v riadku." + blockMath: "Vzorec (blok)" + blockMathDescription: "Zobrazí viacriadkový matematický vzorec (KaTeX) v bloku" + quote: "Citovať" + quoteDescription: "Zobrazí obsah ako citát." + emoji: "Vlastné emoji" + emojiDescription: "Pridaním dvojbodiek pred a za názov vlastnej emoji, sa dá zobraziť vlastná emoji." + search: "Hľadať" + searchDescription: "Zobrazí vyhľadávacie pole so zadaným textom." + flip: "Preklopiť" + flipDescription: "Preklopí obsah horizontálne alebo vertikálne" + jelly: "Animácia (želé)" + jellyDescription: "Obsah sa bude hýbať ako želé." + tada: "Animácia (tadá)" + tadaDescription: "Obsah sa bude hýbať ako Tada!" + jump: "Animácia (skok)" + jumpDescription: "Obsah skočí." + bounce: "Animácia (odraz)" + bounceDescription: "Obsah sa bude odrážať." + shake: "Animácia (trasenie)" + shakeDescription: "Obsah sa bude triasť." + twitch: "Animácia (myknutie)" + twitchDescription: "Obsahu dá animáciu silného trasenia." + spin: "Animácia (rotácia)" + spinDescription: "Obsahu pridá otáčajúcu animáciu." + x2: "Veľký" + x2Description: "Zobrazí obsah väčší." + x3: "Veľmi veľký" + x3Description: "Zobrazí obsah ešte väčší." + x4: "Neuveriteľne veľký" + x4Description: "Zobrazí obsah ešte viac veľký než veľmi veľký." + blur: "Rozmazanie" + blurDescription: "Týmto efektom môže byť obsah rozmazaný. Zaostrí sa keď ned neho príde kurzor." + font: "Písmo" + fontDescription: "Nastaví písmo, ktorým sa zobrazí text." + rainbow: "Dúha" + rainbowDescription: "Zobrazí obsah vo farbách dúhy." + sparkle: "Trblietky" + sparkleDescription: "Obsahu dodá trblietajúci efekt." + rotate: "Otáčať" + rotateDescription: "Otočí obsah o určitý uhol." + plain: "Obyčajné" + plainDescription: "Bez akejkoľvej syntaxe" +_instanceTicker: + none: "Nikdy nezobrazovať" + remote: "Zobraziť pre vzdialených používateľov" + always: "Zobraziť vždy" +_serverDisconnectedBehavior: + reload: "Automaticky obnoviť" + dialog: "Zobraziť okno s varovaním" + quiet: "Zobraziť nerušivé varovanie" +_channel: + create: "Vytvoriť kanál" + edit: "Upraviť kanál" + setBanner: "Nastaviť banner" + removeBanner: "Odstrániť banner" + featured: "Trendy" + owned: "Vlastnené" + following: "Sledované" + usersCount: "{n} účastníkov" + notesCount: "{n} poznámok" +_menuDisplay: + sideFull: "Strana" + sideIcon: "Strana (Ikony)" + top: "Hore" + hide: "Skryť" +_wordMute: + muteWords: "Umlčané slová" + muteWordsDescription: "Medzerami oddeľte pre podmienku AND a novými riadkami pre podmienku OR." + muteWordsDescription2: "Regulárne výrazy sa použijú keď použijete okolo lomítka." + softDescription: "Skryje poznámky z časovej osi, ktoré spĺňajú podmienky." + hardDescription: "Zabráni poznámky spĺňajúce množinu podmienok, aby boli pridané do časovej osi. Navyše tieto poznámky nepribudnú v časovej osi ani keď sa podmienky zmenia." + soft: "Mäkké" + hard: "Tvrdé" + mutedNotes: "Umlčané poznámky" +_instanceMute: + instanceMuteDescription: "Toto umlčí všetky poznámky/preposlania zo zoznamu serverov, vrátane tých, na ktoré používatelia odpovedajú z umlčaného servera." + instanceMuteDescription2: "Oddeľte novými riadkami" + title: "Skryje poznámky z uvedených serverov." + heading: "Zoznam umlčaných inštancií" +_theme: + explore: "Objavovať témy" + install: "Nainštalovať tému" + manage: "Spravovať témy" + code: "Kód témy" + description: "Popis" + installed: "{name} je nainštalovaná" + installedThemes: "Nainštalované témy" + builtinThemes: "Vstavané témy" + alreadyInstalled: "Táto téma je už nainštalovaná" + invalid: "Formát tejto témy je nesprávny" + make: "Vytvoriť tému" + base: "Základ" + addConstant: "Pridať konštantu" + constant: "Konštanta" + defaultValue: "Predvolená hodnota" + color: "Farba" + refProp: "Odkaz na vlastnosť" + refConst: "Odkaz na konštantu" + key: "Kľúč" + func: "Funkcie" + funcKind: "Typ funkcie" + argument: "Argument" + basedProp: "Odkazovaná vlastnosť" + alpha: "Priehľadnosť" + darken: "Stmaviť" + lighten: "Zosvetliť" + inputConstantName: "Zadajte názov tejto konštanty" + importInfo: "Ak sem zadáte kód témy, môžete ju importovať do editora tém." + deleteConstantConfirm: "Naozaj chcete odstrániť konštantu {const}?" + keys: + accent: "Akcent" + bg: "Pozadie" + fg: "Text" + focus: "Fokus" + indicator: "Indikátor" + panel: "Panel" + shadow: "Tieň" + header: "Hlavička" + navBg: "Pozadie bočného panela" + navFg: "Text bočného panela" + navHoverFg: "Text bočného panela (pod kurzorom)" + navActive: "Text bočného panela (aktívny)" + navIndicator: "Indikátor bočného panela" + link: "Odkaz" + hashtag: "Hashtag" + mention: "Zmienka" + mentionMe: "Zmienky (mňa)" + renote: "Preposlať" + modalBg: "Pozadie modálu" + divider: "Oddeľovač" + scrollbarHandle: "Rúčka scrollbaru" + scrollbarHandleHover: "Rúčka scrollbaru (pod kurzorom)" + dateLabelFg: "Text dátového popisku" + infoBg: "Pozadie informácií" + infoFg: "Informačný text" + infoWarnBg: "Pozadie varovania" + infoWarnFg: "Text varovania" + cwBg: "CW pozadie tlačidla" + cwFg: "CW text tlačidla" + cwHoverBg: "CW pozadie tlačidla (pod kurzorom)" + toastBg: "Pozadie upozornenia" + toastFg: "Text upozornenia" + buttonBg: "Pozadie tlačidla" + buttonHoverBg: "Pozadie tlačidla (pod kurzorom)" + inputBorder: "Okraj vstupného poľa" + listItemHoverBg: "Pozadie položky zoznamu (pod kurzorom)" + driveFolderBg: "Pozadie priečinu disku" + wallpaperOverlay: "Vrstvenie pozadia" + badge: "Odznak" + messageBg: "Pozadie chatu" + accentDarken: "Akcent (stmavené)" + accentLighten: "Akcent (zosvetlené)" + fgHighlighted: "Zvýraznený text" +_sfx: + note: "Poznámky" + noteMy: "Vlastná poznámka" + notification: "Oznámenia" + chat: "Chat" + chatBg: "Chat (pozadie)" + antenna: "Antény" + channel: "Upozornenia kanála" +_ago: + future: "Budúcnosť" + justNow: "Teraz" + secondsAgo: "pred {n} sekundami" + minutesAgo: "pred {n} minút {n2} sekundami" + hoursAgo: "pred {n} hodin {n2} minútami" + daysAgo: "pred {n} dň {n2} hodinami" + weeksAgo: "pred {n} týž {n2} dňami" + monthsAgo: "pred {n} mesiac {n2} týždňami" + yearsAgo: "pred {n} rok {n2} mesiacmi" +_time: + second: "s" + minute: "min" + hour: "hod" + day: "dní" +_tutorial: + title: "How to use Iceshrimp" + step1_1: "Welcome!" + step1_2: "Let's get you set up. You'll be up and running in no time!" + step2_1: "First, please fill out your profile." + step2_2: "Providing some information about who you are will make it easier for others to tell if they want to see your notes or follow you." + step3_1: "Now time to follow some people!" + step3_2: "Your home and social timelines are based off of who you follow, so try following a couple accounts to get started.\nClick the plus circle on the top right of a profile to follow them." + step4_1: "Let's get you out there." + step4_2: "For your first post, some people like to made a {introduction} post or a simple \"Hello world!\"" + step5_1: "Timelines, timelines everywhere!" + step5_2: "Your instance has {timelines} different timelines enabled." + step5_3: "The Home {icon} timeline is where you can see posts from your followers." + step5_4: "The Local {icon} timeline is where you can see posts from everyone else on this instance." + step5_5: "The Recommended {icon} timeline is where you can see posts from instances the admins recommend." + step5_6: "The Social {icon} timeline is where you can see posts from friends of your followers." + step5_7: "The Global {icon} timeline is where you can see posts from every other connected instance." + step6_1: "So, what is this place?" + step6_2: "Well, you didn't just join Iceshrimp. You joined a portal to the Fediverse, an interconnected network of thousands of servers, called \"instances\"." + step6_3: "Each server works in different ways, and not all servers run Iceshrimp. This one does though! It's a bit complicated, but you'll get the hang of it in no time." + step6_4: "Now go, explore, and have fun!" +_2fa: + alreadyRegistered: "Už ste zaregistrovali 2-faktorové autentifikačné zariadenie." + registerTOTP: "Registrovať nové zariadenie" + registerSecurityKey: "Registrovať bezpečnostný kľúč" + step1: "Najprv si nainštalujte autentifikačnú aplikáciu (napríklad {a} alebo {b}) na svoje zariadenie." + step2: "Potom, naskenujte QR kód zobrazený na obrazovke." + step2Url: "Do aplikácie zadajte nasledujúcu URL adresu:" + step3: "Nastavenie dokončíte zadaním tokenu z vašej aplikácie." + step4: "Od teraz, všetky ďalšie prihlásenia budú vyžadovať prihlasovací token." + securityKeyInfo: "Okrem odtlačku prsta alebo PIN autentifikácie si môžete nastaviť autentifikáciu cez hardvérový bezpečnostný kľúč podporujúci FIDO2 a tak ešte viac zabezpečiť svoj účet." +_permissions: + "read:account": "Vidieť informácie o vašom účte" + "write:account": "Upraviť informácie o vašom účte" + "read:blocks": "Vidieť zoznam blokovaných používateľov" + "write:blocks": "Upraviť zoznam blokovaných používateľov" + "read:drive": "Prístup k súborom a priečinkom na disku" + "write:drive": "Upraviť alebo odstrániť súbory a priečinky na disku" + "read:favorites": "Vidieť váš zoznam obľúbených" + "write:favorites": "Upraviť váš zoznam obľúbených" + "read:following": "Vidieť koho sledujete" + "write:following": "Sledovať alebo nesledovať ďalšie účty" + "read:messaging": "Vidieť vaše chaty" + "write:messaging": "Písať alebo odstraňovať správy v chate" + "read:mutes": "Vidieť váš zoznam stíšených používateľov" + "write:mutes": "Upravovať zoznam stíšených používateľov" + "write:notes": "Písať alebo odstrániť poznámky" + "read:notifications": "Vidieť vaše oznámenia" + "write:notifications": "Pracovať s vašimi notifikáciami" + "read:reactions": "Vidieť vaše reakcie" + "write:reactions": "Upravovať vaše reakcie" + "write:votes": "Hlasovať v hlasovaniach" + "read:pages": "Vidieť vaše stránky" + "write:pages": "Upraviť alebo odstrániť vaše stránky" + "read:page-likes": "Vidieť vaše páčiky na stránkach" + "write:page-likes": "Upraviť páčiky na stránkach" + "read:user-groups": "Vidieť vaše skupiny" + "write:user-groups": "Upraviť alebo odstrániť vaše skupiny" + "read:channels": "Čítať vaše kanály" + "write:channels": "Upravovať vaše kanály" + "read:gallery": "Vidieť vašu galériu" + "write:gallery": "Upravovať vašu galériu" + "read:gallery-likes": "Vidieť zoznam obľúbených príspevkov z galérie" + "write:gallery-likes": "Upraviť zoznam obľúbených príspevov z galérie" +_auth: + shareAccess: "Prajete si povoliť \"{name}\", aby mal prístup k tomuto účtu?" + shareAccessAsk: "Naozaj chcete povoliť tejto aplikácii prístup k tomuto účtu?" + permissionAsk: "Táto aplikácia vyžaduje nasledujúce nastavenia" + pleaseGoBack: "Prosím prejdite späť na aplikáciu" + callback: "Vraciam sa späť na aplikáciu" + denied: "Prístup zamietnutý" +_antennaSources: + all: "Všetky poznámky" + homeTimeline: "Poznámky od sledovaného používateľa" + users: "Poznámky od konkrétneho používateľa" + userList: "Poznámky od používateľov v zozname" + userGroup: "Poznámky od používateľov z konkrétnej skupiny." +_weekday: + sunday: "Nedeľa" + monday: "Pondelok" + tuesday: "Utorok" + wednesday: "Streda" + thursday: "Štvrtok" + friday: "Piatok" + saturday: "Sobota" +_widgets: + memo: "Prilepené poznámky" + notifications: "Oznámenia" + timeline: "Časová os" + calendar: "Kalendár" + trends: "Trendy" + clock: "Hodiny" + rss: "RSS čítačka" + rssTicker: "RSS Ticker" + activity: "Aktivita" + photos: "Fotky" + digitalClock: "Digitálne hodiny" + unixClock: "UNIX čas" + federation: "Federácia" + postForm: "Napísať poznámku" + slideshow: "Prezentácia" + button: "Tlačidlo" + onlineUsers: "Online používatelia" + jobQueue: "Fronta úloh" + serverMetric: "Metriky servera" + aiscript: "Konzola AiScript" + aichan: "Ai" +_cw: + hide: "Skryť" + show: "Zobraziť viac" + chars: "{count} znakov" + files: "{count} súbor/ov" +_poll: + noOnlyOneChoice: "Treba aspoň dve voľby" + choiceN: "Voľba {n}" + noMore: "Nemôžete pridať viac volieb" + canMultipleVote: "Povoliť hlasovať za viac volieb." + expiration: "Ukončiť hlasovanie" + infinite: "Nikdy" + at: "Konkrétny dátum..." + after: "Ukončiť po..." + deadlineDate: "Dátum ukončenia" + deadlineTime: "hod" + duration: "Trvanie" + votesCount: "{n} hlasov" + totalVotes: "{n} hlasov celkom" + vote: "Hlasovať" + showResult: "Vidieť výsledky hlasovania" + voted: "Zahlasované" + closed: "Skončilo" + remainingDays: "zostáva {d} dní {h} hodín" + remainingHours: "zostáva {h} hodín {m} minút" + remainingMinutes: "zostáva {m} minút {s} sekúnd" + remainingSeconds: "zostáva {s} sekúnd" +_visibility: + public: "Verejné" + publicDescription: "Vaša poznámku bude viditeľná všetkým používateľom" + home: "Domov" + homeDescription: "Pridať iba na domácu časovú os" + followers: "Sledujúci" + followersDescription: "Viditeľné iba tým, ktorí vás sledujú" + specified: "Priame" + specifiedDescription: "Viditeľné iba pre konkrétnych používateľov" + localOnly: "Iba lokálne" + localOnlyDescription: "Vzdialený používatelia nebudú vidieť" +_postForm: + replyPlaceholder: "Odpoveď na túto poznámku..." + quotePlaceholder: "Citovanie tejto poznámky..." + channelPlaceholder: "Poslať do kanála..." + _placeholders: + a: "Čo máte v pláne?" + b: "Čo sa deje?" + c: "O čom rozmýšľaš?" + d: "Čo chcete povedať?" + e: "Začnite písať..." + f: "Čaká sa na písanie..." +_profile: + name: "Názov" + username: "Meno používateľa" + description: "Bio" + youCanIncludeHashtags: "Vo svojom bio môžete mať aj hashtagy." + metadata: "Dodatočné informácie" + metadataEdit: "Upraviť dodatočné informácie" + metadataDescription: "Vo svojom profile môžete uviesť až štyri dodatočné informačné polia. Dodate lahko oznako {a} ali oznako {l} z {rel}, da preverite povezavo v svojem profile!" + metadataLabel: "Popisok" + metadataContent: "Obsah" + changeAvatar: "Zmeniť avatara" + changeBanner: "Zmeniť banner" +_exportOrImport: + allNotes: "Všetky poznámky" + followingList: "Sledujete" + muteList: "Vypnúť zvuk" + blockingList: "Zablokovať" + userLists: "Zoznamy" + excludeMutingUsers: "Vylúčiť stíšených používateľov" + excludeInactiveUsers: "Vylúčiť neaktívnych používateľov" +_charts: + federation: "Federácia" + apRequest: "Žiadosti" + usersIncDec: "Rozdiel v počte používateľov" + usersTotal: "Celkový počet používateľov" + activeUsers: "Aktívni používatelia" + notesIncDec: "Rozdiel v počte poznámok" + localNotesIncDec: "Rozdiel v počte lokálnych poznámok" + remoteNotesIncDec: "Rozdiel v počte vzdialených poznámok" + notesTotal: "Celkový počet poznámok" + filesIncDec: "Rozdiel v počte súborov" + filesTotal: "Celkový počet súborov" + storageUsageIncDec: "Rozdiel využitého úložiska" + storageUsageTotal: "Celkové využité úložisko" +_instanceCharts: + requests: "Žiadosti" + users: "Rozdiel v počte používateľov" + usersTotal: "Celkom spolu počet používateľov" + notes: "Rozdiel v počte poznámok" + notesTotal: "Celkom spolu počet poznámok" + ff: "Rozdiel v počte sledovaných/sledujúcich" + ffTotal: "Celkom spolu počet sledovaných / sledujúcich" + cacheSize: "Rozdiel vo veľkosti cache" + cacheSizeTotal: "Celkom spolu veľkosť cache" + files: "Rozdiel v počte súborov" + filesTotal: "Celkom spolu počet súborov" +_timelines: + home: "Domov" + local: "Lokálne" + social: "Sociálne" + global: "Globálne" +_pages: + newPage: "Vytvoriť novú stránku" + editPage: "Upraviť túto stránku" + readPage: "Zobrazenie zdroja aktívne" + created: "Stránka úspešne vytvorená" + updated: "Stránka úspešne upravená" + deleted: "Stránka úspešne odstránená" + pageSetting: "Nastavenia stránky" + nameAlreadyExists: "Zadaná URL stránku už existuje" + invalidNameTitle: "Zadaná URL stránku je nesprávna" + invalidNameText: "Uistite sa, že nadpis stránky nie je prázdny" + editThisPage: "Upraviť túto stránku" + viewSource: "Ukázať zdroj" + viewPage: "Ukázať vaše stránky" + like: "Páči sa mi" + unlike: "Nepáči sa mi" + my: "Moje stránky" + liked: "Obľúbené stránky" + featured: "Význačné" + inspector: "Inšpektor" + contents: "Obsah" + content: "Blok stránky" + variables: "Premenné" + title: "Nadpis" + url: "URL stránky" + summary: "Zhrnutie stránky" + alignCenter: "Vystrediť prvky" + hideTitleWhenPinned: "Skryť nadpis stránky keď je pripnutá na profil" + font: "Písmo" + fontSerif: "Pätkové" + fontSansSerif: "Bezpätkové" + eyeCatchingImageSet: "Nastaviť miniatúru" + eyeCatchingImageRemove: "Odstrániť miniatúru" + chooseBlock: "Pridať blok" + selectType: "Vyberte typ" + enterVariableName: "Zadajte meno premennej" + variableNameIsAlreadyUsed: "Meno premennej s už používa" + contentBlocks: "Obsah" + inputBlocks: "Vstup" + specialBlocks: "Špeciálne" + blocks: + text: "Text" + textarea: "Textové pole" + section: "Sekcia" + image: "Obrázky" + button: "Tlačidlo" + if: "Ak" + _if: + variable: "Premenné" + post: "Napísať poznámku" + _post: + text: "Obsah" + attachCanvasImage: "Príspevok s obrázkom na plátne" + canvasId: "ID plátna" + textInput: "Textový vstup" + _textInput: + name: "Meno premennej" + text: "Nadpis" + default: "Predvolená hodnota" + textareaInput: "Viacriadkový textový vstup" + _textareaInput: + name: "Meno premennej" + text: "Nadpis" + default: "Predvolená hodnota" + numberInput: "Číselný vstup" + _numberInput: + name: "Meno premennej" + text: "Nadpis" + default: "Predvolená hodnota" + canvas: "Plátno" + _canvas: + id: "ID plátna" + width: "Šírka" + height: "Výška" + note: "Vložená poznámka" + _note: + id: "ID poznámky" + idDescription: "Alebo môžete vložiť URL poznámky sem" + detailed: "Podrobný pohľad" + switch: "Prepnúť" + _switch: + name: "Meno premennej" + text: "Nadpis" + default: "Predvolená hodnota" + counter: "Počítadlo" + _counter: + name: "Meno premennej" + text: "Nadpis" + inc: "Pripočítať" + _button: + text: "Nadpis" + colored: "Farebné" + action: "Operácia po stlačení tlačidla" + _action: + dialog: "Zobraziť dialóg" + _dialog: + content: "Obsah" + resetRandom: "Resetovať zdroj náhodnosti" + pushEvent: "Poslať udalosť" + _pushEvent: + event: "Názov udalosti" + message: "Zobrazená správa po aktivácii" + variable: "Odoslaná premenná" + no-variable: "Žiadne" + callAiScript: "Spustiť AiScript" + _callAiScript: + functionName: "Názov funkcie" + radioButton: "Možnosť" + _radioButton: + name: "Meno premennej" + title: "Nadpis" + values: "Zoznam možností oddelené novými riadkami" + default: "Predvolená hodnota" + script: + categories: + flow: "Riadenie behu" + logical: "Logická operácia" + operation: "Výpočet" + comparison: "Porovnanie" + random: "Náhodné" + value: "Hodnoty" + fn: "Funkcie" + text: "Textové operácie" + convert: "Transformácie" + list: "Zoznamy" + blocks: + text: "Text" + multiLineText: "Text (viacriadkový)" + textList: "Zoznam textov" + _textList: + info: "Oddeľte každú položku novým riadkom" + strLen: "Dĺžka textu" + _strLen: + arg1: "Text" + strPick: "Vybrať znak" + _strPick: + arg1: "Text" + arg2: "Pozícia znaku" + strReplace: "Náhradný text" + _strReplace: + arg1: "Text" + arg2: "Nahradený text" + arg3: "Nahradiť s" + strReverse: "Otočiť text" + _strReverse: + arg1: "Text" + join: "Spojiť texty" + _join: + arg1: "Zoznamy" + arg2: "Oddeľovač" + add: "Pridať" + _add: + arg1: "A" + arg2: "B" + subtract: "Odčítať" + _subtract: + arg1: "A" + arg2: "B" + multiply: "Násobiť" + _multiply: + arg1: "A" + arg2: "B" + divide: "Deliť" + _divide: + arg1: "A" + arg2: "B" + mod: "Zvyšok po delení" + _mod: + arg1: "A" + arg2: "B" + round: "Zaokrúhliť" + _round: + arg1: "Číslo" + eq: "A a B sa rovnajú" + _eq: + arg1: "A" + arg2: "B" + notEq: "A a B sa nerovnajú" + _notEq: + arg1: "A" + arg2: "B" + and: "A a zároveň B" + _and: + arg1: "A" + arg2: "B" + or: "A alebo B" + _or: + arg1: "A" + arg2: "B" + lt: "< A je menšie ako B" + _lt: + arg1: "A" + arg2: "B" + gt: "> A je väčšie ako B" + _gt: + arg1: "A" + arg2: "B" + ltEq: "<= A je menšie alebo rovné B" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: ">= A je väčšie alebo rovné B" + _gtEq: + arg1: "A" + arg2: "B" + if: "Vetva" + _if: + arg1: "Ak" + arg2: "Potom" + arg3: "Inak" + not: "Opak" + _not: + arg1: "Opak" + random: "Náhodné" + _random: + arg1: "Pravdepodobnosť" + rannum: "Náhodné číslo" + _rannum: + arg1: "Minimálna hodnota" + arg2: "Maximálna hodnota" + randomPick: "Náhodný výber zo zoznamu" + _randomPick: + arg1: "Zoznam" + dailyRandom: "Náhodne (zmení sa raz denne pre každého používateľa)" + _dailyRandom: + arg1: "Pravdepodobnosť" + dailyRannum: "Náhodné číslo (Mení sa denne pre každého používateľa)" + _dailyRannum: + arg1: "Minimálna hodnota" + arg2: "Maximálna hodnota" + dailyRandomPick: "Náhodný výber zo zoznamu (Mení sa denne pre každého používateľa)" + _dailyRandomPick: + arg1: "Zoznam" + seedRandom: "Náhodne (so seedom)" + _seedRandom: + arg1: "Seed" + arg2: "Pravdepodobnosť" + seedRannum: "Náhodné číslo (so seedom)" + _seedRannum: + arg1: "Seed" + arg2: "Minimálna hodnota" + arg3: "Maximálna hodnota" + seedRandomPick: "Náhodný výber zo zoznamu (so seedom)" + _seedRandomPick: + arg1: "Seed" + arg2: "Zoznam" + DRPWPM: "Náhodný výber z váženého zoznamu (Mení sa denne pre každého používateľa)" + _DRPWPM: + arg1: "Zoznam textov" + pick: "Vybrať zo zoznamu" + _pick: + arg1: "Zoznam" + arg2: "Pozícia" + listLen: "Získať dĺžku zoznamu" + _listLen: + arg1: "Zoznam" + number: "Číslo" + stringToNumber: "Text na číslo" + _stringToNumber: + arg1: "Text" + numberToString: "Číslo na text" + _numberToString: + arg1: "Číslo" + splitStrByLine: "Rozdelí text po riadkoch" + _splitStrByLine: + arg1: "Text" + ref: "Premenné" + aiScriptVar: "AiScript premenná" + fn: "Funkcie" + _fn: + slots: "Sloty" + slots-info: "Oddeľte každý slot novým riadkom" + arg1: "Výstup" + for: "For cyklus" + _for: + arg1: "Počet opakovaní" + arg2: "Akcia" + typeError: "Slot {slot} akceptuje hodnoty typu \"{expect}\", ale dodaná hodnota je typu \"{actual}\"!" + thereIsEmptySlot: "Slot {slot} je prázdny!" + types: + string: "Text" + number: "Číslo" + boolean: "Boolean" + array: "Zoznamy" + stringArray: "Zoznam textov" + emptySlot: "Prázdny slot" + enviromentVariables: "Premenné prostredia" + pageVariables: "Premenné stránky" + argVariables: "Vstupné sloty" +_relayStatus: + requesting: "Čaká sa" + accepted: "Akceptované" + rejected: "Odmietnuté" +_notification: + fileUploaded: "Súbor sa úspešne nahral" + youGotMention: "{name} vás spomenul/a" + youGotReply: "{name} vám odpovedal/a" + youGotQuote: "{name} vás citoval/a" + youRenoted: "{name} preposlal/a vašu poznámku" + youGotPoll: "{name} hlasoval/a" + youGotMessagingMessageFromUser: "{name} vám poslal/a správu" + youGotMessagingMessageFromGroup: "Prišla správa do skupiny {name}" + youWereFollowed: "Máte nového sledujúceho" + youReceivedFollowRequest: "Dostali ste žiadosť o sledovanie" + yourFollowRequestAccepted: "Vaša žiadosť o sledovanie bola prijatá" + youWereInvitedToGroup: "Pozvať do skupiny" + pollEnded: "Výsledky hlasovania sú k dispozícii." + emptyPushNotificationMessage: "Push notifikácie aktualizované" + _types: + all: "Všetky" + follow: "Sledujete" + mention: "Zmienka" + reply: "Odpovede" + renote: "Preposlať" + quote: "Citovať" + reaction: "Reakcie" + pollVote: "Hlasy v hlasovaniach" + pollEnded: "Hlasovanie skončilo" + receiveFollowRequest: "Doručené žiadosti o sledovanie" + followRequestAccepted: "Schválené žiadosti o sledovanie" + groupInvited: "Pozvánky do skupín" + app: "Oznámenia z prepojených aplikácií" + _actions: + followBack: "Sledovať späť\n" + reply: "Odpovedať" + renote: "Preposlať" +_deck: + alwaysShowMainColumn: "Vždy zobraziť v hlavnom stĺpci" + columnAlign: "Zarovnať stĺpce" + addColumn: "Pridať stĺpec" + configureColumn: "Nastavenie stĺpcov" + swapLeft: "Vymeniť vľavo" + swapRight: "Vymeniť vpravo" + swapUp: "Vymeniť hore" + swapDown: "Vymeniť s nasledujúcim" + stackLeft: "Priložiť do ľavého stĺpca" + popRight: "Vybrať napravo" + profile: "Profil" + newProfile: "Nový profil" + deleteProfile: "Odstrániť profil" + introduction: "Kombinujte stĺpce a vytvorte si svoje vlastné rozhranie!" + introduction2: "Stlačením tlačidla + v pravej časti obrazovky môžete kedykoľvek pridať stĺpce." + widgetsIntroduction: "V ponuke stĺpca vyberte možnosť \"Upraviť widget\" a pridajte widget" + _columns: + main: "Hlavný" + widgets: "Widgety" + notifications: "Oznámenia" + tl: "Časová os" + antenna: "Antény" + list: "Zoznam" + mentions: "Zmienky" + direct: "Priame poznámky" diff --git a/locales/sv-SE.yml b/locales/sv-SE.yml new file mode 100644 index 0000000..f2c0152 --- /dev/null +++ b/locales/sv-SE.yml @@ -0,0 +1,320 @@ +--- +_lang_: "Svenska" +headlineIceshrimp: "Ett nätverk kopplat av noter" +introIceshrimp: "Välkommen! Iceshrimp är en öppen och decentraliserad mikrobloggningstjänst.\nSkapa en \"not\" och dela dina tankar med alla runtomkring dig. 📡\nMed \"reaktioner\" kan du snabbt uttrycka dina känslor kring andras noter.👍\nLåt oss utforska en nya värld!🚀" +monthAndDay: "{day}/{month}" +search: "Sök" +notifications: "Notifikationer" +username: "Användarnamn" +password: "Lösenord" +forgotPassword: "Glömt lösenord" +fetchingAsApObject: "Hämtar från Fediversum" +ok: "OK" +gotIt: "Uppfattat!" +cancel: "Avbryt" +enterUsername: "Ange användarnamn" +renotedBy: "Omnoterad av {user}" +noNotes: "Inga noteringar" +noNotifications: "Inga aviseringar" +instance: "Instanser" +settings: "Inställningar" +basicSettings: "Basinställningar" +otherSettings: "Andra inställningar" +openInWindow: "Öppna i ett fönster" +profile: "Profil" +timeline: "Tidslinje" +noAccountDescription: "Användaren har inte skrivit en biografi än." +login: "Logga in" +loggingIn: "Loggar in" +logout: "Logga ut" +signup: "Registrera" +uploading: "Uppladdning sker..." +save: "Spara" +users: "Användare" +addUser: "Lägg till användare" +favorite: "Lägg till i favoriter" +favorites: "Favoriter" +unfavorite: "Avfavorisera" +favorited: "Tillagd i favoriter." +alreadyFavorited: "Redan tillagd i favoriter." +cantFavorite: "Gick inte att lägga till i favoriter." +pin: "Fäst till profil" +unpin: "Lossa från profil" +copyContent: "Kopiera innehåll" +copyLink: "Kopiera länk" +delete: "Radera" +deleteAndEdit: "Radera och ändra" +deleteAndEditConfirm: "Är du säker att du vill radera denna not och ändra den? Du kommer förlora alla reaktioner, omnoteringar och svar till den." +addToList: "Lägg till i lista" +sendMessage: "Skicka ett meddelande" +copyUsername: "Kopiera användarnamn" +searchUser: "Sök användare" +reply: "Svara" +loadMore: "Ladda mer" +showMore: "Visa mer" +youGotNewFollower: "följde dig" +receiveFollowRequest: "Följarförfrågan mottagen" +followRequestAccepted: "Följarförfrågan accepterad" +mention: "Nämn" +mentions: "Omnämningar" +directNotes: "Direktnoter" +importAndExport: "Importera / Exportera" +import: "Importera" +export: "Exportera" +files: "Filer" +download: "Nedladdning" +driveFileDeleteConfirm: "Är du säker att du vill radera filen \"{name}\"? Noter med denna fil bifogad kommer också raderas." +unfollowConfirm: "Är du säker att du vill avfölja {name}?" +exportRequested: "Du har begärt en export. Detta kan ta lite tid. Den kommer läggas till i din Drive när den blir klar." +importRequested: "Du har begärt en import. Detta kan ta lite tid." +lists: "Listor" +noLists: "Du har inga listor" +note: "Not" +notes: "Noter" +following: "Följer" +followers: "Följare" +followsYou: "Följer dig" +createList: "Skapa lista" +manageLists: "Hantera lista" +error: "Fel!" +somethingHappened: "Ett fel har uppstått" +retry: "Försök igen" +pageLoadError: "Det gick inte att ladda sidan." +pageLoadErrorDescription: "Detta händer oftast p.g.a. nätverksfel eller din webbläsarcache. Försök tömma din cache och testa sedan igen efter en liten stund." +serverIsDead: "Servern svarar inte. Vänta ett litet tag och försök igen." +youShouldUpgradeClient: "För att kunna se denna sida, vänligen ladda om sidan för att uppdatera din klient." +enterListName: "Skriv ett namn till listan" +privacy: "Integritet" +makeFollowManuallyApprove: "Följarförfrågningar kräver manuellt godkännande" +defaultNoteVisibility: "Standardsynlighet" +follow: "Följ" +followRequest: "Skicka följarförfrågan" +followRequests: "Följarförfrågningar" +unfollow: "Avfölj" +followRequestPending: "Följarförfrågning avvaktar för svar" +enterEmoji: "Skriv en emoji" +renote: "Omnotera" +unrenote: "Ta tillbaka omnotering" +renoted: "Omnoterad." +cantRenote: "Inlägget kunde inte bli omnoterat." +cantReRenote: "En omnotering kan inte bli omnoterad." +quote: "Citat" +pinnedNote: "Fästad not" +pinned: "Fäst till profil" +you: "Du" +clickToShow: "Klicka för att visa" +sensitive: "Känsligt innehåll" +add: "Lägg till" +reaction: "Reaktioner" +reactionSetting: "Reaktioner som ska visas i reaktionsväljaren" +reactionSettingDescription2: "Dra för att omordna, klicka för att radera, tryck \"+\" för att lägga till." +rememberNoteVisibility: "Komihåg notvisningsinställningar" +attachCancel: "Ta bort bilaga" +markAsSensitive: "Markera som känsligt innehåll" +unmarkAsSensitive: "Avmarkera som känsligt innehåll" +enterFileName: "Ange filnamn" +mute: "Tysta" +unmute: "Avtysta" +block: "Blockera" +unblock: "Avblockera" +suspend: "Suspendera" +unsuspend: "Ta bort suspenderingen" +blockConfirm: "Är du säker att du vill blockera kontot?" +unblockConfirm: "Är du säkert att du vill avblockera kontot?" +suspendConfirm: "Är du säker att du vill suspendera detta konto?" +unsuspendConfirm: "Är du säker att du vill avsuspendera detta konto?" +selectList: "Välj lista" +selectAntenna: "Välj en antenn" +selectWidget: "Välj en widget" +editWidgets: "Redigera widgets" +editWidgetsExit: "Avsluta redigering" +customEmojis: "Anpassa emoji" +emoji: "Emoji" +emojis: "Emoji" +emojiName: "Emoji namn" +emojiUrl: "Emoji länk" +addEmoji: "Lägg till emoji" +settingGuide: "Rekommenderade inställningar" +cacheRemoteFiles: "Spara externa filer till cachen" +cacheRemoteFilesDescription: "När denna inställning är avstängd kommer externa filer laddas direkt från den externa instansen. Genom att stänga av detta kommer lagringsutrymme minska i användning men kommer öka datatrafiken eftersom miniatyrer inte kommer genereras." +flagAsBot: "Markera konto som bot" +flagAsBotDescription: "Aktivera det här alternativet om kontot är kontrollerat av ett program. Om aktiverat kommer den fungera som en flagga för andra utvecklare för att hindra ändlösa kedjor med andra bottar. Det kommer också få Iceshrimps interna system att hantera kontot som en bot." +flagAsCat: "Markera konto som katt" +flagAsCatDescription: "Aktivera denna inställning för att markera kontot som en katt." +flagShowTimelineReplies: "Visa svar i tidslinje" +flagShowTimelineRepliesDescription: "Visar användarsvar till andra användares noter i tidslinjen om påslagen." +autoAcceptFollowed: "Godkänn följarförfrågningar från användare du följer automatiskt" +addAccount: "Lägg till konto" +loginFailed: "Inloggningen misslyckades" +showOnRemote: "Se på extern instans" +general: "Allmänt" +wallpaper: "Bakgrundsbild" +setWallpaper: "Välj bakgrund" +removeWallpaper: "Ta bort bakgrund" +searchWith: "Sök: {q}" +youHaveNoLists: "Du har inga listor" +followConfirm: "Är du säker att du vill följa {name}?" +proxyAccount: "Proxykonto" +proxyAccountDescription: "Ett proxykonto är ett konto som agerar som en extern följare för användare under vissa villkor. Till exempel, när en användare lägger till en extern användare till en lista så kommer den externa användarens aktivitet inte levireras till instansen om ingen lokal användare följer det kontot, så proxykontot används istället." +host: "Värd" +selectUser: "Välj användare" +recipient: "Mottagare" +annotation: "Kommentarer" +federation: "Federation" +instances: "Instanser" +registeredAt: "Registrerad på" +latestRequestSentAt: "Senaste förfrågan skickad" +latestRequestReceivedAt: "Senaste begäran mottagen" +latestStatus: "Senaste status" +storageUsage: "Använt lagringsutrymme" +charts: "Diagram" +perHour: "Per timme" +perDay: "Per dag" +stopActivityDelivery: "Sluta skicka aktiviteter" +blockThisInstance: "Blockera instans" +operations: "Operationer" +software: "Mjukvara" +version: "Version" +metadata: "Metadata" +monitor: "Övervakning" +jobQueue: "Jobbkö" +cpuAndMemory: "CPU och minne" +network: "Nätverk" +disk: "Disk" +instanceInfo: "Instansinformation" +statistics: "Statistik" +clearQueue: "Rensa kö" +clearQueueConfirmTitle: "Är du säker att du vill rensa kön?" +clearQueueConfirmText: "Om någon not är olevererad i kön kommer den inte federeras. Vanligtvis behövs inte denna handling." +clearCachedFiles: "Rensa cache" +clearCachedFilesConfirm: "Är du säker att du vill radera alla cachade externa filer?" +blockedInstances: "Blockerade instanser" +blockedInstancesDescription: "Lista adressnamn av instanser som du vill blockera. Listade instanser kommer inte längre kommunicera med denna instans." +muteAndBlock: "Tystningar och blockeringar" +mutedUsers: "Tystade användare" +blockedUsers: "Blockerade användare" +noUsers: "Det finns inga användare" +editProfile: "Redigera profil" +noteDeleteConfirm: "Är du säker på att du vill ta bort denna not?" +pinLimitExceeded: "Du kan inte fästa fler noter" +intro: "Iceshrimp har installerats! Vänligen skapa en adminanvändare." +done: "Klar" +processing: "Bearbetar..." +preview: "Förhandsvisning" +default: "Standard" +defaultValueIs: "Standard: {value}" +noCustomEmojis: "Det finns ingen emoji" +noJobs: "Det finns inga jobb" +federating: "Federerar" +blocked: "Blockerad" +suspended: "Suspenderad" +all: "Allt" +subscribing: "Prenumererar" +publishing: "Publiceras" +notResponding: "Svarar inte" +instanceFollowing: "Följer på instans" +instanceFollowers: "Följare av instans" +instanceUsers: "Användare av denna instans" +changePassword: "Ändra lösenord" +security: "Säkerhet" +retypedNotMatch: "Inmatningen matchar inte" +currentPassword: "Nuvarande lösenord" +newPassword: "Nytt lösenord" +newPasswordRetype: "Bekräfta lösenord" +attachFile: "Bifoga filer" +more: "Mer!" +featured: "Utvalda" +usernameOrUserId: "Användarnamn eller användar-id" +noSuchUser: "Kan inte hitta användaren" +lookup: "Sökning" +announcements: "Nyheter" +imageUrl: "Bild-URL" +remove: "Radera" +removed: "Borttaget" +removeAreYouSure: "Är du säker att du vill radera \"{x}\"?" +deleteAreYouSure: "Är du säker att du vill radera \"{x}\"?" +resetAreYouSure: "Vill du återställa?" +saved: "Sparad" +messaging: "Chatt" +upload: "Ladda upp" +keepOriginalUploading: "Behåll originalbild" +nsfw: "Känsligt innehåll" +pinnedNotes: "Fästad not" +userList: "Listor" +smtpHost: "Värd" +smtpUser: "Användarnamn" +smtpPass: "Lösenord" +clearCache: "Rensa cache" +user: "Användare" +searchByGoogle: "Sök" +file: "Filer" +_email: + _follow: + title: "följde dig" +_mfm: + mention: "Nämn" + quote: "Citat" + emoji: "Anpassa emoji" + search: "Sök" +_theme: + keys: + mention: "Nämn" + renote: "Omnotera" +_sfx: + note: "Noter" + notification: "Notifikationer" + chat: "Chatt" +_widgets: + notifications: "Notifikationer" + timeline: "Tidslinje" + federation: "Federation" + jobQueue: "Jobbkö" +_cw: + show: "Ladda mer" +_visibility: + followers: "Följare" +_profile: + username: "Användarnamn" +_exportOrImport: + followingList: "Följer" + muteList: "Tysta" + blockingList: "Blockera" + userLists: "Listor" +_charts: + federation: "Federation" +_pages: + script: + categories: + list: "Listor" + blocks: + _join: + arg1: "Listor" + _randomPick: + arg1: "Listor" + _dailyRandomPick: + arg1: "Listor" + _seedRandomPick: + arg2: "Listor" + _pick: + arg1: "Listor" + _listLen: + arg1: "Listor" + types: + array: "Listor" +_notification: + youWereFollowed: "följde dig" + _types: + follow: "Följer" + mention: "Nämn" + renote: "Omnotera" + quote: "Citat" + reaction: "Reaktioner" + _actions: + reply: "Svara" + renote: "Omnotera" +_deck: + _columns: + notifications: "Notifikationer" + tl: "Tidslinje" + list: "Listor" + mentions: "Omnämningar" diff --git a/locales/th-TH.yml b/locales/th-TH.yml new file mode 100644 index 0000000..9f2d9c3 --- /dev/null +++ b/locales/th-TH.yml @@ -0,0 +1,1192 @@ +--- +_lang_: "ภาษาไทย" +headlineIceshrimp: "เชื่อมต่อเครือข่ายโดยโน้ต" +introIceshrimp: "ยินดีต้อนรับจ้าาา! Iceshrimp เป็นบริการไมโครบล็อกโอเพ่นซอร์ส แบบการกระจายอำนาจ\nสร้าง \"โน้ต\" เพื่อแบ่งปันความคิดของคุณกับทุกคนรอบตัวคุณกันเถอะ 📡\nด้วยการ \"รีแอคชั่นผู้คน\" คุณยังสามารถแสดงความรู้สึกของคุณเกี่ยวกับบันทึกของทุกคนได้อย่างรวดเร็ว 👍\n\nแล้วมาท่องสำรวจโลกใบใหม่กันเถอะ! 🚀" +monthAndDay: "{เดือน}/{วัน}" +search: "ค้นหา" +notifications: "การเเจ้งเตือน" +username: "ชื่อผู้ใช้" +password: "รหัสผ่าน" +forgotPassword: "ลืมรหัสผ่าน?" +fetchingAsApObject: "กำลังดึงข้อมูล จาก เฟดิเวิร์ส" +ok: "ตกลง" +gotIt: "เข้าใจแล้ว !" +cancel: "ยกเลิก" +enterUsername: "ใส่ชื่อผู้ใช้" +renotedBy: "รีโน้ตโดย {ผู้ใช้}" +noNotes: "ไม่มีโน้ต" +noNotifications: "ไม่มีการแจ้งเตือน" +instance: "ตัวอย่าง" +settings: "การตั้งค่า" +basicSettings: "การตั้งค่าพื้นฐาน" +otherSettings: "การตั้งค่าอื่นๆ" +openInWindow: "เปิดในหน้าต่าง" +profile: "โปรไฟล์" +timeline: "ไทม์ไลน์" +noAccountDescription: "ผู้ใช้รายนี้ยังไม่ได้เขียนลงประวัติของพวกเขา" +login: "เข้าสู่ระบบ" +loggingIn: "กำลังเข้าสู่ระบบ" +logout: "ออกจากระบบ" +signup: "สร้างบัญชีผู้ใช้" +uploading: "กำลังอัพโหลด..." +save: "บันทึก" +users: "ผู้ใช้งาน" +addUser: "เพิ่มผู้ใช้" +favorite: "รายการโปรด" +favorites: "รายการโปรด" +unfavorite: "ลบออกจากรายการโปรด" +favorited: "เพิ่มแล้วในรายการโปรด" +alreadyFavorited: "เพิ่มในรายการโปรดอยู่แล้ว" +cantFavorite: "ไม่สามารถเพิ่มในรายการโปรดได้" +pin: "ปักหมุดไปยังโปรไฟล์" +unpin: "เลิกปักหมุดจากโปรไฟล์" +copyContent: "คัดลอกเนื้อหา" +copyLink: "คัดลอกลิงก์" +delete: "ลบ" +deleteAndEdit: "ลบและแก้ไข" +deleteAndEditConfirm: "นายแน่ใจแล้วเหรอ? ว่าต้องการลบโน้ตนี้และแก้ไข คุณอาจจะสูญเสียการโต้ตอบ, โน้ต, และการตอบกลับทั้งหมดได้นะ" +addToList: "เพิ่มในลิสต์" +sendMessage: "ส่งข้อความ" +copyUsername: "คัดลอกชื่อผู้ใช้" +searchUser: "ค้นหาผู้ใช้งาน" +reply: "ตอบกลับ" +loadMore: "โหลดเพิ่มเติม" +showMore: "แสดงเพิ่มเติม" +showLess: "ปิด" +youGotNewFollower: "ได้ติดตามคุณ" +receiveFollowRequest: "คำขอผู้ติดตามที่ได้รับ" +followRequestAccepted: "ผู้ติดตามได้ตอบรับคำขอร้องของคุณแล้ว" +mention: "กล่าวถึง" +mentions: "พูดถึง" +directNotes: "ไดเร็คโน้ต" +importAndExport: "นำเข้า / ส่งออก" +import: "การนำเข้า" +export: "การนำออก" +files: "ไฟล์" +download: "ดาวน์โหลด" +driveFileDeleteConfirm: "นายแน่ใจแล้วหรอ? ว่าต้องการลบไฟล์ \"{name}\" โน้ตย่อที่แนบมากับไฟล์นี้ก็จะถูกลบด้วยนะ" +unfollowConfirm: "นายแน่ใจแล้วหรอว่าต้องการเลิกติดตาม {name}?" +exportRequested: "เมื่อคุณได้ร้องขอการส่งออก อาจจะต้องใช้เวลาสักครู่ และจะถูกเพิ่มในไดรฟ์ของคุณเมื่อเสร็จสิ้นแล้ว" +importRequested: "เมื่อคุณได้ร้องขอการนำเข้า อาจจะต้องใช้เวลาสักครู่นะ" +lists: "รายการ" +noLists: "คุณไม่มีลิสต์ใดๆนะ" +note: "ตัวโน้ต" +notes: "หมายเหตุ" +following: "กำลังติดตาม" +followers: "ผู้ติดตาม" +followsYou: "ติดตามคุณ" +createList: "สร้างลิสต์" +manageLists: "จัดการลิสต์" +error: "ผิดพลาด!" +somethingHappened: "อุ๊ย ! มีอะไรบางอย่างผิดพลาด" +retry: "ลองใหม่อีกครั้ง" +pageLoadError: "เกิดข้อผิดพลาดในการโหลดหน้านี้" +pageLoadErrorDescription: "โดยปกติแล้วมักจะเกิดจากข้อผิดพลาดของเครือข่ายหรือแคชของเบราว์เซอร์ ลองล้างแคชแล้วลองใหม่อีกครั้งหลังจากรอสักครู่ " +serverIsDead: "เซิร์ฟเวอร์นี้ไม่มีการตอบสนอง ได้โปรดกรุณารอสักครู่แล้วลองใหม่อีกครั้งนะ" +youShouldUpgradeClient: "หากต้องการดูหน้านี้ได้โปรดกรุณา รีเซ็ตเพื่ออัปเดตไคลเอ็นต์ของคุณนะ" +enterListName: "ใส่ชื่อสำหรับรายการลิสต์" +privacy: "ความเป็นส่วนตัว" +makeFollowManuallyApprove: "ติดตามคำขอที่ต้องได้รับการอนุมัติ" +defaultNoteVisibility: "การมองเห็นที่เป็นค่าเริ่มต้น" +follow: "กำลังติดตาม" +followRequest: "ส่งคำขอติดตาม" +followRequests: "ติดตามการร้องขอ" +unfollow: "เลิกติดตาม" +followRequestPending: "กำลังรอดำเนินการร้องขอติดตาม" +enterEmoji: "ใส่อีโมจิ" +renote: "รีโน้ต" +unrenote: "เลิกรีโน้ต" +renoted: "รีโน้ตเอาไว้" +cantRenote: "โพสต์นี้ไม่สามารถรีโน้ตไว้ใหม่ได้นะ" +cantReRenote: "ไม่สามารถรีโน้ตเอาไว้ใหม่ได้นะ" +quote: "อ้างคำพูด" +pinnedNote: "โน้ตที่ปักหมุดเอาไว้" +pinned: "ปักหมุดไปยังโปรไฟล์" +you: "ตัวเอง" +clickToShow: "คลิกเพื่อแสดง" +sensitive: "เนื้อหาที่ละเอียดอ่อน NSFW" +add: "เพิ่ม" +reaction: "รีแอคชั่น" +reactionSetting: "รีแอคชั่นไปยังแสดงผลในตัวเลือกการรีแอคชั่น" +reactionSettingDescription2: "กดลากเพื่อจัดลำดับใหม่ กดคลิกเพื่อลบ กด \"+\" เพื่อเพิ่ม" +rememberNoteVisibility: "จดจำการตั้งค่าการมองเห็นตัวโน้ต" +attachCancel: "ลบไฟล์ออกที่แนบมา" +markAsSensitive: "ทำเครื่องหมายว่าละเอียดอ่อน" +unmarkAsSensitive: "ยกเลิกทำเครื่องหมายเป็น NSFW" +enterFileName: "พิมพ์ชื่อไฟล์" +mute: "ปิดเสียง" +unmute: "ไม่ปิดเสียง" +block: "บล็อค" +unblock: "เลิกปิดกั้น" +suspend: "ถูกระงับ" +unsuspend: "ยกเลิกระงับ" +blockConfirm: "คุณแน่ใจแล้วเหรอ? ว่าต้องการบล็อกบัญชีนี้" +unblockConfirm: "คุณแน่ใจแล้วเหรอ? ว่าต้องการปลดบล็อคบัญชีนี้" +suspendConfirm: "นายแน่ใจแล้วเหรอว่าต้องการระงับบัญชีนี้อ่ะ?" +unsuspendConfirm: "นายแน่ใจแล้วหรอ? ว่าต้องการยกเลิกการระงับบัญชีนี้" +selectList: "เลือกรายการ (Automatic Translation)" +selectAntenna: "เลือกเสาอากาศ" +selectWidget: "เลือกวิดเจ็ต" +editWidgets: "แก้ไขวิดเจ็ต" +editWidgetsExit: "เรียบร้อย" +customEmojis: "กำหนดอีโมจิเอง" +emoji: "อีโมจิ" +emojis: "อีโมจิ" +emojiName: "ชื่ออิโมจิ" +emojiUrl: "อิโมจิ URL" +addEmoji: "แทรกอีโมจิ" +settingGuide: "การตั้งค่าที่แนะนำ" +cacheRemoteFiles: "แคชไฟล์ระยะไกล" +cacheRemoteFilesDescription: "เมื่อปิดใช้งานการตั้งค่านี้ ไฟล์ระยะไกลนั้นจะถูกโหลดโดยตรงจากอินสแตนซ์ระยะไกล แต่กรณีการปิดใช้งานนี้จะช่วยลดปริมาณการใช้พื้นที่จัดเก็บข้อมูล แต่เพิ่มปริมาณการใช้งาน เพราะเนื่องจากจะไม่มีการสร้างภาพขนาดย่อ" +flagAsBot: "ทำเครื่องหมายบอกว่าบัญชีนี้เป็นบอท" +flagAsBotDescription: "การเปิดใช้งานตัวเลือกนี้หากบัญชีนี้ถูกควบคุมโดยนักเขียนโปรแกรม หรือ ถ้าหากเปิดใช้งาน มันจะทำหน้าที่เป็นแฟล็กสำหรับนักพัฒนารายอื่นๆ และเพื่อป้องกันการโต้ตอบแบบไม่มีที่สิ้นสุดกับบอทตัวอื่นๆ และยังสามารถปรับเปลี่ยนระบบภายในของ Iceshrimp เพื่อปฏิบัติต่อบัญชีนี้เป็นบอท" +flagAsCat: "ทำเครื่องหมายบอกว่าบัญชีนี้เป็นแมว" +flagAsCatDescription: "การเปิดใช้งานตัวเลือกนี้เพื่อทำเครื่องหมายบอกว่าบัญชีนี้เป็นแมว" +flagShowTimelineReplies: "แสดงตอบกลับ ในไทม์ไลน์" +flagShowTimelineRepliesDescription: "แสดงการตอบกลับของผู้ใช้งานไปยังโน้ตของผู้ใช้งานรายอื่นๆในไทม์ไลน์หากได้เปิดเอาไว้" +autoAcceptFollowed: "อนุมัติคำขอติดตามโดยอัตโนมัติทันที จากผู้ใช้งานที่คุณกำลังติดตาม" +addAccount: "เพิ่มบัญชี" +loginFailed: "การเข้าสู่ระบบไม่สำเร็จ" +showOnRemote: "ดูบนอินสแตนซ์ระยะไกล" +general: "ทั่วไป" +wallpaper: "วอลล์เปเปอร์" +setWallpaper: "ตั้งวอลเปเปอร์" +removeWallpaper: "นำวอลเปเปอร์ออก" +searchWith: "ค้นหา: {q}" +youHaveNoLists: "รายการนี้ว่างเปล่า" +followConfirm: "คุณแน่ใจแล้วหรอว่าต้องการที่จะติดตาม {name}?" +proxyAccount: "บัญชี พร็อกซี่" +proxyAccountDescription: "บัญชีพร็อกซี่ คือ บัญชีที่จะทำหน้าที่เป็นผู้ติดตามระยะไกลสำหรับผู้ใช้งานที่อยู่ภายใต้ด้วยเงื่อนไขบางอย่าง ยกตัวอย่าง เช่น เมื่อมีผู้ใช้งานนั้นได้เพิ่มผู้ใช้งานจากระยะไกลลงในรายการ แต่กิจกรรมของผู้ใช้ในระยะไกลนั้นจะไม่ถูกส่งไปยังอินสแตนซ์หากไม่มีผู้ใช้งานในพื้นที่ติดตามผู้ใช้รายนั้น ดังนั้นบัญชีพร็อกซีนี้จะติดตามแทน" +host: "โฮสต์" +selectUser: "เลือกผู้ใช้งาน" +recipient: "ผู้รับ" +annotation: "ความคิดเห็น" +federation: "สหพันธ์" +instances: "ตัวอย่าง" +registeredAt: "จดทะเบียนที่" +latestRequestSentAt: "ส่งคำขอล่าสุดไปแล้ว" +latestRequestReceivedAt: "ได้รับคำขอล่าสุดไปแล้ว" +latestStatus: "สถานะล่าสุด" +storageUsage: "พื้นที่จัดเก็บข้อมูลที่ใช้ไป" +charts: "โดดเด่น" +perHour: "ทุกชั่วโมง" +perDay: "ต่อวัน" +stopActivityDelivery: "หยุดส่งกิจกรรม" +blockThisInstance: "บล็อกอินสแตนซ์นี้" +operations: "ดำเนินการ" +software: "ซอฟต์แวร์" +version: "เวอร์ชั่น" +metadata: "ข้อมูลเมตา" +monitor: "มอนิเตอร์" +jobQueue: "คิวงาน" +cpuAndMemory: "ซีพียู และ หน่วยความจำ" +network: "เน็ตเวิร์ก" +disk: "ดิสก์" +instanceInfo: "ข้อมูล อินสแตนซ์" +statistics: "สถิติการใช้งาน" +clearQueue: "ล้างคิว" +clearQueueConfirmTitle: "คุณแน่ใจแล้วหรอว่าต้องการที่จะล้างคิว?" +clearQueueConfirmText: "บันทึกย่อที่ยังไม่ได้ส่งที่เหลืออยู่ในคิวนั้นมักจะ ไม่ถูกรวมเข้าด้วยกัน โดยปกติแล้วไม่จำเป็นต้องดำเนินการนี้" +clearCachedFiles: "ล้างแคช" +clearCachedFilesConfirm: "นายแน่ใจแล้วหรอว่าต้องการที่จะลบไฟล์ระยะไกลที่แคชไว้ทั้งหมด?" +blockedInstances: "อินสแตนซ์ที่ ถูกบล็อก" +blockedInstancesDescription: "ระบุชื่อโฮสต์ของอินสแตนซ์ที่คุณต้องการบล็อก อินสแตนซ์ที่อยู่ในรายการนั้นจะไม่สามารถพูดคุยกับอินสแตนซ์นี้ได้อีกต่อไป" +muteAndBlock: "ปิดเสียงและบล็อก" +mutedUsers: "ผู้ใช้ที่ถูกปิดเสียง" +blockedUsers: "ผู้ใช้ที่ถูกบล็อก" +noUsers: "ไม่พบผู้ใช้งาน" +editProfile: "แก้ไขโปรไฟล์" +noteDeleteConfirm: "นายแน่ใจแล้วหรอว่าต้องการลบโน้ตนี้นะ?" +pinLimitExceeded: "คุณไม่สามารถปักหมุดโน้ตเพิ่มเติมใดๆได้อีก" +intro: "การติดตั้ง Iceshrimp เสร็จสิ้นแล้วนะ! โปรดสร้างผู้ใช้งานที่เป็นผู้ดูแลระบบ" +done: "เสร็จสิ้น" +processing: "กำลังประมวลผล..." +preview: "แสดงตัวอย่าง" +default: "ค่าตั้งต้น" +defaultValueIs: "ค่าเริ่มต้น: {value}" +noCustomEmojis: "ไม่มีอีโมจิ" +noJobs: "ไม่มีชิ้นงาน" +federating: "สหพันธ์" +blocked: "ถูกบล็อก" +suspended: "ถูกระงับ" +all: "ทั้งหมด" +subscribing: "สมัครแล้ว" +publishing: "กำลังเผยแพร่" +notResponding: "ไม่มีการตอบสนอง" +instanceFollowing: "กำลังติดตาม บน อินสแตนซ์" +instanceFollowers: "ผู้ติดตามของอินสแตนซ์" +instanceUsers: "ผู้ใช้งานของอินสแตนซ์นี้" +changePassword: "เปลี่ยนรหัสผ่าน" +security: "ความปลอดภัย" +retypedNotMatch: "อินพุตไม่ตรงกันนะ" +currentPassword: "รหัสผ่านปัจจุบัน" +newPassword: "รหัสผ่านใหม่" +newPasswordRetype: "ใส่รหัสผ่านใหม่อีกครั้ง" +attachFile: "แนบไฟล์" +more: "เพิ่มเติม!" +featured: "เป็นจุดเด่น" +usernameOrUserId: "ชื่อผู้ใช้หรือรหัสผู้ใช้งาน" +noSuchUser: "ไม่มีผู้ใช้นี้อยู่ในระบบ" +lookup: "ค้นหา" +announcements: "ประกาศ" +imageUrl: "url รูปภาพ" +remove: "ลบ" +removed: "ถูกลบไปแล้ว" +removeAreYouSure: "นายแน่ใจจริงหรอว่าต้องการที่จะลบออก \"{x}\"" +deleteAreYouSure: "นายแน่ใจจริงหรอว่าต้องการที่จะลบออก \"{x}\"" +resetAreYouSure: "รีเซ็ตเลยไหม" +saved: "บันทึกแล้ว" +messaging: "แชท" +upload: "อัพโหลด" +keepOriginalUploading: "เก็บภาพต้นฉบับ" +keepOriginalUploadingDescription: "บันทึกรูปภาพที่อัพโหลดต้นฉบับตามที่เป็นอยู่ ถ้าหากปิดอยู่ ระบบจะสร้างเวอร์ชั่นที่จะแสดงบนเว็บเมื่ออัพโหลดนะ" +fromDrive: "จากไดรฟ์" +fromUrl: "จาก URL" +uploadFromUrl: "อัพโหลดจาก URL" +uploadFromUrlDescription: "URL ของไฟล์ที่คุณต้องการอัปโหลด" +uploadFromUrlRequested: "อัพโหลดที่ร้องขอ" +uploadFromUrlMayTakeTime: "มันอาจจะต้องใช้เวลาสักครู่จนกว่าการอัพโหลดจะเสร็จสมบูรณ์นะ" +explore: "สำรวจ" +messageRead: "อ่านแล้ว" +noMoreHistory: "ในนั้นไม่มีประวัติอีกต่อไปแล้วนะ" +startMessaging: "เริ่มการสนทนา" +nUsersRead: "อ่านโดย {n}" +agreeTo: "ฉันยอมรับที่จะ {0}" +tos: "ข้อกำหนดและเงื่อนไข" +start: "เริ่มต้น​ใช้งาน​" +home: "หน้าแรก" +remoteUserCaution: "เนื่องจากผู้ใช้งานรายนี้นั้น มาจากอินสแตนซ์ระยะไกล ข้อมูลที่แสดงดังกล่าวนั้นอาจจะไม่สมบูรณ์ก็ได้นะ" +activity: "กิจกรรม" +images: "รูปภาพ" +birthday: "วันเกิด" +yearsOld: "{อายุ} ปี" +registeredDate: "วันที่สมัครสมาชิก" +location: "ตำแหน่งที่ตั้ง" +theme: "ธีม" +themeForLightMode: "ธีมที่จะใช้ในโหมดแสง" +themeForDarkMode: "ธีมที่จะใช้ในโหมดมืด" +light: "สว่าง" +dark: "มืด" +lightThemes: "ธีมสีสว่าง" +darkThemes: "ธีมมืด" +syncDeviceDarkMode: "ซิงค์โหมดมืดด้วยการตั้งค่ากับอุปกรณ์" +drive: "ไดรฟ์" +fileName: "ชื่อไฟล์" +selectFile: "เลือกไฟล์" +selectFiles: "เลือกไฟล์" +selectFolder: "เลือกโฟลเดอร์" +selectFolders: "เลือกโฟลเดอร์" +renameFile: "เปลี่ยนชื่อไฟล์" +folderName: "ชื่อแฟ้ม" +createFolder: "สร้างโฟลเดอร์" +renameFolder: "เปลี่ยนชื่อโฟลเดอร์" +deleteFolder: "ลบโฟลเดอร์" +addFile: "เพิ่มไฟล์" +emptyDrive: "ไดรฟ์ของคุณว่างเปล่านะ" +emptyFolder: "โฟลเดอร์นี้น่าจะว่างเปล่านะ" +unableToDelete: "ไม่สามารถลบออกได้นะ" +inputNewFileName: "ป้อนชื่อไฟล์ใหม่นะ" +inputNewDescription: "กรุณาใส่แคปชั่นใหม่" +inputNewFolderName: "กรุณาใส่ชื่อโฟลเดอร์ใหม่นะ\n" +circularReferenceFolder: "โฟลเดอร์ปลายทาง คือ โฟลเดอร์ย่อยของโฟลเดอร์ที่คุณต้องการที่จะย้ายล่ะนะ" +hasChildFilesOrFolders: "เนื่องจากโฟลเดอร์นี้ไม่ว่างเปล่า จึงไม่สามารถลบได้นะ" +copyUrl: "คัดลอก URL" +rename: "เปลี่ยนชื่อ" +avatar: "ไอคอน" +banner: "แบนเนอร์" +nsfw: "เนื้อหาที่ละเอียดอ่อน NSFW" +whenServerDisconnected: "สูญเสียการเชื่อมต่อกับเซิร์ฟเวอร์" +disconnectedFromServer: "ถูกตัดการเชื่อมต่อออกจากเซิร์ฟเวอร์" +reload: "รีโหลด" +doNothing: "เมิน" +reloadConfirm: "นายต้องการรีเฟรชไทม์ไลน์หรือป่าว?" +watch: "ดู" +unwatch: "หยุดดู" +accept: "ยอมรับ" +reject: "ปฏิเสธ" +normal: "โหมดปกติ" +instanceName: "ชื่อ อินสแตนซ์" +instanceDescription: "คำอธิบายอินสแตนซ์" +maintainerName: "ผู้ดูแล" +maintainerEmail: "อีเมล์แอดมิน" +tosUrl: "เงื่อนไขการให้บริการ URL" +thisYear: "ปีนี้" +thisMonth: "เดือนนี้" +today: "วันนี้" +dayX: "{วัน}" +monthX: "{เดือน}" +yearX: "{ปี}" +pages: "หน้า" +integration: "รวบรวม" +connectService: "เชื่อมต่อ" +disconnectService: "ตัดการเชื่อมต่อ" +enableLocalTimeline: "เปิดใช้งานไทม์ไลน์ในพื้นที่" +enableGlobalTimeline: "เปิดใช้งานไทม์ไลน์ทั่วโลก" +disablingTimelinesInfo: "ผู้ดูแลระบบและผู้ควบคุมจะสามารถเข้าถึงไทม์ไลน์ทั้งหมด ถึงแม้ว่าจะไม่ได้เปิดใช้งานก็ตาม" +registration: "ลงทะเบียน" +enableRegistration: "เปิดใช้งานการลงทะเบียนผู้ใช้ใหม่" +invite: "เชิญชวน" +driveCapacityPerLocalAccount: "ความจุของไดรฟ์ต่อผู้ใช้ภายในเครื่อง" +driveCapacityPerRemoteAccount: "ความจุของไดรฟ์ต่อผู้ใช้ระยะไกล" +inMb: "เป็นเมกะไบต์" +iconUrl: "ไอคอน URL" +bannerUrl: "URL รูปภาพแบนเนอร์" +backgroundImageUrl: "URL ภาพพื้นหลัง" +basicInfo: "ข้อมูลเบื้องต้น" +pinnedUsers: "ผู้ใช้งานที่ได้รับการปักหมุด" +pinnedUsersDescription: "ลิสต์ชื่อผู้ใช้โดยคั่นด้วยการขึ้นบรรทัดใหม่เพื่อปักหมุดในแท็บ \"สำรวจ\"" +pinnedPages: "หน้าที่ปักหมุด" +pinnedPagesDescription: "ป้อนเส้นทางของหน้าที่คุณต้องการตรึงไว้ที่หน้าแรกของอินสแตนซ์นี้ โดยคั่นด้วยตัวแบ่งบรรทัด" +pinnedClipId: "ID ของคลิปที่จะปักหมุด" +pinnedNotes: "โน้ตที่ปักหมุดเอาไว้" +hcaptcha: "hCaptcha" +enableHcaptcha: "เปิดใช้ hCaptcha" +hcaptchaSiteKey: "คีย์ไซต์" +hcaptchaSecretKey: "คีย์ลับ" +recaptcha: "reCAPTCHA" +enableRecaptcha: "เปิดใช้ reCAPTCHA" +recaptchaSiteKey: "คีย์ไซต์" +recaptchaSecretKey: "คีย์ลับ" +avoidMultiCaptchaConfirm: "การใช้ระบบ Captcha หลายระบบอาจทำให้เกิดการรบกวนหรืออาจจะเกิดข้อผิดพลาดได้ หากต้องการที่จะปิดการใช้งานระบบ Captcha อื่น ๆ แนะนำให้ปิดตัวอื่นๆก่อน ถ้าหากคุณต้องการให้เปิดใช้งานต่อไป ให้ กด ยกเลิก" +antennas: "เสาอากาศ" +manageAntennas: "จัดการเสาอากาศ" +name: "ชื่อ" +antennaSource: "แหล่งเสาอากาศ" +antennaKeywords: "คีย์เวิร์ดที่ควรฟัง" +antennaExcludeKeywords: "คีย์เวิร์ดที่จะยกเว้น" +antennaKeywordsDescription: "คั่นด้วยช่องว่างสำหรับเงื่อนไข AND หรือด้วยการขึ้นบรรทัดใหม่สำหรับเงื่อนไข OR นะ" +notifyAntenna: "แจ้งเตือนเกี่ยวกับโน้ตใหม่" +withFileAntenna: "เฉพาะโน้ตที่มีไฟล์" +enableServiceworker: "เปิดใช้งาน การแจ้งเตือนแบบพุชสำหรับเบราว์เซอร์ของคุณ" +antennaUsersDescription: "ระบุหนึ่งชื่อผู้ใช้ต่อบรรทัด" +caseSensitive: "กรณีที่สำคัญ" +withReplies: "รวมตอบกลับ" +connectedTo: "บัญชีดังต่อไปนี้มีการเชื่อมต่อกัน" +notesAndReplies: "โพสต์และการตอบกลับ" +withFiles: "รวบรวมไฟล์" +silence: "ถูกปิดปาก" +silenceConfirm: "นายแน่ใจแล้วหรอว่าต้องการที่จะ ปิดปาก ผู้ใช้งานรายนี้?" +unsilence: "ยกเลิกการปิดปาก" +unsilenceConfirm: "นายแน่ใจแล้วหรอว่าต้องการที่จะยกเลิกปิดปากผู้ใช้งานรายนี้?" +popularUsers: "ผู้ใช้ที่เป็นที่นิยม" +recentlyUpdatedUsers: "ผู้ใช้ที่เพิ่งใช้งานล่าสุด" +recentlyRegisteredUsers: "ผู้ใช้ที่เข้าร่วมใหม่" +recentlyDiscoveredUsers: "ผู้ใช้ที่เพิ่งค้นพบใหม่" +exploreUsersCount: "มีผู้ใช้ {จำนวน} ราย" +exploreFediverse: "สำรวจเฟดดิเวิร์ส" +popularTags: "แท็กยอดนิยม" +userList: "รายการ" +about: "เกี่ยวกับ" +aboutIceshrimp: "เกี่ยวกับ Iceshrimp" +administrator: "ผู้ดูแลระบบ" +token: "โทเค็น" +twoStepAuthentication: "ยืนยันตัวตน 2 ชั้น" +moderator: "ผู้ควบคุม" +moderation: "การกลั่นกรอง" +nUsersMentioned: "กล่าวถึงโดยผู้ใช้ {n} รายนี้" +securityKey: "กุญแจความปลอดภัย" +securityKeyName: "ชื่อคีย์" +registerSecurityKey: "ลงทะเบียนรหัสความปลอดภัยคีย์" +lastUsed: "ใช้ล่าสุด" +unregister: "เลิกติดตาม" +passwordLessLogin: "เข้าสู่ระบบแบบไม่ใช้รหัสผ่าน" +resetPassword: "รีเซ็ตรหัสผ่าน" +newPasswordIs: "รหัสผ่านใหม่คือ \"{password}\"" +reduceUiAnimation: "ลดภาพเคลื่อนไหว UI" +share: "แชร์" +notFound: "ไม่พบหน้าที่ต้องการ" +notFoundDescription: "ไม่พบหน้าที่สอดคล้องตรงกันกับ URL นี้นะ" +uploadFolder: "โฟลเดอร์เริ่มต้นสำหรับอัพโหลด" +cacheClear: "ล้างแคช" +markAsReadAllNotifications: "ทำเครื่องหมายการแจ้งเตือนทั้งหมดว่าอ่านแล้ว" +markAsReadAllUnreadNotes: "ทำเครื่องหมายโน้ตทั้งหมดว่าอ่านแล้ว" +markAsReadAllTalkMessages: "ทำเครื่องหมายข้อความทั้งหมดว่าอ่านแล้ว" +help: "ช่วยเหลือ" +inputMessageHere: "พิมพ์ข้อความที่นี่" +close: "ปิด" +group: "กลุ่ม" +groups: "กลุ่ม" +createGroup: "สร้างกลุ่ม" +ownedGroups: "กลุ่มที่เป็นเจ้าของ" +joinedGroups: "เข้าร่วมกลุ่ม" +invites: "เชิญชวน" +groupName: "ชื่อกลุ่ม" +members: "สมาชิก" +transfer: "ถ่ายโอน" +messagingWithUser: "แชทส่วนตัว" +messagingWithGroup: "แชทกลุ่ม" +title: "หัวข้อ" +text: "ข้อความ" +enable: "เปิดใช้งาน" +next: "ถัด​ไป" +retype: "พิมพ์รหัสอีกครั้ง" +noteOf: "โน้ต โดย {ผู้ใช้งาน}" +inviteToGroup: "ชวนเข้ากลุ่ม" +quoteAttached: "อ้างอิง" +quoteQuestion: "นายต้องการที่จะอ้างอิงหรอ?" +noMessagesYet: "ยังไม่มีข้อความนะ" +newMessageExists: "คุณมีข้อความใหม่" +onlyOneFileCanBeAttached: "คุณสามารถแนบไฟล์กับข้อความได้เพียงไฟล์เดียวเท่านั้นนะ" +signinRequired: "กรุณาลงทะเบียนหรือลงชื่อเข้าใช้ก่อนดำเนินการต่อนะ" +invitations: "เชิญชวน" +invitationCode: "รหัสคำเชิญ" +checking: "Checking" +available: "พร้อมใช้งาน" +unavailable: "ไม่พร้อมใช้" +usernameInvalidFormat: "คุณสามารถใช้อักษรตัวพิมพ์ใหญ่และตัวพิมพ์เล็ก ตัวเลข และขีดล่างได้นะ ( a-z , A-Z , 0-9 , รวมไปถึงอักษรพิเศษเช่น + * / , . - อื่นๆเป็นต้น )" +tooShort: "สั้นเกินไปนะ" +tooLong: "ยาวเกินไปนะ" +weakPassword: "รหัสผ่าน แย่มาก" +normalPassword: "รหัสผ่านปกติ" +strongPassword: "รหัสผ่านรัดกุมมาก" +passwordMatched: "ถูกต้อง!" +passwordNotMatched: "ไม่ถูกต้อง" +signinWith: "ลงชื่อเข้าใช้ด้วย {x}" +signinFailed: "ไม่สามารถลงชื่อผู้เข้าใช้ได้ เนื่องจาก ชื่อผู้ใช้หรือรหัสผ่านที่คุณป้อนนั้นไม่ถูกต้องนะ" +tapSecurityKey: "แตะคีย์ความปลอดภัย" +or: "หรือ" +language: "ภาษา" +uiLanguage: "ภาษาอินเทอร์เฟซผู้ใช้งาน" +groupInvited: "คุณได้รับเชิญให้เข้าร่วมกลุ่ม" +aboutX: "เกี่ยวกับ {x}" +useOsNativeEmojis: "ใช้อีโมจิ OS แบบดั้งเดิม" +disableDrawer: "อย่าใช้ลิ้นชักสไตล์เมนู" +youHaveNoGroups: "คุณยังไม่มีกลุ่ม" +joinOrCreateGroup: "รับเชิญเข้าร่วมกลุ่มหรือสร้างกลุ่มของคุณเองเลยนะ" +noHistory: "ไม่มีรายการ" +signinHistory: "ประวัติการเข้าสู่ระบบ" +disableAnimatedMfm: "ปิดการใช้งาน MFM ด้วยแอนิเมชั่น" +doing: "กำลังประมวลผล......" +category: "หมวดหมู่" +tags: "แท็ก" +docSource: "ที่มาของเอกสารนี้" +createAccount: "สร้างบัญชี" +existingAccount: "บัญชีที่มีอยู่" +regenerate: "สร้างอีกครั้ง" +fontSize: "ขนาดตัวอักษร" +noFollowRequests: "คุณไม่มีคำขอติดตามที่รอดำเนินการ" +openImageInNewTab: "เปิดรูปภาพในแท็บใหม่" +dashboard: "หน้ากระดานหลัก" +local: "ในพื้นที่" +remote: "ระยะไกล" +total: "รวมทั้งหมด" +weekOverWeekChanges: "เปลี่ยนแปลงไปเมื่อสัปดาห์ที่แล้ว" +dayOverDayChanges: "เปลี่ยนแปลงไปเมื่อวานนี้" +appearance: "ภาพลักษณ์" +clientSettings: "การตั้งค่าไคลเอนต์" +accountSettings: "ตั้งค่าบัญชี" +promotion: "โฆษณา" +promote: "โปรโมท" +numberOfDays: "จำนวนวัน" +hideThisNote: "ซ่อนโน้ตนี้" +showFeaturedNotesInTimeline: "แสดงโน้ตเด่นในไทม์ไลน์" +objectStorage: "อ็อบเจ็กต์ ที่จัดเก็บ" +useObjectStorage: "ใช้ อ็อบเจ็กต์ ที่จัดเก็บ" +objectStorageBaseUrl: "URL ฐาน" +objectStorageBaseUrlDesc: "URL ที่ใช้เป็นข้อมูลอ้างอิง ระบุ URL ของ CDN หรือ Proxy ถ้าหากคุณใช้อย่างใดอย่างหนึ่ง\n สำหรับการใช้งาน S3 'https://.s3.amazonaws.com' และสำหรับ GCS หรือบริการที่เทียบเท่าใช้ 'https://storage.googleapis.com/', เป็นต้น" +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "โปรดระบุชื่อที่เก็บข้อมูลที่ใช้กับผู้ให้บริการของคุณ" +objectStoragePrefix: "คำนำหน้า" +objectStoragePrefixDesc: "ไฟล์ทั้งหมดจะถูกเก็บไว้ภายใต้ไดเร็กทอรีที่มีคำนำหน้านี้นะ" +objectStorageEndpoint: "ปลายทาง" +objectStorageEndpointDesc: "เว้นว่างไว้หากคุณใช้ AWS S3 หรือระบุปลายทางเป็น '' หรือ ':' ทั้งนี้ขึ้นอยู่กับผู้ให้บริการที่คุณใช้อยู่ด้วย" +objectStorageRegion: "ภูมิภาค" +objectStorageRegionDesc: "ระบุภูมิภาค เช่น 'xx-east-1' ถ้าหากบริการของคุณไม่ได้แยกความแตกต่างระหว่างภูมิภาคก็ให้ เว้นว่างไว้หรือป้อน 'us-east-1'" +objectStorageUseSSL: "ใช้ SSL" +objectStorageUseSSLDesc: "ปิดการทำงานนี้ไว้ ถ้าหากคุณจะไม่ใช้ HTTPS สำหรับการเชื่อมต่อ API" +objectStorageUseProxy: "เชื่อมต่อผ่านพร็อกซี" +objectStorageUseProxyDesc: "ปิดสิ่งนี้ไว้ถ้าหากคุณจะไม่ใช้ Proxy สำหรับการเชื่อมต่อ API" +objectStorageSetPublicRead: "ตั้งค่า \"public-read\" ในการอัปโหลด" +serverLogs: "บันทึกของเซิร์ฟเวอร์" +deleteAll: "ลบทั้งหมด" +showFixedPostForm: "แสดงแบบฟอร์มการโพสต์ที่ด้านบนสุดของไทม์ไลน์" +newNoteRecived: "มีโน้ตใหม่" +sounds: "เสียง" +listen: "ฟัง" +none: "ไม่มี" +showInPage: "แสดงในเพจ" +popout: "ป๊อปเอาต์" +volume: "ความดัง" +masterVolume: "มาสเตอร์วอลุ่ม" +details: "รายละเอียด" +chooseEmoji: "เลือกโมจิของเธอ" +unableToProcess: "ไม่สามารถดำเนินการให้เสร็จสิ้นได้" +recentUsed: "ใช้ล่าสุด" +install: "ติดตั้ง" +uninstall: "ถอนการติดตั้ง" +installedApps: "แอปที่ติดตั้งแล้ว" +nothing: "ไม่พบผลลัพธ์" +installedDate: "วันที่ติดตั้ง" +lastUsedDate: "ใช้งานครั้งล่าสุด" +state: "สถานะ" +sort: "เรียงลำดับ" +ascendingOrder: "เรียงจากน้อยไปมาก" +descendingOrder: "เรียงจากมากไปน้อย" +scratchpad: "กระดานทดลอง" +scratchpadDescription: "Scratchpad เป็นการจัดเตรียมสภาพแวดล้อมสำหรับการทดลอง AiScript แต่คุณสามารถเขียน ดำเนินการ และตรวจสอบผลลัพธ์ของการโต้ตอบกับ Iceshrimp มันได้ด้วยนะ" +output: "เอาท์พุต" +script: "สคริปต์" +disablePagesScript: "ปิดการใช้งาน AiScript บนเพจ" +updateRemoteUser: "อัปเดตข้อมูลผู้ใช้งานระยะไกล" +deleteAllFiles: "ลบไฟล์ทั้งหมด" +deleteAllFilesConfirm: "นายแน่ใจแล้วหรอว่าต้องการที่จะลบไฟล์ทั้งหมด?" +removeAllFollowing: "เลิกติดตามผู้ใช้ที่ติดตามทั้งหมด" +removeAllFollowingDescription: "การที่คุณดำเนินการนี้จะเลิกติดตามบัญชีทั้งหมดจาก {host} โปรดเรียกใช้คำสั่งสิ่งนี้หากต้องการยกเลิกอินสแตนซ์ เช่น ไม่มีอยู่แล้ว" +userSuspended: "ผู้ใช้รายนี้ถูกระงับการใช้งาน" +userSilenced: "ผู้ใช้รายนี้กำลังถูกปิดกั้น" +yourAccountSuspendedTitle: "บัญชีนี้นั้นถูกระงับ" +yourAccountSuspendedDescription: "บัญชีนี้ถูกระงับ เนื่องจากละเมิดข้อกำหนดในการให้บริการของเซิร์ฟเวอร์หรืออาจจะละเมิดหลักเกณฑ์ชุมชน หรือ อาจจะโดนร้องเรียนเรื่องการละเมิดลิขสิทธิ์และอื่นๆอย่างต่อเนื่องซ้ำๆ หากคุณคิดว่าไม่ได้ทำผิดจริงๆหรือตัดสินผิดพลาด ได้โปรดกรุณาติดต่อผู้ดูแลระบบหากคุณต้องการทราบเหตุผลโดยละเอียดเพิ่มเติม และขอความกรุณาอย่าสร้างบัญชีใหม่" +menu: "เมนู" +divider: "ตัวแบ่ง" +addItem: "เพิ่มรายการ" +relays: "รีเลย์" +addRelay: "เพิ่มรีเลย์" +inboxUrl: "อินบ็อกซ์ URL" +addedRelays: "เพิ่มรีเลย์แล้ว" +serviceworkerInfo: "ต้องเปิดใช้งานสำหรับการแจ้งเตือนแบบพุช" +deletedNote: "โน้ตที่ถูกลบ" +invisibleNote: "โน้ตที่มองไม่เห็น" +enableInfiniteScroll: "โหลดเพิ่มเติมโดยอัตโนมัติ" +visibility: "การมองเห็น" +poll: "โพล" +useCw: "ซ่อนเนื้อหา" +enablePlayer: "เปิดเครื่องเล่นวิดีโอ" +disablePlayer: "ปิดเครื่องเล่นวิดีโอ" +expandTweet: "ขยายทวีต" +themeEditor: "ตัวแก้ไขธีม" +description: "รายละเอียด" +describeFile: "เพิ่มแคปชั่น" +enterFileDescription: "ใส่แคปชั่น" +author: "ผู้เขียน" +leaveConfirm: "คุณมีการเปลี่ยนแปลงที่ไม่ได้บันทึกนะ นายต้องการทิ้งการเปลี่ยนแปลงเหล่านั้นหรอ?" +manage: "การจัดการ" +plugins: "ปลั๊กอิน" +preferencesBackups: "ตั้งค่าการสำรองข้อมูล" +deck: "เด็ค" +undeck: "ออกจากเด็ค" +useBlurEffectForModal: "ใช้เอฟเฟกต์เบลอสำหรับโมดอล" +useFullReactionPicker: "ใช้เครื่องมือเลือกปฏิกิริยาขนาดเต็ม" +width: "ความกว้าง" +height: "ความสูง" +large: "ใหญ่" +medium: "ปานกลาง" +small: "เล็ก" +generateAccessToken: "สร้างการเข้าถึงโทเค็น" +permission: "การอนุญาต" +enableAll: "เปิดใช้งานทั้งหมด" +disableAll: "ปิดการใช้งานทั้งหมด" +tokenRequested: "ให้สิทธิ์การเข้าถึงบัญชี" +pluginTokenRequestedDescription: "ปลั๊กอินนี้จะสามารถใช้การอนุญาตที่ตั้งค่าไว้ที่นี่นะ" +notificationType: "ประเภทการแจ้งเตือน" +edit: "แก้ไข" +emailServer: "อีเมล์เซิร์ฟเวอร์" +enableEmail: "เปิดใช้งานการกระจายอีเมล" +emailConfigInfo: "ใช้เพื่อยืนยันอีเมลของคุณระหว่างการสมัครหรือถ้าหากคุณลืมรหัสผ่าน" +email: "อีเมล์" +emailAddress: "ที่อยู่อีเมล์" +smtpConfig: "กำหนดค่าเซิร์ฟเวอร์ SMTP" +smtpHost: "โฮสต์" +smtpPort: "พอร์ต" +smtpUser: "ชื่อผู้ใช้" +smtpPass: "รหัสผ่าน" +emptyToDisableSmtpAuth: "ปล่อยชื่อผู้ใช้และรหัสผ่านว่างไว้เพื่อปิดใช้งานการยืนยัน SMTP" +smtpSecure: "ใช้โดยนัย SSL/TLS สำหรับการเชื่อมต่อ SMTP" +smtpSecureInfo: "ปิดสิ่งนี้เมื่อใช้ STARTTLS" +testEmail: "ทดสอบการส่งอีเมล" +wordMute: "ปิดเสียงคำ" +regexpError: "ข้อผิดพลาดของนิพจน์ทั่วไป" +regexpErrorDescription: "เกิดข้อผิดพลาดในนิพจน์ทั่วไปในบรรทัดที่ {line} ของการปิดเสียงคำ {tab} ของคุณ:" +instanceMute: "ปิดเสียง อินสแตนซ์" +userSaysSomething: "{name} พูดอะไรบางอย่าง" +makeActive: "เปิดใช้งาน" +display: "แสดงผล" +copy: "คัดลอก" +metrics: "เมตริก" +overview: "ภาพรวม" +logs: "บันทึกข้อมูลระบบ" +delayed: "ดีเลย์" +database: "ฐานข้อมูล" +channel: "แชนแนล" +create: "สร้าง" +notificationSetting: "ตั้งค่าการแจ้งเตือน" +notificationSettingDesc: "เลือกประเภทการแจ้งเตือนที่ต้องการจะแสดง" +useGlobalSetting: "ใช้การตั้งค่าส่วนกลาง" +useGlobalSettingDesc: "หากเปิดไว้ ระบบจะใช้การตั้งค่าการแจ้งเตือนของบัญชีของคุณ หากปิดอยู่ สามารถทำการกำหนดค่าแต่ละรายการได้นะ" +other: "อื่น ๆ" +regenerateLoginToken: "สร้างโทเค็นการเข้าสู่ระบบอีกครั้ง" +regenerateLoginTokenDescription: "สร้างโทเค็นใหม่ที่ใช้ภายในระหว่างการเข้าสู่ระบบ โดยตามหลักปกติแล้วการดำเนินการนี้ไม่จำเป็น หากสร้างใหม่ อุปกรณ์ทั้งหมดจะถูกออกจากระบบนะ" +setMultipleBySeparatingWithSpace: "คั่นหลายรายการด้วยช่องว่าง" +fileIdOrUrl: "ไฟล์ ID หรือ URL" +behavior: "พฤติกรรม" +sample: "ตัวอย่าง" +abuseReports: "รายงาน" +reportAbuse: "รายงาน" +reportAbuseOf: "รายงาน {ชื่อ}" +fillAbuseReportDescription: "กรุณากรอกรายละเอียดเกี่ยวกับรายงานนี้ หากเป็นเรื่องเกี่ยวกับโน้ตโดยเฉพาะ ได้โปรดระบุ URL" +abuseReported: "เราได้ส่งรายงานของคุณไปแล้ว ขอบคุณมากๆนะ" +reporter: "นักข่าว" +reporteeOrigin: "รายงานต้นทาง" +reporterOrigin: "นักข่าวต้นทาง" +forwardReport: "ส่งต่อรายงานไปยังอินสแตนซ์ระยะไกล" +forwardReportIsAnonymous: "แทนที่จะเป็นบัญชีของคุณ บัญชีระบบที่ไม่ระบุตัวตนจะแสดงเป็นนักข่าวที่อินสแตนซ์ระยะไกล" +send: "ส่ง" +abuseMarkAsResolved: "ทำเครื่องหมายรายงานว่าแก้ไขแล้ว" +openInNewTab: "เปิดในแท็บใหม่" +openInSideView: "เปิดในมุมมองด้านข้าง" +defaultNavigationBehaviour: "พฤติกรรมการนำทางที่เป็นค่าเริ่มต้น" +editTheseSettingsMayBreakAccount: "การแก้ไขการตั้งค่าเหล่านี้อาจทำให้บัญชีของคุณเสียหายนะ" +instanceTicker: "ข้อมูลอินสแตนซ์ของบันทึกย่อ" +waitingFor: "กำลังรอคอย {x}" +random: "สุ่มค่า" +system: "ระบบ" +switchUi: "สลับ UI" +desktop: "เดสก์ท็อป" +clip: "คลิป" +createNew: "สร้างใหม่" +optional: "ไม่บังคับ" +createNewClip: "สร้างคลิปใหม่" +unclip: "ลบคลิป" +confirmToUnclipAlreadyClippedNote: "โน้ตนี้เป็นส่วนหนึ่งของคลิป \"{name}\" แล้ว คุณต้องการลบออกจากคลิปนี้แทนอย่างงั้นหรอ?" +public: "สาธารณะ" +i18nInfo: "Iceshrimp กำลังได้รับการแปลเป็นภาษาต่างๆ โดยอาสาสมัคร คุณสามารถช่วยเหลือได้ที่ {link}" +manageAccessTokens: "การจัดการโทเค็นการเข้าถึง" +accountInfo: "ข้อมูลบัญชี" +notesCount: "จำนวนของโน้ต" +repliesCount: "จำนวนการตอบกลับที่ส่ง" +renotesCount: "จำนวนรีโน้ตที่ส่ง" +repliedCount: "จำนวนของการตอบกลับที่ได้รับ" +renotedCount: "จำนวนรีโน้ตที่ได้รับ" +followingCount: "จำนวนบัญชีที่ติดตาม" +followersCount: "จำนวนผู้ติดตาม" +sentReactionsCount: "จำนวนปฏิกิริยาที่ส่ง" +receivedReactionsCount: "จำนวนปฏิกิริยาที่ได้รับ" +pollVotesCount: "จำนวนโหวตที่ส่งไป" +pollVotedCount: "จำนวนโหวตที่ได้รับ" +yes: "ใช่" +no: "ไม่" +driveFilesCount: "จำนวนไฟล์ไดรฟ์" +driveUsage: "การใช้พื้นที่ไดรฟ์" +noCrawle: "ปฏิเสธการจัดทำดัชนีของโปรแกรมรวบรวมข้อมูล" +noCrawleDescription: "ขอให้เครื่องมือค้นหาไม่จัดทำดัชนีหน้าโปรไฟล์ บันทึกย่อ หน้า ฯลฯ" +lockedAccountInfo: "เว้นแต่ว่าคุณจะต้องตั้งค่าการเปิดเผยโน้ตเป็น \"ผู้ติดตามเท่านั้น\" โน้ตย่อของคุณจะปรากฏแก่ทุกคน ถึงแม้ว่าคุณจะเป็นกำหนดให้ผู้ติดตามต้องได้รับการอนุมัติด้วยตนเองก็ตาม" +alwaysMarkSensitive: "ทำเครื่องหมายเป็น NSFW เป็นค่าเริ่มต้น" +loadRawImages: "โหลดภาพต้นฉบับแทนการแสดงภาพขนาดย่อ" +disableShowingAnimatedImages: "ไม่ต้องเล่นภาพเคลื่อนไหว" +verificationEmailSent: "ส่งอีเมลยืนยันแล้วนะ ได้โปรดกรุณาไปที่ลิงก์ที่รวมไว้เพื่อทำการตรวจสอบให้เสร็จสิ้น" +notSet: "ไม่ได้ตั้งค่า" +emailVerified: "อีเมลได้รับการยืนยันแล้ว" +noteFavoritesCount: "จำนวนโน้ตที่ชื่นชอบ" +pageLikesCount: "จำนวนเพจที่ชอบ" +pageLikedCount: "จำนวนการกดถูกใจเพจที่ได้รับแล้ว" +contact: "ติดต่อ" +useSystemFont: "ใช้ฟอนต์เริ่มต้นของระบบ" +clips: "คลิป" +experimentalFeatures: "ฟังก์ชั่นทดสอบ" +developer: "สำหรับนักพัฒนา" +makeExplorable: "ทำให้บัญชีมองเห็นใน \"สำรวจ\"" +makeExplorableDescription: "ถ้าหากคุณปิดการทำงานนี้ บัญชีของคุณนั้นจะไม่แสดงในส่วน \"สำรวจ\" นะ" +showGapBetweenNotesInTimeline: "แสดงช่องว่างระหว่างโพสต์บนไทม์ไลน์" +duplicate: "ทำซ้ำ" +left: "ซ้าย" +center: "ศูนย์กลาง" +wide: "กว้าง" +narrow: "ชิด" +reloadToApplySetting: "การตั้งค่านี้จะมีผลหลังจากโหลดหน้าซ้ำเท่านั้น ต้องการที่จะโหลดใหม่เลยมั้ย" +needReloadToApply: "จำเป็นต้องโหลดซ้ำถึงจะมีผลนะ" +showTitlebar: "แสดงแถบชื่อ" +clearCache: "ล้างแคช" +onlineUsersCount: "{n} ผู้ใช้คนนี้กำลังออนไลน์" +nUsers: "{n} ผู้ใช้งาน" +nNotes: "{n} โน้ต" +sendErrorReports: "ส่งรายงานว่าข้อผิดพลาด" +sendErrorReportsDescription: "เมื่อเปิดใช้งาน ข้อมูลข้อผิดพลาดโดยรายละเอียดนั้นจะถูกแชร์ให้กับ Iceshrimp เมื่อเกิดปัญหา ซึ่งช่วยปรับปรุงคุณภาพของ Iceshrimp\nซึ่งจะรวมถึงข้อมูล เช่น เวอร์ชั่นของระบบปฏิบัติการ เบราว์เซอร์ที่คุณใช้ กิจกรรมของคุณใน Iceshrimp เป็นต้น" +myTheme: "ธีมของฉัน" +backgroundColor: "ภาพพื้นหลัง" +accentColor: "รูปแบบสี" +textColor: "สีข้อความ" +saveAs: "บันทึกเป็น..." +advanced: "ขั้นสูง" +value: "ค่า" +createdAt: "สร้างเมื่อ" +updatedAt: "อัพเดทล่าสุด" +saveConfirm: "บันทึกเปลี่ยนแปลงมั้ย?" +deleteConfirm: "ลบจริงๆเหรอ?" +invalidValue: "ค่านี้ไม่ถูกต้อง" +registry: "ทะเบียน" +closeAccount: "ปิด บัญชี" +currentVersion: "เวอร์ชั่นปัจจุบัน" +latestVersion: "รุ่นปัจจุบัน" +youAreRunningUpToDateClient: "คุณกำลังใช้ไคลเอ็นต์เวอร์ชันใหม่ล่าสุดนะ" +newVersionOfClientAvailable: "มีไคลเอ็นต์เวอร์ชันใหม่กว่าของคุณพร้อมใช้งานนะ" +usageAmount: "การใช้งาน" +capacity: "ความจุ" +inUse: "ใช้แล้ว" +editCode: "แก้ไขโค้ด" +apply: "ตกลง" +receiveAnnouncementFromInstance: "รับการแจ้งเตือนจากอินสแตนซ์นี้" +emailNotification: "การแจ้งเตือนทางอีเมล์" +publish: "เผยแพร่" +inChannelSearch: "ค้นหาในช่อง" +useReactionPickerForContextMenu: "เปิดตัวเลือกปฏิกิริยาเมื่อคลิกขวา" +typingUsers: "{users} กำลัง" +jumpToSpecifiedDate: "ข้ามไปยังวันที่เฉพาะเจาะจง" +showingPastTimeline: "กำลังแสดงผลไทม์ไลน์เก่า" +clear: "ล้าง" +markAllAsRead: "ทำเครื่องหมายทั้งหมดว่าอ่านแล้ว" +goBack: "ย้อนกลับ" +unlikeConfirm: "ลบไลค์ของคุณออกจริงๆหรอ" +fullView: "มุมมองแบบเต็ม" +quitFullView: "ออกจากมุมมองแบบเต็ม" +addDescription: "เพิ่มคำอธิบาย" +userPagePinTip: "คุณสามารถแสดงผลโน้ตย่อได้ที่นี่โดยเลือก \"ปักหมุดที่โปรไฟล์\" จากเมนูของโน้ตย่อแต่ละรายการนะ" +notSpecifiedMentionWarning: "โน้ตนี้มีการกล่าวถึงผู้ใช้งานที่ไม่รวมอยู่ในผู้รับ" +info: "เกี่ยวกับ" +userInfo: "ข้อมูลผู้ใช้" +unknown: "ไม่ทราบสถานะ" +onlineStatus: "สถานะออนไลน์" +hideOnlineStatus: "ซ่อนสถานะออนไลน์" +hideOnlineStatusDescription: "การซ่อนสถานะออนไลน์ของคุณช่วยลดความสะดวกของคุณสมบัติบางอย่าง เช่น การค้นหา อ่ะนะ" +online: "ออนไลน์" +active: "ใช้งานอยู่" +offline: "ออฟไลน์" +notRecommended: "ไม่ใช้งาน" +botProtection: "การป้องกัน Bot (or AI)" +instanceBlocking: "อินสแตนซ์ที่ถูกบล็อก" +selectAccount: "เลือกบัญชี" +switchAccount: "สลับบัญชีผู้ใช้" +enabled: "เปิดใช้งาน" +disabled: "ปิดการใช้งาน" +quickAction: "ปุ่มลัด" +user: "ผู้ใช้งาน" +administration: "การจัดการ" +accounts: "บัญชีผู้ใช้" +switch: "สลับ" +noMaintainerInformationWarning: "ข้อมูลผู้ดูแลไม่ได้รับการกำหนดค่านะ" +noBotProtectionWarning: "ไม่ได้กำหนดค่าการป้องกันบอทนะ" +configure: "กำหนดค่า" +postToGallery: "สร้างโพสต์แกลเลอรี่ใหม่" +gallery: "แกลเลอรี่" +recentPosts: "โพสต์ล่าสุด" +popularPosts: "โพสต์ติดอันดับ" +shareWithNote: "แบ่งปันด้วยโน้ต" +ads: "โฆษณา" +expiration: "กำหนดเวลา" +memo: "ข้อควรจำ" +priority: "ลำดับความสำคัญ" +high: "สูง" +middle: "ปานกลาง" +low: "ต่ำ" +emailNotConfiguredWarning: "ไม่ได้ตั้งค่าที่อยู่อีเมลนะ" +ratio: "อัตราส่วน" +previewNoteText: "แสดงตัวอย่าง" +customCss: "CSS ที่กำหนดเอง" +customCssWarn: "ควรใช้การตั้งค่านี้เฉพาะต่อเมื่อคุณรู้ว่าการตั้งค่านี้ใช้ทำอะไร การป้อนค่าที่ไม่เหมาะสมอาจทำให้ไคลเอ็นต์หยุดทำงานตามปกติได้นะ" +global: "ทั่วโลก" +squareAvatars: "แสดงผลอวตารสี่เหลี่ยม" +sent: "ส่ง" +received: "ได้รับแล้ว" +searchResult: "ผลการค้นหา" +hashtags: "แฮชแท็ก" +troubleshooting: "แก้ปัญหา" +useBlurEffect: "ใช้เอฟเฟกต์เบลอใน UI" +learnMore: "แสดงให้ดูหน่อย" +iceshrimpUpdated: "Iceshrimp ได้รับการอัปเดตแล้ว!" +whatIsNew: "แสดงการเปลี่ยนแปลง" +translate: "แปลภาษา" +translatedFrom: "แปลมาจาก {x}" +accountDeletionInProgress: "กำลังดำเนินการลบบัญชีอยู่" +usernameInfo: "ชื่อที่ระบุบัญชีของคุณจากผู้อื่นในเซิร์ฟเวอร์นี้ คุณสามารถใช้ตัวอักษร (a~z, A~Z), ตัวเลข (0~9) หรือขีดล่าง (_) ชื่อผู้ใช้ไม่สามารถเปลี่ยนแปลงได้ในภายหลัง" +aiChanMode: "โหมด Ai " +keepCw: "เก็บคำเตือนเนื้อหา" +pubSub: "บัญชีผับ/ย่อย" +lastCommunication: "การสื่อสารครั้งสุดท้ายล่าสุด" +resolved: "คลี่คลายแล้ว" +unresolved: "รอการเฉลย" +breakFollow: "ลบผู้ติดตาม" +itsOn: "เปิดใช้งาน" +itsOff: "ปิดใช้งาน" +emailRequiredForSignup: "จำเป็นต้องการใช้ที่อยู่อีเมลสำหรับการสมัคร" +unread: "ไม่ได้อ่าน" +filter: "กรอง" +controlPanel: "แผงควบคุม" +manageAccounts: "จัดการบัญชี" +makeReactionsPublic: "ตั้งค่าประวัติปฏิกิริยาต่อสาธารณะ" +makeReactionsPublicDescription: "การทำเช่นนี้จะทำให้รายการปฏิกิริยาที่ผ่านมาของคุณจะปรากฏต่อสาธารณะนะ" +classic: "คลาสสิค" +muteThread: "ปิดเสียงเธรด" +unmuteThread: "เปิดเสียงเธรด" +ffVisibility: "การมองเห็นผู้ติดตาม/ผู้ติดตาม" +ffVisibilityDescription: "ช่วยให้คุณสามารถกำหนดค่าได้ว่าใครสามารถดูได้ว่าคุณติดตามใครและใครติดตามคุณบ้าง" +continueThread: "ดูความต่อเนื่องเธรด" +deleteAccountConfirm: "การดำเนินการนี้จะลบบัญชีของคุณอย่างถาวรเลยนะ แน่ใจหรอดำเนินการ?" +incorrectPassword: "รหัสผ่านไม่ถูกต้อง" +voteConfirm: "ยืนยันการโหวต \"{choice}\" มั้ย?" +hide: "ซ่อน" +leaveGroup: "ออกจากกลุ่ม" +leaveGroupConfirm: "คุณแน่ใจหรอว่าต้องการออกจาก \"{name}\"" +useDrawerReactionPickerForMobile: "แสดงผล ตัวเลือกปฏิกิริยาเป็นลิ้นชักบนมือถือ" +clickToFinishEmailVerification: "กรุณาคลิก [{ok}] เพื่อดำเนินการยืนยันอีเมลให้เสร็จสมบูรณ์นะ" +overridedDeviceKind: "ประเภทอุปกรณ์" +smartphone: "สมาร์ทโฟน" +tablet: "แท็บเล็ต" +auto: "อัตโนมัติ" +themeColor: "อินสแตนซ์ Ticker Color" +size: "ขนาด" +numberOfColumn: "จำนวนคอลัมน์" +searchByGoogle: "ค้นหา" +instanceDefaultLightTheme: "ธีมสว่างค่าเริ่มต้นสำหรับอินสแตนซ์" +instanceDefaultDarkTheme: "ธีมมืดค่าเริ่มต้นอินสแตนซ์" +instanceDefaultThemeDescription: "ป้อนรหัสธีมในรูปแบบออบเจ็กต์" +mutePeriod: "ระยะเวลาปิดเสียง" +indefinitely: "ตลอดไป" +tenMinutes: "10 นาที" +oneHour: "1 ชั่วโมง" +oneDay: "1 วัน" +oneWeek: "1 สัปดาห์" +reflectMayTakeTime: "อาจจำเป็นต้องใช้เวลาสักระยะหนึ่งจึงจะเห็นแสดงผลได้นะ" +failedToFetchAccountInformation: "ไม่สามารถเรียกดึงข้อมูลบัญชีได้" +rateLimitExceeded: "เกินขีดจำกัดอัตรา" +cropImage: "ครอบตัดรูปภาพ" +cropImageAsk: "คุณต้องการครอบตัดรูปภาพนี้อย่างงั้นหรือ?" +file: "ไฟล์" +recentNHours: "ล่าสุด {n} ชั่วโมงที่แล้ว" +recentNDays: "ล่าสุด {n} วันที่แล้ว" +noEmailServerWarning: "ไม่ได้กำหนดค่าเซิร์ฟเวอร์อีเมลนี้" +thereIsUnresolvedAbuseReportWarning: "มีรายงานที่ยังไม่ได้แก้ไข" +recommended: "แนะนำ" +check: "ตรวจสอบ" +driveCapOverrideLabel: "เปลี่ยนความจุของไดรฟ์สำหรับผู้ใช้รายนี้" +driveCapOverrideCaption: "รีเซ็ตความจุเป็นค่าเริ่มต้นโดยการป้อนค่าเป็น 0 หรือ ต่ำกว่า" +requireAdminForView: "คุณจำเป็นต้องเข้าสู่ระบบด้วยบัญชีผู้ดูแลระบบเพื่อเข้าดูสิ่งนี้" +isSystemAccount: "บัญชีที่ถูกสร้างมานั้น และถูกดำเนินการโดยอัตโนมัติด้วยระบบ" +typeToConfirm: "โปรดป้อน {x} เพื่อยืนยัน" +deleteAccount: "ลบบัญชี" +document: "เอกสาร" +numberOfPageCache: "จำนวนหน้าเพจที่แคช" +numberOfPageCacheDescription: "การเพิ่มจำนวนนี้จะช่วยเพิ่มความสะดวกให้กับผู้ใช้งาน แต่จะทำให้เซิร์ฟเวอร์โหลดมากขึ้นและต้องใช้หน่วยความจำมากขึ้นอีกด้วย" +logoutConfirm: "คุณแน่ใจว่าต้องการออกจากระบบ?" +lastActiveDate: "ใช้งานล่าสุดที่" +statusbar: "ไอคอนบนแถบสถานะ" +pleaseSelect: "ตัวเลือก" +reverse: "ย้อนกลับ" +colored: "สี" +refreshInterval: "รอบการอัพเดต" +label: "ป้ายชื่อ" +type: "รูปแบบ" +speed: "ความเร็ว" +slow: "ช้า" +fast: "เร็ว" +sensitiveMediaDetection: "การตรวจจับของสื่อ NSFW" +localOnly: "เฉพาะท้องถิ่น" +remoteOnly: "รีโมทเท่านั้น" +failedToUpload: "การอัปโหลดล้มเหลว" +cannotUploadBecauseInappropriate: "ไม่สามารถอัปโหลดไฟล์นี้ได้เนื่องจากระบบตรวจพบบางส่วนของไฟล์ว่านี้อาจจะเป็น NSFW" +cannotUploadBecauseNoFreeSpace: "การอัปโหลดนั้นล้มเหลวเนื่องจากไม่มีความจุของไดรฟ์" +beta: "เบต้า" +enableAutoSensitive: "ทำเครื่องหมาย NSFW อัตโนมัติ" +enableAutoSensitiveDescription: "อนุญาตให้ตรวจหาและทำเครื่องหมายสื่อ NSFW โดยอัตโนมัติผ่านการเรียนรู้ของเครื่องหากเป็นไปได้ แม้ว่าตัวเลือกนี้จะถูกปิดใช้งาน แต่ก็สามารถเปิดใช้งานได้ทั้งอินสแตนซ์นี้" +activeEmailValidationDescription: "เปิดใช้งานการตรวจสอบที่อยู่อีเมลให้มีความเข้มงวดยิ่งขึ้น ซึ่งอาจจะรวมไปถึงการตรวจสอบที่อยู่อีเมล์ที่ใช้แล้วทิ้งและโดยให้พิจารณาว่าสามารถสื่อสารด้วยได้หรือไม่ เมื่อไม่เลือกระบบจะตรวจสอบเฉพาะรูปแบบของอีเมลเท่านั้น" +navbar: "แถบนำทาง" +shuffle: "สลับ" +account: "บัญชีผู้ใช้" +move: "ย้าย" +_sensitiveMediaDetection: + description: "ลดความพยายามในการดูแลเซิร์ฟเวอร์ผ่านการจดจำสื่อ NSFW โดยอัตโนมัติผ่านการเรียนรู้ของเครื่อง การทำสิ่งนี้อาจจะเพิ่มภาระบนเซิร์ฟเวอร์เล็กน้อย" + sensitivity: "การตรวจจับความไว" + sensitivityDescription: "การลดความไวนั้นจะนำไปสู่การตรวจจับที่ผิดพลาดน้อยลง (ผลบวกที่ผิดพลาด) แต่ในขณะที่การเพิ่มนั้นจะนำไปสู่การตรวจหาที่พลาดน้อยลง (ผลลบเท็จ)" + setSensitiveFlagAutomatically: "ทำเครื่องหมายว่าเป็น NSFW" + setSensitiveFlagAutomaticallyDescription: "ผลลัพธ์ของการตรวจจับภายในนั้นจะยังคงอยู่ ถึงแม้ว่าจะปิดตัวเลือกนี้" + analyzeVideos: "เปิดใช้งานวิเคราะห์ของวิดีโอ" + analyzeVideosDescription: "การวิเคราะห์วิดีโอนอกเหนือจากรูปภาพนั้น การทำสิ่งนี้จะทำให้เพิ่มภาระบนเซิร์ฟเวอร์เล็กน้อย" +_emailUnavailable: + used: "ที่อยู่อีเมลนี้ได้ถูกใช้ไปแล้ว" + format: "รูปแบบของที่อยู่อีเมลนี้ไม่ถูกต้อง" + disposable: "ที่อยู่อีเมลที่ใช้แล้วทิ้งนั้นไม่สามารถใช้ได้" + mx: "เซิร์ฟเวอร์อีเมลนี้ไม่ถูกต้อง" + smtp: "เซิร์ฟเวอร์อีเมลนี้ไม่มีการตอบสนอง" +_ffVisibility: + public: "เผยแพร่" + followers: "ปรากฏให้แก่ผู้ติดตามเท่านั้น" + private: "ส่วนตัว" +_signup: + almostThere: "เกือบจะมี" + emailAddressInfo: "โปรดกรอกอีเมลของคุณ มันจะไม่เปิดเผยต่อสาธารณะ" + emailSent: "เราได้ส่งอีเมลยืนยันไปยังที่อยู่อีเมลของคุณแล้วนะ ({email}) โปรดคลิกลิงก์ที่รวมไว้เพื่อสร้างบัญชีให้เสร็จสิ้น" +_accountDelete: + accountDelete: "ลบบัญชีผู้ใช้" + mayTakeTime: "เนื่องจากการลบบัญชีนี้จะเป็นกระบวนการที่ต้องใช้ทรัพยากรมาก จึงอาจจะต้องใช้เวลาสักครู่ถึงจะเสร็จสมบูรณ์ ทั้งนี้ขึ้นอยู่กับจำนวนเนื้อหาที่คุณสร้างและจำนวนไฟล์ที่คุณอัปโหลดนะ" + sendEmail: "เมื่อการลบบัญชีนี้เสร็จสิ้น เราอาจจะส่งอีเมลไปยังที่อยู่อีเมลของคุณที่เคยลงทะเบียนไว้กับบัญชีนี้นะ" + requestAccountDelete: "ร้องขอให้ลบบัญชี" + started: "การลบได้เริ่มต้นขึ้น" + inProgress: "ปัจจุบันกำลังดำเนินการลบอยู่" +_ad: + back: "ย้อนกลับ" + reduceFrequencyOfThisAd: "แสดงโฆษณานี้ให้น้อยลง" +_forgotPassword: + enterEmail: "ป้อนที่อยู่อีเมลที่คุณเคยใช้ในการลงทะเบียนไว้ ลิงก์ที่คุณสามารถรีเซ็ตรหัสผ่านได้นั้นจะถูกส่งไปนะ" + ifNoEmail: "ถ้าหากคุณไม่ได้ใช้อีเมลระหว่างการลงทะเบียน กรุณาติดต่อผู้ดูแลระบบอินสแตนซ์แทนนะ" + contactAdmin: "อินสแตนซ์นี้ไม่รองรับการใช้งานที่อยู่อีเมลนี้ กรุณาติดต่อผู้ดูแลระบบอินสแตนซ์เพื่อรีเซ็ตรหัสผ่านของคุณแทน" +_gallery: + my: "แกลลอรี่ของฉัน" + liked: "โพสต์ที่ถูกใจ" + like: "ชื่นชอบ" + unlike: "ลบไลค์" +_email: + _follow: + title: "ได้ติดตามคุณ" + _receiveFollowRequest: + title: "คุณได้รับคำขอติดตาม" +_plugin: + install: "ติดตั้งปลั๊กอิน" + installWarn: "กรุณาอย่าติดตั้งปลั๊กอินที่ไม่น่าเชื่อถือนะคะ" + manage: "จัดการปลั๊กอิน" +_preferencesBackups: + list: "สร้างการสำรองข้อมูล" + saveNew: "บันทึกใหม่" + loadFile: "โหลดจากไฟล์" + apply: "นำไปใช้กับอุปกรณ์นี้" + save: "บันทึก" + inputName: "กรุณาป้อนชื่อสำหรับข้อมูลสำรองนี้" + cannotSave: "การบันทึกล้มเหลว" + nameAlreadyExists: "มีข้อมูลสำรองชื่อ \"{name}\" นี้อยู่แล้ว กรุณาป้อนชื่ออื่นนะ" + applyConfirm: "คุณต้องการใช้ข้อมูลสำรอง \"{name}\" กับอุปกรณ์นี้อย่างงั้นจริงหรอ การตั้งค่าที่มีอยู่ของอุปกรณ์นี้จะถูกเขียนทับนะ" + saveConfirm: "บันทึกข้อมูลสำรองเป็น {name} มั้ย?" + deleteConfirm: "ลบข้อมูลสำรอง {name} มั้ย?" + renameConfirm: "เปลี่ยนชื่อข้อมูลสำรองนี้จาก \"{old}\" เป็น \"{new}\" หรือป่าว" + noBackups: "ไม่มีข้อมูลสำรองนะ คุณสามารถสำรองข้อมูลการตั้งค่าไคลเอนต์ของคุณบนเซิร์ฟเวอร์นี้โดยใช้ \"สร้างการสำรองข้อมูลใหม่\"ได้นะ" + createdAt: "สร้างเมื่อ: {date} {time}" + updatedAt: "อัปเดตเมื่อ: {date} {time}" + cannotLoad: "การโหลดล้มเหลว" + invalidFile: "รูปแบบไฟล์ไม่ถูกต้องนะ" +_registry: + scope: "สโคป" + key: "คีย์" + keys: "คีย์" + domain: "โดเมน" + createKey: "สร้างคีย์" +_aboutIceshrimp: + about: "Iceshrimp เป็นซอฟต์แวร์โอเพ่นซอร์สที่ถูกพัฒนาโดย Syuilo ตั้งแต่ปี 2014" + contributors: "ผู้สนับสนุนหลัก" + allContributors: "ผู้มีส่วนร่วมทั้งหมด" + source: "ซอร์สโค้ด" + translation: "รับแปลภาษา Iceshrimp" + donate: "บริจาคให้กับ Iceshrimp" + morePatrons: "เราขอขอบคุณสำหรับความช่วยเหลือจากผู้ช่วยอื่นๆ ที่ไม่ได้ระบุไว้ที่นี่นะ ขอขอบคุณ! 🥰" + patrons: "สมาชิกพันธมิตร" +_nsfw: + respect: "ซ่อนสื่อ NSFW" + ignore: "อย่าซ่อนสื่อ NSFW" + force: "ซ่อนสื่อทั้งหมด" +_mfm: + cheatSheet: "โค้ด MFM Cheat Sheet" + intro: "MFM เป็นภาษามาร์กอัปพิเศษเฉพาะของ Iceshrimp ที่สามารถใช้ได้ในหลายที่ คุณยังสามารถดูรายการไวยากรณ์ MFM ที่มีอยู่ทั้งหมดได้ที่นี่นะ" + dummy: "Iceshrimp ขยายโลกของ Fediverse" + mention: "กล่าวถึง" + mentionDescription: "คุณสามารถระบุผู้ใช้โดยใช้ At-Symbol และชื่อผู้ใช้ได้นะ" + hashtag: "แฮชแท็ก" + hashtagDescription: "คุณสามารถระบุชื่อแฮชแท็กได้โดยใช้เครื่องหมายตัวเลขและข้อความได้นะ" + url: "URL" + urlDescription: "สามารถแสดง URL ได้นะ" + link: "ลิงก์" + linkDescription: "เจาะจงเฉพาะ ส่วนของข้อความที่สามารถแสดงเป็น URL ได้" + bold: "ตัวหนา" + boldDescription: "ไฮไลท์ตัวอักษรโดยทำให้หนาขึ้น" + small: "ขนาดเล็ก" + smallDescription: "แสดงผลเนื้อหาขนาดเล็กและบาง" + center: "เซ็นเตอร์" + centerDescription: "แสดงผลเนื้อหาเป็นศูนย์กลาง" + inlineCode: "โค้ด (อินไลน์)" + inlineCodeDescription: "แสดงผลการเน้นไวยากรณ์แบบอินไลน์สำหรับโค้ด (โปรแกรม)" + blockCode: "โค้ด (บล็อก)" + blockCodeDescription: "แสดงผลการเน้นไวยากรณ์สำหรับโค้ดหลายบรรทัด (โปรแกรม) ในบล็อก" + inlineMath: "คณิต (อินไลน์)" + inlineMathDescription: "แสดงผลสูตรคณิต (KaTeX) ในบรรทัด" + blockMath: "คณิต (บล็อก)" + blockMathDescription: "แสดงผลสูตรคณิตหลายบรรทัด (KaTeX) ในบล็อก" + quote: "อ้างคำพูด" + quoteDescription: "แสดงผลเนื้อหาเป็นใบเสนอราคา" + emoji: "กำหนดอีโมจิเอง" + emojiDescription: "โดยล้อมรอบชื่ออีโมจิที่กำหนดเองด้วยเครื่องหมายทวิภาค จะสามารถแสดงผลอีโมจิที่กำหนดเองได้" + search: "ค้นหา" + searchDescription: "แสดงผลกล่องค้นหาพร้อมกับข้อความที่ป้อนไว้ล่วงหน้า" + flip: "พลิก" + flipDescription: "พลิกเนื้อหาในแนวนอนหรือแนวตั้ง" + jelly: "แอนิเมชั่น (เยลลี่)" + jellyDescription: "ให้เนื้อหาเป็นแอนิเมชั่นเหมือนเยลลี่" + tada: "แอนิเมชั่น (ธาดา)" + tadaDescription: "ให้เนื้อหาเป็นแอนิเมชั่นเหมือน \"ทาด้า!\"" + jump: "อนิเมชั่น (กระโดด)" + jumpDescription: "ให้เนื้อหามีภาพเคลื่อนไหวแบบกระโดด" + bounce: "อนิเมชั่น (เด้ง)" + bounceDescription: "ให้เนื้อหามีอนิเมชั่นเด้ง" + shake: "อนิเมชั่น (เขย่า)" + shakeDescription: "ให้เนื้อหามีภาพเคลื่อนไหวสั่น" + twitch: "แอนิเมชั่น (Twitch)" + twitchDescription: "ให้เนื้อหามีแอนิเมชั่นกระตุกอย่างแรง" + spin: "แอนิเมชั่น (สปิน)" + spinDescription: "ให้เนื้อหาเป็นภาพเคลื่อนไหวแบบหมุน" + x2: "ขนาดใหญ่" + x2Description: "แสดงเนื้อหาที่ใหญ่ขึ้น" + x3: "ใหญ่มาก" + x3Description: "แสดงเนื้อหาอีเว้นท์ที่ใหญ่ขึ้น" + x4: "ใหญ่อย่างไม่น่าเชื่อ" + x4Description: "แสดงผลเนื้อหาที่ใหญ่กว่าใหญ่กว่าขนาดใหญ่" + blur: "เบลอ" + blurDescription: "เบลอเนื้อหา จะแสดงผลอย่างชัดเจนต่อเมื่อวางเมาส์เหนือ" + font: "ตัวอักษร" + fontDescription: "ตั้งค่าตัวอักษรเพื่อแสดงเนื้อหาใน" + rainbow: "สายรุ้ง" + rainbowDescription: "ทำให้เนื้อหานั้นปรากฏเป็นสีรุ้ง" + sparkle: "กลิตเตอร์" + sparkleDescription: "ให้เนื้อหานั้นมีเอฟเฟกต์แบบอนุภาคประกาย" + rotate: "หมุนหน้าจอ" + rotateDescription: "เปลี่ยนเนื้อหาตามด้วยมุมที่ระบุไว้" + plain: "เรียบง่าย" + plainDescription: "ปิดการใช้งานเอฟเฟกต์ของ MFM ทั้งหมดที่มีอยู่ในเอฟเฟกต์ MFM นี้" +_instanceTicker: + none: "ไม่ต้องแสดง" + remote: "แสดงสำหรับผู้ใช้ระยะไกล" + always: "แสดงเสมอ" +_serverDisconnectedBehavior: + reload: "โหลดใหม่โดยอัตโนมัติ" + dialog: "แสดงกล่องโต้ตอบคำเตือน" + quiet: "แสดงคำเตือนที่ไม่เป็นการรบกวน" +_channel: + create: "สร้างแชนแนลใหม่" + edit: "แก้ไขแชนแนล" + setBanner: "เซตแบนเนอร์" + removeBanner: "ลบแบนเนอร์" + featured: "เทรนด์" + owned: "เจ้าของ" + following: "ติดตามแล้ว" + usersCount: "{n} ผู้เข้าร่วม" + notesCount: "{n} โน้ต" +_menuDisplay: + sideFull: "ด้านข้าง" + sideIcon: "ด้านข้าง (ไอคอน)" + top: "ท็อป" + hide: "ซ่อน" +_wordMute: + muteWords: "ปิดเสียงคำ" + muteWordsDescription: "คั่นด้วยช่องว่างสำหรับเงื่อนไข AND หรือด้วยการขึ้นบรรทัดใหม่สำหรับเงื่อนไข OR นะ" + muteWordsDescription2: "ล้อมรอบคีย์เวิร์ดด้วยเครื่องหมายทับเพื่อใช้นิพจน์ทั่วไป" + softDescription: "ซ่อนโน้ตให้ตรงตามเงื่อนไขที่ตั้งไว้จากไทม์ไลน์" + hardDescription: "ป้องกันไม่ให้โน้ตย่อที่ตรงตามเงื่อนไขที่ตั้งไว้ไม่ให้ถูกเพิ่มลงในไทม์ไลน์ นอกจากนี้ โน้ตเหล่านี้จะไม่ถูกเพิ่มลงในไทม์ไลน์แม้ว่าจะมีการเปลี่ยนแปลงเงื่อนไขยังไงก็ตาม" + soft: "ซอฟ" + hard: "ยาก" + mutedNotes: "ปิดเสียงโน้ต" +_instanceMute: + instanceMuteDescription: "การดำเนินการนี้จะปิดเสียง\"โน้ต/รีโน้ต\"จากอินสแตนซ์ที่อยู่ในรายการ รวมถึงบันทึกของผู้ใช้ที่ตอบกลับผู้ใช้จากอินสแตนซ์ที่ปิดเสียง" + instanceMuteDescription2: "คั่นด้วยการขึ้นบรรทัดใหม่" + title: "ซ่อนโน้ตจากอินสแตนซ์ที่มีอยู่ในรายการ" + heading: "รายชื่ออินสแตนซ์ที่ถูกปิดเสียง" +_theme: + explore: "สำรวจธีม" + install: "ติดตั้งธีม" + manage: "จัดการธีม" + code: "โค้ดธีม" + description: "รายละเอียด" + installed: "{name} ได้รับการติดตั้ง" + installedThemes: "ธีมที่ติดตั้ง" + builtinThemes: "ธีมในตัว" + alreadyInstalled: "ธีมนี้ได้รับการติดตั้งแล้ว" + invalid: "รูปแบบของธีมนี้ไม่ถูกต้องนะ" + make: "ทำธีม" + base: "ฐาน" + addConstant: "เพิ่มค่าคงที่" + constant: "ตัวแปร" + defaultValue: "ค่าเริ่มต้น" + color: "สี" + refProp: "อ้างอิงคุณสมบัติ" + refConst: "อ้างอิงค่าคงที่" + key: "คีย์" + func: "ฟังก์ชัน" + funcKind: "ประเภทฟังก์ชัน" + argument: "อากิวเม้นต์" + basedProp: "ทรัพย์สินอ้างอิง" + alpha: "ความทึบแสง" + darken: "มืดลง" + lighten: "สว่าง" + inputConstantName: "ป้อนชื่อสำหรับค่าคงที่นี้" + importInfo: "ถ้าหากต้องการป้อนโค้ดที่นี่ คุณยังสามารถนำเข้าไปยังโปรแกรมแก้ไขธีมได้" + deleteConstantConfirm: "คุณต้องการลบค่าคงที่ {const} หรือป่าว?" + keys: + accent: "เน้น" + bg: "ภาพพื้นหลัง" + fg: "ข้อความ" + focus: "โฟกัส" + indicator: "ตัวบ่งชี้" + panel: "แผงควบคุม" + shadow: "เงา" + header: "ส่วนหัว" + navBg: "พื้นหลังแถบด้านข้าง" + navFg: "ข้อความแถบด้านข้าง" + mention: "กล่าวถึง" + renote: "รีโน้ต" + divider: "ตัวแบ่ง" +_sfx: + note: "หมายเหตุ" + notification: "การเเจ้งเตือน" + chat: "แชท" +_widgets: + notifications: "การเเจ้งเตือน" + timeline: "ไทม์ไลน์" + activity: "กิจกรรม" + federation: "สหพันธ์" + jobQueue: "คิวงาน" +_cw: + show: "โหลดเพิ่มเติม" +_visibility: + home: "หน้าแรก" + followers: "ผู้ติดตาม" +_profile: + name: "ชื่อ" + username: "ชื่อผู้ใช้" +_exportOrImport: + followingList: "กำลังติดตาม" + muteList: "ปิดเสียง" + blockingList: "บล็อค" + userLists: "รายการ" +_charts: + federation: "สหพันธ์" +_timelines: + home: "หน้าแรก" +_pages: + blocks: + image: "รูปภาพ" + script: + categories: + list: "รายการ" + blocks: + _join: + arg1: "รายการ" + _randomPick: + arg1: "รายการ" + _dailyRandomPick: + arg1: "รายการ" + _seedRandomPick: + arg2: "รายการ" + _pick: + arg1: "รายการ" + _listLen: + arg1: "รายการ" + types: + array: "รายการ" +_notification: + youWereFollowed: "ได้ติดตามคุณ" + _types: + follow: "กำลังติดตาม" + mention: "กล่าวถึง" + renote: "รีโน้ต" + quote: "อ้างคำพูด" + reaction: "รีแอคชั่น" + _actions: + reply: "ตอบกลับ" + renote: "รีโน้ต" +_deck: + _columns: + notifications: "การเเจ้งเตือน" + tl: "ไทม์ไลน์" + antenna: "เสาอากาศ" + list: "รายการ" + mentions: "พูดถึง" diff --git a/locales/tr-TR.yml b/locales/tr-TR.yml new file mode 100644 index 0000000..f05dd86 --- /dev/null +++ b/locales/tr-TR.yml @@ -0,0 +1,2118 @@ +_lang_: "Türkçe" +introIceshrimp: "Hoş geldin! Iceshrimp, sonsuza kadar ücretsiz olan, açık kaynaklı, merkezi + olmayan bir sosyal medya platformudur! 🚀" +monthAndDay: "{month}Ay {day}Gün" +search: "Arama" +notifications: "Bildirimler" +username: "Kullanıcı Adı" +password: "Şifre" +forgotPassword: "şifremi unuttum" +ok: "TAMAM" +gotIt: "Anladım!" +cancel: "İptal" +enterUsername: "Kullanıcı adınızı giriniz" +noNotes: "Gönderiler mevcut değil" +noNotifications: "Bildirim bulunmuyor" +settings: "Ayarlar" +basicSettings: "Temel Ayarlar" +otherSettings: "Diğer Ayarlar" +openInWindow: "Bir pencere ile aç" +profile: "Profil" +timeline: "Zaman çizelgesi" +noAccountDescription: "Bu kullanıcı henüz hakkındasını yazmadı." +login: "Giriş Yap" +logout: "Çıkış Yap" +signup: "Kayıt Ol" +uploading: "Yükleniyor..." +users: "Kullanıcı" +addUser: "Kullanıcı Ekle" +favorite: "Favoriler" +favorites: "Favoriler" +unfavorite: "Favorilerden Kaldır" +favorited: "Favorilerime eklendi." +alreadyFavorited: "Zaten favorilerinizde kayıtlı." +pin: "Sabitlenmiş" +unpin: "Sabitlemeyi kaldır" +copyContent: "İçeriği kopyala" +copyLink: "Bağlantıyı Kopyala" +delete: "Sil" +deleteAndEdit: "Sil ve yeniden düzenle" +deleteAndEditConfirm: "Bu gönderiyi silip yeniden düzenlemek istiyor musunuz? Bu gönderiye + ilişkin tüm tepkiler, destekler ve yanıtlar silinecektir." +addToList: "Listeye ekle" +sendMessage: "Mesaj Gönder" +copyUsername: "Kullanıcı Adını Kopyala" +searchUser: "Kullanıcıları ara" +pinned: "Sabitlenmiş" +remove: "Sil" +smtpUser: "Kullanıcı Adı" +smtpPass: "Şifre" +user: "Kullanıcı" +searchByGoogle: "Arama" +_mfm: + search: "Arama" + play: MFM'i çal + stop: MFM'i durdur + cheatSheet: MFM Kopya Kağıdı + intro: MFM, Iceshrimp, Iceshrimp, Akkoma ve daha pek çok yerde kullanılabilen bir biçimlendirme + dilidir. Burada mevcut tüm MFM sözdiziminin bir listesini görüntüleyebilirsiniz. + link: Link + boldDescription: Harfleri kalınlaştırarak vurgular. + small: Küçük + smallDescription: İçeriği küçük ve ince görüntüler. + warn: MFM, hızla hareket eden veya gösterişli animasyonlar içerebilir + alwaysPlay: Her zaman tüm animasyonlu MFM'yi otomatik oynat + x4Description: İçeriği büyükten de büyükten daha büyük görüntüler. + rainbowDescription: İçeriğin gökkuşağı renklerinde görünmesini sağlar. + bounceDescription: İçeriğe sıçarayan bir animasyon verir. + sparkle: Işıltı + sparkleDescription: İçeriğe ışıltılı bir parçacık efekti verir. + rotateDescription: İçeriği belirli bir açıyla döndürür. + fadeDescription: İçeriği içeri ve dışarı karartır. + fade: Karart + position: Pozisyon + blockCode: Kod (Blok) + crop: Kırp + positionDescription: İçeriği belirli bir miktarda taşıyın. + scale: Ölçek + scaleDescription: İçeriği belirtilen bir miktara göre ölçeklendirin. + foreground: Ön plan rengi + mention: Bahset + mentionDescription: Bir et-sembolü (@) ve bir kullanıcı adı kullanarak bir kullanıcı + belirleyebilirsiniz. + hashtag: Etiket + dummy: Iceshrimp, Fediverse dünyasını genişletiyor + hashtagDescription: Sayı işareti ve metin kullanarak bir etiket belirtebilirsiniz. + url: URL + urlDescription: URL'ler görüntülenebilir. + inlineMath: Matematik (Satır İçi) + blockCodeDescription: Bir blokta çok satırlı (program) kod için sözdizimi vurgulamasını + görüntüler. + inlineMathDescription: Matematik formüllerini (KaTeX) satır içinde görüntüleyin + quote: Alıntı + quoteDescription: İçeriği alıntı olarak görüntüler. + twitch: Animasyon (Seğir) + emoji: Özel Emoji + jelly: Animasyon (Jöle) + blur: Bulanık + blurDescription: İçeriği bulanıklaştırır. Fareyle üzerine gelindiğinde net bir şekilde + görüntülenecektir. + spinDescription: İçeriğe dönen bir animasyon verir. + plainDescription: Bu MFM efektinde bulunan tüm MFM'lerin etkilerini devre dışı bırakır. + background: Arka plan rengi + backgroundDescription: Metnin arka plan rengini değiştirin. + jump: Animasyon (Zıpla) + cropDescription: İçeriği kırpar. + advancedDescription: Devre dışı bırakılırsa, animasyonlu MFM oynatılmadığı sürece + yalnızca temel işaretlemeye izin verir + bold: Kalın + inlineCodeDescription: (Program) kodu için satır içi sözdizimi vurgulamasını görüntüler. + flip: Tersine Çevir + flipDescription: İçeriği yatay veya dikey olarak çevirir. + font: Yazı Tipi + twitchDescription: İçeriğe güçlü bir şekilde seğiren bir animasyon verir. + spin: Animasyon (Dön) + x2Description: İçeriği büyük gösterir. + rotate: Döndür + plain: Düz + linkDescription: Metnin belirli bölümleri bir URL olarak görüntülenebilir. + searchDescription: Önceden girilmiş metin içeren bir arama kutusu görüntüler. + blockMathDescription: Matematik formüllerini (KaTeX) bir blokta görüntüleyin + jumpDescription: İçeriğe zıplama animasyonu verir. + rainbow: Gökkuşağı + x4: İnanılmaz derecede büyük + tadaDescription: İçeriğe "Tada!" benzeri bir animasyon verir. + shake: Animasyon (Salla) + x3: Büyük göster + blockMath: Matematik (Blok) + x2: Büyük + fontDescription: İçeriğin görüntüleneceği yazı tipini ayarlar. + foregroundDescription: Metnin ön plan rengini değiştirin. + centerDescription: İçeriği ortada görüntüler. + inlineCode: Kod (Satır İçi) + advanced: Gelişmiş MFM + center: Ortala + x3Description: İçeriği daha büyük gösterir. + tada: Animasyon (Tada) + emojiDescription: Özel bir emoji adını iki nokta ile çevreleyerek, özel emoji görüntülenebilir. + jellyDescription: İçeriğe jöle benzeri bir animasyon verir. + shakeDescription: İçeriğe sallanan bir animasyon verir. + bounce: Animasyon (Sıçra) +_sfx: + notification: "Bildirim" + noteMy: Kendi Gönderim + note: Yeni gönderi + antenna: Anten + chat: Sohbet + channel: Kanal bildirimleri + chatBg: Sohbet (Arkaplan) +_widgets: + notifications: "Bildirim" + timeline: "Zaman çizelgesi" + photos: Fotoğraflar + userList: Kullanıcı Listesi + _userList: + chooseList: Liste seç + onlineUsers: Aktif Kullanıcılar + aiscript: AiScript Konsolu + activity: Aktivite + digitalClock: Dijital Saat + unixClock: UNIX Saati + meiliIndexCount: Indexlenmiş gönderiler + calendar: Takvim + trends: Popüler + memo: Yapışkan Notlar + rssTicker: RSS Ticker + federation: Federasyon + postForm: Gönderi Formu + meiliSize: Index boyutu + slideshow: Slayt Gösterisi + button: Düğme + clock: Saat + rss: RSS Okuyucu + serverInfo: Sunucu Bilgisi + meiliStatus: Sunucu Durumu + jobQueue: İş Sırası + serverMetric: Sunucu Bilgileri +_profile: + username: "Kullanıcı Adı" + changeBanner: Afişini değiştir + locationDescription: Önce şehrinizi girerseniz, yerel saatinizi diğer kullanıcılara + gösterecektir. + youCanIncludeHashtags: Hakkımdan'da etiket kullanabilirsin. + description: Hakkımda + metadataDescription: 'Bunları kullanarak profilinizde ek bilgi alanları görüntüleyebilirsiniz. Profilinizdeki bağlantıyı doğrulamak için {rel} ile bir {a} etiketi veya {l} etiketi ekleyebilirsiniz!' + metadata: Ek Bilgi + metadataContent: İçerik + metadataLabel: Etiket + changeAvatar: Avatarını değiştir + name: İsim + metadataEdit: Ek Bilgini Düzenle +_deck: + _columns: + notifications: "Bildirim" + tl: "Zaman çizelgesi" + antenna: Anten + list: Liste + widgets: Araçlar + channel: Kanal + direct: Direkt mesajlar + main: Ana + mentions: Bahsetmeler + swapLeft: Sol sütunla değiştir + addColumn: Sütun ekle + configureColumn: Sütun ayarları + swapRight: Sağ sütunla değiştir + swapUp: Üstteki sütunla değiştir + stackLeft: Sol sütunla birleştir + swapDown: Alttaki sütunla değiştir + popRight: Sağdaki sütunu aç + introduction2: İstediğiniz zaman yeni sütunlar eklemek için ekranın sağındaki + + işaretini tıklayın. + alwaysShowMainColumn: Her zaman ana sütunu göster + columnAlign: Sütunları hizala + profile: Çalışma alanı + newProfile: Yeni çalışma alanı + renameProfile: Çalışma alanını yeniden adlandır + deleteProfile: Çalışma alanını sil + nameAlreadyExists: Bu çalışma alanı zaten mevcut. + introduction: Sütunları özgürce düzenleyerek sizin için mükemmel arayüzü oluşturun! + widgetsIntroduction: Lütfen sütun menüsünde "Araç'ları düzenle"yi seçin ve bir widget + ekleyin. +searchPlaceholder: Iceshrimp'de Ara +reply: Yanıtla +jumpToPrevious: Öncekini görüntüle +deleted: Silindi +editNote: Notu düzenle +noThankYou: Hayır, teşekkürler +addInstance: Bir sunucu ekle +cantFavorite: Favorilere eklenemedi. +edited: '{date} tarihinde ve {time} vaktinde düzenlendi' +loggingIn: Giriş Yapılıyor +save: Kaydet +headlineIceshrimp: Sonsuza kadar ücretsiz, açık kaynak kodlu, merkeziyetsiz sosyal medya + platformu! 🚀 +loadMore: Daha fazla yükle +instance: Sunucu +fetchingAsApObject: Fediverse'den çekiliyor +removeReaction: Tepkini sil +rememberNoteVisibility: Gönderi görünürlüğü ayarlarını hatırla +attachCancel: Eklentiyi kaldır +suspend: Askıya Al +unsuspend: Askıya Almayı Kaldır +unmute: Susturmayı Kaldır +blockConfirm: Bu hesabı engellemek istediğinize emin misiniz? +unblockConfirm: Bu hesabın engelini kaldırmak istediğinize emin misiniz? +settingGuide: Tavsiye edilen ayarlar +cacheRemoteFilesDescription: Bu ayar devre dışı bırakıldığında, uzak dosyalar doğrudan + uzak sunucudan yüklenir. Bunun devre dışı bırakılması depolama kullanımını azaltacak, + ancak küçük resimler oluşturulmayacağından trafiği artıracaktır. +flagAsCatDescription: Kedi kulaklarına sahip olacak ve bir kedi gibi konuşacaksın! +flagSpeakAsCat: Kedi gibi konuş +setWallpaper: Arkaplan ayarla +removeWallpaper: Arkaplanı sil +operations: Operasyonlar +clearCachedFiles: Ön belleği temizle +clearCachedFilesConfirm: Önbelleğe alınan tüm uzak dosyaları silmek istediğinizden + emin misiniz? +blockedInstancesDescription: Engellemek istediğiniz sunucuların ana bilgisayar adlarını + listeleyin. Listelenen sunucular artık bu sunucularla iletişim kuramayacak. +blockedUsers: Engellenmiş kullanıcılar +editProfile: Profilini düzenle +intro: Iceshrimp'in indirilmesi tamamlandı! Lütfen yönetici hesap oluşturun. +instanceUsers: Sunucunun kullanıcıları +changePassword: Şifreyi değiştir +security: Güvenlik +newPasswordRetype: Yeni şifreyi tekrarla +uploadFromUrlRequested: Yükleme istendi +syncDeviceDarkMode: Karanlık modu cihazının ayarları ile senkronize et +renameFolder: Bu klasörü yeniden adlandır +emptyFolder: Bu klasör boş +unableToDelete: Silinemiyor +inputNewDescription: Yeni başlık gir +hasChildFilesOrFolders: Bu klasör boş olduğundan silinemez. +disconnectedFromServer: Sunucuyla bağlantı kesildi +reload: Yenile +disablingTimelinesInfo: Yöneticiler ve Moderatörler, etkinleştirilmemiş olsalar bile + tüm zaman çizelgelerine her zaman erişebilir. +pinnedUsersDescription: '"Keşfet" sekmesinde sabitlenecek kullanıcı adlarını satır + sonlarıyla ayırarak listeleyin.' +pinnedPages: Sabitlenmiş Sayfalar +pinnedPagesDescription: Bu sunucunun üst sayfasına sabitlemek istediğiniz Sayfaların + yollarını satır sonları ile ayırarak girin. +enableHcaptcha: hCaptcha'yı Aktif Et +notifyAntenna: Yeni gönderileribildir +recentlyUpdatedUsers: En son aktif kullanıcılar +about: Hakkında +twoStepAuthentication: İki-adımlı doğrulama +securityKeyName: Key name +help: Yardım +inputMessageHere: Mesajını buraya gir +ownedGroups: Gruplarım +joinedGroups: Katılınmış gruplar +invites: Davetler +members: Kullanıcılar +transfer: Transfer +messagingWithGroup: Grup sohbeti +next: Sonraki +retype: Tekrar gir +dashboard: Panel +objectStorageBucket: Bucket +objectStorageBucketDesc: Sağlayıcınız tarafından kullanınan bucket ismini yazın. +showFixedPostForm: Gönderim formunu zaman çizelgesinin en üstünde görüntüleyin +newNoteRecived: Yeni gönderiler mevcut +none: Hiçbiri +details: Detaylar +recentUsed: Son kullanılan +installedApps: Yetkilendirilmiş Uygulamalar +removeAllFollowing: Takip edilen herkesi çıkar +yourAccountSuspendedDescription: Bu hesap, sunucunun hizmet şartlarını veya benzerlerini + ihlal ettiği için askıya alındı. Daha ayrıntılı bir neden öğrenmek istiyorsanız + yöneticiyle iletişime geçin. Lütfen yeni bir hesap oluşturmayın. +addedRelays: Eklenen Röleler +serviceworkerInfo: Push bildirimleri için aktif olması gerekiyor. +author: Sahip +tokenRequested: Hesaba erişim ver +useFullReactionPicker: Tam boyutunda tepki seçici kullan +small: Küçük +enableAll: Hepsine izin ver +disableAll: Hepsini kapat +regexpError: Regex hatası +emailConfigInfo: Kayıt sırasında veya şifrenizi unutursanız e-postanızı onaylamak + için kullanılır +smtpSecure: SMTP bağlantıları için SSL/TSL kullan +regexpErrorDescription: '{tab} kelimenizin {line} satırındaki normal ifadede bir hata + oluştu:' +instanceMute: Sunucu Susturmaları +reporter: Rapor eden +userSaysSomethingReason: '{name}, {reason} söyledi' +userSaysSomethingReasonRenote: '{name}, {reason} içeren bir gönderiyi öne çıkardı' +userSaysSomethingReasonQuote: '{name}, {reason} içeren bir gönderiden alıntı yaptı' +notificationSettingDesc: Görünecek bildirimleri seç. +other: Diğer +sample: Örnek +notSet: Ayarlanmadı +emailVerified: Mail doğrulandı +showGapBetweenNotesInTimeline: Zaman tünelinde gönderiler arasındaki boşluğu göster +sendErrorReports: Hata raporları gönder +followingCount: Takip edilen hesap sayısı +no: Hayır +myTheme: Temam +backgroundColor: Arkaplan rengi +accentColor: Vurgu rengi +textColor: Yazı rengi +createdAt: Oluşturuldu +updatedAt: Güncellendi +saveConfirm: Kaydet? +registry: Kayıt +currentVersion: Şuanki Sürüm +accountDeletionInProgress: Hesap silme şu anda devam ediyor +unresolved: Çözülmedi +newVersionOfClientAvailable: Yeni istemci sürümü mevcut. +shareWithNote: Gönderi ile paylaş +whatIsNew: Değişiklikleri göster +translate: Çevir +breakFollow: Takipçiyi sil +breakFollowConfirm: Takipçiyi kaldırmak istediğinizden emin misiniz? +unfollowConfirm: "{name}'i takibi bırakmak istediğinizden emin misiniz?" +importRequested: Bir içe aktarma isteğinde bulundunuz. Bu biraz zaman alabilir. +somethingHappened: Bir hata ile karşılaşıldı +retry: Tekrar Dene +youShouldUpgradeClient: Bu sayfayı görüntülemek için, lütfen istemcinizi güncelleyin. +reactionSetting: Tepki seçicide gösterilecek tepkiler +unmarkAsSensitive: NSFW işaretini kaldır +enterFileName: Dosya adı gir +noJobs: Hiçbir iş yok +instanceFollowing: Sunucuda takip ediliyor +instanceFollowers: Sunucunun takipçileri +currentPassword: Şuanki şifre +newPassword: Yeni şifre +saved: Kaydedildi +uploadFromUrlDescription: Yüklemek istediğiniz dosyanın URL'si +noMoreHistory: Başka geçmiş yok +startMessaging: Yeni sohbet oluştur +manageGroups: Grupları düzenle +nUsersRead: '{n} tarafından okundu' +images: Görseller +birthday: Doğumgünü +light: Aydınlık +dark: Karanlık +lightThemes: Aydınlık temalar +selectFiles: Dosyalar seç +selectFolders: Klasörler seç +renameFile: Dosyayı yeniden adlandır +folderName: Klasör adı +createFolder: Klasör oluştur +copyUrl: URL'yi Kopyala +maintainerName: Sahip +maintainerEmail: Sahibin e-postası +tosUrl: Kullanım Koşulları URL'si +monthX: '{month}' +basicInfo: Basit bilgi +pinnedUsers: Sabitlenmiş kullanıcılar +manageAntennas: Antenleri Düzenle +name: İsim +silence: Sustur +unsilence: Susturmayı geri al +exploreUsersCount: '{count} Kullanıcı var' +exploreFediverse: Fediversi keşfet +popularTags: Popüler etiketler +close: Kapat +group: Grup +text: Yazı +checking: Doğrulanıyor... +tooLong: Çok uzun +weakPassword: Zayıf şifre +normalPassword: Ortalama şifre +disableDrawer: Çekmece tarzı menüler kullanmayın +youHaveNoGroups: Grupların yok +joinOrCreateGroup: Bir gruba davet edil veya kendininkini oluştur. +regenerate: Yeniden Oluştur +fontSize: Yazı boyutu +noFollowRequests: Bekleyen takip isteğiniz yok +openImageInNewTab: Resmi yeni sekmede aç +useObjectStorage: Object Storage kullan +objectStorageUseProxy: Proxy üzerinden bağlan +installedDate: Yetkilendirilme tarihi +scratchpad: Karalama Defteri +deleteAllFiles: Tüm dosyaları isl +useCw: İçeriği gizle +plugins: Eklentiler +manage: Yönetmek +preferencesBackups: Tercih yedekleri +generateAccessToken: Erişim tokeni oluştur +enableEmail: E-posta dağıtımını etkinleştir +regenerateLoginToken: Giriş tokenini yeniden oluştur +regenerateLoginTokenDescription: Oturum açma sırasında dahili olarak kullanılan belirteci + yeniden oluşturur. Normalde bu eylem gerekli değildir. Yeniden oluşturulursa, tüm + cihazların oturumu kapatılacaktır. +followersCount: Takipçi sayısı +yes: Evet +lockedAccountInfo: Gönderi görünürlüğünüzü "Yalnızca takipçiler" olarak ayarlamazsanız, + takipçilerin manuel olarak onaylanmasını isteseniz bile gönderileriniz herkes tarafından + görülebilir. +unlikeConfirm: Beğeniyi kaldırmak istiyor musunuz? +notSpecifiedMentionWarning: Bu gönderi, alıcı olarak dahil edilmeyen kullanıcılardan + bahsetmektedir +hideOnlineStatus: Çevrimiçi bilgisini gizle +hideOnlineStatusDescription: Çevrimiçi durumunuzu gizlemek, arama gibi bazı özelliklerin + rahatlığını azaltır. +botProtection: Bot Koruması +selectAccount: Hesap seç +recentPosts: En son sayfalar +high: Yüksek +middle: Orta +secureModeInfo: Diğer sunuculardan talepte bulunurken kanıtlamadan geri göndermeyiniz. +previewNoteText: Önizlemeyi göster +customCss: Özel CSS +global: Global +makeReactionsPublic: Tepki geçmişini herkese açık olarak ayarla +clickToFinishEmailVerification: Mail doğrulamasını tamamlamak için lütfen [{ok}]'a + tıklayın. +overridedDeviceKind: Cihaz tipi +smartphone: Akıllı telefon +tablet: Tablet +auto: Otomatik +tenMinutes: 10 dakika +recentNDays: Son {n} gün +noEmailServerWarning: Mail sunucusu ayarlanmadı. +thereIsUnresolvedAbuseReportWarning: Çözülmemiş raporlar var. +statusbar: Durum çubuğu +pleaseSelect: Bir seçenek seçin +lastActiveDate: Son kullanılan +reverse: Tersi +logoutConfirm: Gerçekten oturum kapatılsın mı? +type: Tip +speed: Hız +slow: Yavaş +activeEmailValidationDescription: Tek kullanımlık adreslerin kontrol edilmesi ve gerçekten + iletişim kurup kurulamayacağına göre e-posta adreslerinin daha sıkı doğrulanmasını + sağlar. İşaretlenmediğinde, yalnızca e-postanın biçimi doğrulanır. +move: Taşı +defaultReaction: Giden ve gelen gönderiler için varsayılan emoji tepkisi +indexPosts: Dizin Gönderileri +youGotNewFollower: takip etti +receiveFollowRequest: Takip isteği alındı +followRequestAccepted: Takip isteği onaylandı +mention: Bahset +download: İndir +lists: Listeler +noLists: Hiç listen yok +cantRenote: Bu gönderi yükseltilemez. +cantReRenote: Yükseltme yükseltilemez. +mute: Sustur +block: Engelle +editWidgetsExit: Tamamlandı +customEmojis: Özel Tepki +cpuAndMemory: İşlemci ve Bellek +selectInstance: Sunucu seç +instances: Sunucular +silencedInstancesDescription: Susturmak istediğiniz sunucuların ana bilgisayar adlarını + listeleyin. Listelenen sunuculardaki hesaplar "Sessiz" olarak değerlendirilir, yalnızca + takip istekleri yapabilir ve takip edilmediği takdirde yerel hesaplardan bahsedemez. + Bu, engellenen sunucuları etkilemeyecektir. +muteAndBlock: Susturmalar ve Engeller +noteDeleteConfirm: Bu gönderiyi silmek istediğine emin misin? +resetAreYouSure: Gerçekten sıfırla? +remoteUserCaution: Uzak kullanıcılardan gelen bilgiler eksik olabilir. +yearsOld: '{age} yaşında' +removed: Başarıyla silindi +reject: Reddet +unwatch: İzlemeyi bırak +accept: Kabul et +normal: Normal +thisMonth: Ay +enableRecaptcha: reCAPTCHA'yı Aktif Et +antennas: Antenler +recaptchaSiteKey: Site key +withFileAntenna: Sadece dosyalı gönderiler +antennaInstancesDescription: Sunucu başı bir satır kullanın +moderator: Moderatör +moderation: Moderasyon +lastUsed: En son kullanılan +unregister: Kaydı sil +passwordLessLogin: Şifresiz giriş +uploadFolder: Yüklemeler için varsayılan klasör +markAsReadAllUnreadNotes: Tüm gönderileri okundu olarak işaretle +notFound: Bulunamadı +groups: Gruplar +quoteQuestion: Alıntı olarak eklensin mi? +signinRequired: Lütfen devam etmeden önce kayıt olun +noMessagesYet: Şuana kadar mesaj yok +newMessageExists: Yeni mesaj yok +invitations: Davetler +invitationCode: Davet kodu +signinWith: '{x} ile giriş yap' +strongPassword: Güçlü şifre +passwordNotMatched: Uyuşmuyor +signinFailed: Giriş yapılamadı. Şifre ve ya kullanıcı adı yanlış. +tapSecurityKey: Güvenlik anahtarınıza dokunun +or: veya +noHistory: Geçmiş bulunamadı +language: Dil +clientSettings: İstemci Ayarları +accountSettings: Hesap Ayarları +listen: Dinle +chooseEmoji: Emoji seç +promotion: Terfi Edildi +nothing: Burada görüntülenecek bir şey yok +lastUsedDate: Son kullanılma tarihi +updateRemoteUser: Uzak kullanıcı bilgilerini güncelle +width: Genişlik +height: Uzunluk +permission: İzinler +email: Mail +smtpSecureInfo: STARTTLS kullanırken bunu kapatın +alwaysMarkSensitive: Varsayılan olarak NSFW olarak işaretle +noteFavoritesCount: İşaretlenen gönderilerin sayısı +pageLikesCount: Beğenilen Sayfaların sayısı +duplicate: Kopyasını Oluştur +clearCache: Önbelleği Temizle +onlineUsersCount: '{n} kullanıcı aktif' +nUsers: '{n} Kullanıcı' +nNotes: '{n} Gönderi' +useReactionPickerForContextMenu: Sağ tık ile tepki seçiciyi aç +typingUsers: '{users} yazıyor' +jumpToSpecifiedDate: Spesifik tarihe atla +showingPastTimeline: Şuan eski bir zaman çizelgesini görüntülüyorsunuz +clear: Temizle +fullView: Tam görünüm +emailNotConfiguredWarning: Mail adresi seçilmedi. +privateMode: Özel Mod +fast: Hızlı +learnMore: Daha fazla bilgi edin +localOnly: Sadece yerel +delayed: Ertelenmiş +useGlobalSetting: Global ayaralrı kullan +switchAccount: Hesap değiştir +notRecommended: Tavsiye edilmiyor +onlineStatus: Çevrimiçi bilgisi +active: Aktif +instanceBlocking: Federasyon Yönetmek +enabled: Aktif +disabled: Deaktif +quickAction: Hızlı işlemler +configure: Yapılandır +blockedInstances: Engellenmiş Sunucular +silencedInstances: Susturulmuş Sunucular +lookup: Görüntüle +inputNewFolderName: Yeni klasör ismi gir +noteOf: Gönderi {user} tarafından +onlyOneFileCanBeAttached: Bir mesaja sadece 1 dosya ekleyebilirsin +install: İndir +uninstall: kALDIR +send: Gönder +noCrawleDescription: Arama motorlarından profil sayfanızı, gönderilerinizi, Sayfalarınızı + vb. indekslememesini isteyin. +emailNotification: Mail bildirimleri +goBack: Geri +online: Çevrimiçi +translatedFrom: "{x}'den çevrildi" +cropImage: Resmi kırp +deleteAccount: Hesabı Sil +navbar: Gezinti çubuğu +account: Hesap +instanceDefaultThemeDescription: Tema kodunu nesne biçiminde girin. +alt: ALT +mutePeriod: Sessiz süresi +indefinitely: Kalıcı olarak +oneHour: Bir saat +oneWeek: Bir hafta +colored: Renkli +sensitiveMediaDetection: Resim NSFW Belirleme +subscribePushNotification: Push bildirimlerini aktif et +pushNotificationAlreadySubscribed: Push bildirimler zaten açık +sendPushNotificationReadMessage: İlgili bildirimler veya mesajlar okunduktan sonra + push bildirimlerini silin +sendPushNotificationReadMessageCaption: Kısa bir süre için "{emptyPushNotificationMessage}" + metnini içeren bir bildirim görüntülenecektir. Bu, mümkünse cihazınızın pil kullanımını + artırabilir. +enterSendsMessage: Mesaj göndermek için Mesajlaşma'da Geri Dön'e basın (Ctrl + Return) +customMOTDDescription: Bir kullanıcı sayfayı her yüklediğinde/yeniden yüklediğinde + rastgele gösterilecek satır sonlarıyla ayrılmış MOTD (açılış ekranı) için özel mesajlar. +customSplashIconsDescription: Bir kullanıcı sayfayı her yüklediğinde/yeniden yüklediğinde + rastgele gösterilecek satır sonlarıyla ayrılmış özel açılış ekranı simgeleri için + URL'ler. Lütfen resimlerin statik bir URL'de olduğundan ve tercihen tümü 192x192 + olarak yeniden boyutlandırıldığından emin olun. +updateAvailable: Bir güncelleme mevcut olabilir! +splash: Açılış Ekranı +moveTo: Şimdiki hesabını yeni bir hesaba taşı +swipeOnMobile: Sayfalar arasında kaydırmaya izin ver +swipeOnDesktop: Masaüstünde mobil stil kaydırmaya izin ver +migration: Taşıma +moveAccount: Hesabını taşı! +moveFrom: Daha eski bir hesaptan bu hesaba taşıyın +moveFromLabel: 'Taşındığınız hesap:' +importAndExport: İçeri/Dışarı Aktar +manageLists: Listeleri düzenle +error: Hata +pageLoadError: Sayfayı yüklerken bir hata ile karşılaşıldı. +serverIsDead: Sunucu yanıt vermiyor. Biraz bekleyip tekrar deneyin. +defaultNoteVisibility: Varsayılan görünürlük +follow: Takip et +reactionSettingDescription2: Yeniden sıralamak için sürükleyin, silmek için tıklayın, + eklemek için "+"ya basın. +you: Sen +clickToShow: Görmek için tıkla +sensitive: NSFW +add: Ekle +reaction: Tepkiler +markAsSensitive: NSFW olarak işaretle +unblock: Engeli Kaldır +addAccount: Hesap ekle +network: İnternet +disk: Depolama +instanceInfo: Sunucu Bilgisi +statistics: İstatistikler +hiddenTagsDescription: Trendlerden gizlemek ve keşfetmek istediğiniz etiketlerin (# + olmadan)etiketlerini listeleyin. Gizli etiketler başka yollarla keşfedilebilir. +mutedUsers: Susturulmuş kullanıcılar +uploadFromUrlMayTakeTime: Yüklemenin tamamlanması zaman alabilir. +activity: Aktivite +theme: Temalar +themeForLightMode: Aydınlık modda kullanmak için temalar +reloadConfirm: Zaman çizelgesini yenilemek ister misiniz? +instanceName: Sunucu adı +circularReferenceFolder: Hedef klasör, taşımak istediğiniz klasörün bir alt klasörüdür. +instanceDescription: Sunucu açıklaması +driveCapacityPerLocalAccount: Kullanıcı başı Driver kapasitesi +driveCapacityPerRemoteAccount: Uzak kullanıcı başı Driver kapasitesi +inMb: Megabayt cinsinden +pinnedClipId: Sabitlenecek atacın ID'si +withFiles: Dosya içeren +recentlyRegisteredUsers: Yeni katılmış kullanıcılar +recentlyDiscoveredUsers: Yeni keşfedilmiş kullanıcılar +nUsersMentioned: '{n} kullanıcı tarafından bahsedildi' +securityKey: Security key +title: Başlık +total: Toplam +sounds: Sesler +objectStorageRegionDesc: "'xx-east-1' gibi bir bölge belirtin. Hizmetiniz bölgeler + arasında ayrım yapmıyorsa, bunu boş bırakın veya 'us-east-1' girin." +objectStorageUseSSL: SSL Kullan +popout: Açılır Pencere +volume: Ses Kuvveti +showInPage: Sayfada göster +masterVolume: Ana ses +undeck: Desteden çık +useBlurEffectForModal: Modallar için bulanıklık efekti uygula +leaveConfirm: Kaydedilmemiş değişiklikler var. Devam etmek istiyor musunuz? +testEmail: Email dağıtımını test et +wordMute: Kelime susturması +userSaysSomething: '{name} bir şey söyledi' +channel: Kanallar +create: Oluştur +useGlobalSettingDesc: Açıksa, hesap bildirim ayarlarınız kullanılacaktır. Kapatılırsa, + bireysel yapılandırmalar yapılabilir. +setMultipleBySeparatingWithSpace: Birden çok girişi boşluklarla ayırın. +fileIdOrUrl: Dosya ID veya URL'si +behavior: Davranış +abuseReported: Raporunuz gönderildi. Teşekkürler. +reporteeOrigin: Ana Raporcu +reporterOrigin: Ana Rapor Eden +defaultNavigationBehaviour: Varsayılan gezinme davranışı +editTheseSettingsMayBreakAccount: Bunları düzenlemek hesabınıza zarar verebilir. +renotedCount: Alınan yükseltme sayısı +driveFilesCount: Drive dosya sayısı +deleteConfirm: Sil? +invalidValue: Geçersiz değer. +instanceSecurity: Sunucu Güvenliği +searchResult: Arama sonuçları +useBlurEffect: Kullanıcı arayüzünde bulanıklaştırma efektleri kullanın +iceshrimpUpdated: Iceshrimp güncellendi! +lastCommunication: Son iletişim +itsOn: Etkinleştirilmiş +emailRequiredForSignup: Kayıt olmak için mail gerekiyor +leaveGroup: Gruptan ayrıl +useDrawerReactionPickerForMobile: Reaksiyon seçiciyi mobil cihazda çekmece olarak + göster +leaveGroupConfirm: '"{name}"den ayrılmak istediğinizden emin misiniz?' +instanceDefaultLightTheme: Sunucu genelinde varsayılan aydınlık tema +document: Dökümanlar +numberOfPageCacheDescription: Bu sayının arttırılması, kullanıcılar için kolaylık + sağlayacaktır ancak daha fazla sunucu yükünün yanı sıra daha fazla bellek kullanılmasına + neden olacaktır. +refreshInterval: 'Güncelleme aralığı ' +label: Etiket +replayTutorial: Eğiticiyi tekrar oynat +moveAccountDescription: Bu süreç geri döndürülemez. Taşımadan önce yeni hesabınızda + bu hesap için bir takma ad ayarladığınızdan emin olun. Lütfen @person@server.com + şeklinde biçimlendirilmiş hesabın etiketini girin +emojis: Emoji +flagAsCat: Kedi misin? 😺 +selectChannel: Kanal seç +emojiName: Emoji adı +showOnRemote: Uzak sunucuda görüntüle +flagSpeakAsCatDescription: Gönderileriniz kedi modundayken nyanifiye edilecek +flagShowTimelineReplies: Yanıtları zaman çizelgesinde göster +silenceThisInstance: Bu sunucuyu sustur +proxyAccountDescription: Vekil hesabı, belirli koşullar altında kullanıcılar için + uzaktan takipçi işlevi gören bir hesaptır. Örneğin, bir kullanıcı listeye bir uzak + kullanıcı eklediğinde, o kullanıcıyı takip eden yerel bir kullanıcı yoksa uzak kullanıcının + etkinliği sunucuya teslim edilmeyecektir, bu nedenle onun yerine vekil hesabı takip + edilecektir. +clearQueueConfirmTitle: Bu sırayı temizlemek istediğine emin misin? +software: Yazılım +version: Sürüm +federating: Federasyon +preview: Ön izleme +retypedNotMatch: Girişler uyuşmuyor. +attachFile: Dosya ekle +noSuchUser: Kullanıcı bulunamadı +removeAreYouSure: '"{x}" kaldırmak istediğinize emin misiniz?' +keepOriginalUploading: Orjinal resmi sakla +messageRead: Oku +deleteAreYouSure: '"{x}" silmek istediğinize emin misiniz?' +messaging: Sohbet +upload: Yükle +fromUrl: URL'den +agreeTo: '{0} kabul ediyorum' +tos: Kullanım Koşulları +drive: Drive +selectFolder: Klasör seç +inputNewFileName: Yeni dosya ismi gir +whenServerDisconnected: Sunucuyla bağlantı kesildiğinde +avatar: Avatar +rename: Yeniden Adlandır +banner: Afiş +nsfw: NSFW +doNothing: Görmezden Gel +watch: İzle +connectService: Bağlan +registration: Kayıt +hcaptcha: hCaptcha +pinnedNotes: Sabitlenmiş gönderiler +hcaptchaSiteKey: Site key +hcaptchaSecretKey: Secret key +antennaSource: Anten kaynağı +antennaKeywords: Dinlenecek anahtar kelimeler +antennaExcludeKeywords: Hariç tutulacak anahtar kelimeler +antennaKeywordsDescription: AND koşulu için boşluklarla veya OR koşulu için satır + sonlarıyla ayırın. +caseSensitive: Büyük harf duyarlı +enableServiceworker: Tarayıcınız için Push-Bildirimleri Etkinleştirin +unsilenceConfirm: Bu kullanıcının susturma işlemini geri almak istediğinizden emin + misiniz? +userList: Listeler +antennaUsersDescription: Kullanıcı başı bir satır kullanın +administrator: Yönetici +token: Token +cacheClear: Önbelleği temizle +createGroup: Grup oluştur +newPasswordIs: Yeni şifren "{password}" +share: Paylaş +enable: Etkinleştir +groupName: Grup adı +available: Mevcut +unavailable: Mevcut değil +weekOverWeekChanges: Geçen haftadan beri değişiklikler +usernameInvalidFormat: Büyük ve küçük harfleri, sayıları ve alt çizgileri kullanabilirsiniz. +tooShort: Çok kısa +passwordMatched: Uyuşuyor +dayOverDayChanges: Dünden beri değişiklikler +appearance: Görünüm +objectStorageBaseUrl: Ana URL +objectStoragePrefix: Prefix +unableToProcess: Operasyon tamamlanamadı +deleteAllFilesConfirm: Tüm dosyaları silmek istediğine emin misin? +disablePagesScript: Sayfalardan AiScript'i deaktive et +expandOnNoteClick: Gönderileri basarak aç +expandOnNoteClickDesc: Kapatılırsa, gönderileri hala menüden veya sağtıklayarak açabilirsin. +removeAllFollowingDescription: Bunu gerçekleştirmek, {host} üzerindeki tüm hesapları + takip etmeyi bırakır. +deck: Deste +pluginTokenRequestedDescription: Bu eklenti, burada ayarlanan izinleri kullanabilecektir. +notificationType: Bildirim tipi +channelFederationWarn: Kanallar başka sunuculara federe edilmiyor +forwardReport: Raporu uzak sunucuya ilet +openInNewTab: Yeni sekmede aç +clip: Ataç +optional: Opsiyonel +manageAccessTokens: Erişim tokenlerini düzenle +clipsDesc: Ataçlar, paylaşılabilen kategorize yer imleri gibidir. Tek tek gönderiler + menüsünden ataçlar oluşturabilirsiniz. +makeExplorable: Hesabını "Keşfet" 'te göster +accountInfo: Hesap Bilgisi +makeExplorableDescription: Bunu kapatırsanız, hesabınız "Keşfet" bölümünde görünmez. +saveAs: Olarak kaydet... +advanced: Gelişmiş +value: Değer +youAreRunningUpToDateClient: En son istemci sürümünü kullanıyorsunuz. +accounts: Hesaplar +switch: Değiştir +popularPosts: Popüler sayfalar +inChannelSearch: Kanalda ara +administration: Yönetim +ads: Reklamlar +low: Düşük +seperateRenoteQuote: Ayrı destek ve fiyat teklifi düğmeleri +sent: Gönderildi +customMOTD: Özel MOTD +showUpdates: Iceshrimp güncellendiğinde bir açılır pencere göster +logoImageUrl: Logo resim URL'si +showAdminUpdates: Yeni bir Iceshrimp sürümünün mevcut olduğunu belirtin (yalnızca yönetici) +newer: asla +older: daha eski +exportRequested: Bir dışarı aktarma talebinde bulundunuz. Bu biraz zaman alabilir. + Tamamlandığında Drive'ınıza eklenecektir. +notes: Gönderiler +following: Takip Ediyor +followers: Takipçiler +followsYou: Seni takip ediyor +pageLoadErrorDescription: Buna normalde ağ hataları veya tarayıcının önbelleği neden + olur. Önbelleği temizlemeyi deneyin ve biraz bekledikten sonra tekrar deneyin. +quote: Alıntıla +pinnedNote: Sabitlenmiş gönderi +renote: Yükselt +unrenote: Yükseltmeyi geri al +emojiUrl: Emoji URL +suspendConfirm: Bu hesabı askıya almak istediğinize emin misiniz? +addEmoji: Ekle +autoAcceptFollowed: Takip ettiğiniz kullanıcıların takip isteklerini otomatik olarak + onaylayın +general: Genel +accountMoved: 'Bu kullanıcı yeni bir hesapa taşındı:' +wallpaper: Arkaplan +searchWith: 'Arat: {q}' +youHaveNoLists: Hiçbir listen yok +followConfirm: '{name} kullanıcısını takip etmek istediğine emin misin?' +metadata: Metadata +monitor: İzlengeç +jobQueue: İş Sırası +noUsers: Kullanıcı bulunamadı +noInstances: Sunucu bulunamadı +pinLimitExceeded: Daha fazla gönderi sabitleyemezsin +defaultValueIs: 'Varsayılan: {value}' +noCustomEmojis: Emoji yok +blocked: Engellenmiş +default: Varsayılan +all: Tümü +subscribing: Abone Olunuyor +publishing: Yayınlanmak +notResponding: Cevap vermiyor +more: Daha fazla! +featured: Önerilen +usernameOrUserId: Kullanıcı adı veya kullanıcı id'si +fromDrive: Drive'dan +uploadFromUrl: URL'den yükle +announcements: Duyurular +explore: Keşfet +imageUrl: Resim URL'si +thisYear: Yıl +deleteFolder: Bu klasörü sil +addFile: Dosya ekle +dayX: '{day}' +enableLocalTimeline: Yerel zaman çizgisini aktif et +disconnectService: Bağlantıyı kes +enableGlobalTimeline: Global zaman çizgisini aktif et +enableRegistration: Yeni kullanıcı kaydını aktif et +invite: Davet et +bannerUrl: Afiş resmi URL +backgroundImageUrl: Arkaplan URL'si +recaptcha: reCAPTCHA +iconUrl: Ikon URL +recaptchaSecretKey: Secret key +avoidMultiCaptchaConfirm: Birden fazla Captcha sistemi kullanmak aralarında etkileşime + neden olabilir. Şu anda etkin olan diğer Captcha sistemlerini devre dışı bırakmak + ister misiniz? Etkin kalmalarını istiyorsanız, iptal düğmesine basın. +aboutIceshrimp: Iceshrimp Hakkında +popularUsers: Popüler kullanıcılar +notFoundDescription: Bu URL'ye karşılık gelen sayfa bulunamadı. +reduceUiAnimation: Arayüz animasyonlarını azalt +markAsReadAllNotifications: Tüm bildirimleri okundu olarak işaretle +markAsReadAllTalkMessages: Tüm mesajları okundu olarak işaretle +inviteToGroup: Gruba davet et +quoteAttached: Alıntıla +useOsNativeEmojis: Sistem Emojilerini Kullan +signinHistory: Giriş geçmişleri +disableAnimatedMfm: Animasyonlu MFM'yi devre dışı bırak +uiLanguage: Arayüz dili +groupInvited: Bir gruba davet edildin +createAccount: Hesap Oluştur +existingAccount: Var olan hesap +aboutX: '{x} Hakkında' +doing: İşleniyor... +category: Kategori +deleteAll: Hepsini sil +objectStorageEndpoint: Endpoint +output: Çıkış +userSuspended: Bu kullanıcı askıya alındı. +userSilenced: Bu kullanıcı susturuldu. +yourAccountSuspendedTitle: Bu hesap askıya alındı +relays: Röleler +inboxUrl: Gelen URL +menu: Menü +divider: Ayraç +addItem: Öğe Ekle +enableInfiniteScroll: Otomatik olarak daha fazla yükle +enablePlayer: Video oynatıcıyı aç +disablePlayer: Video oynatıcıyı kapat +expandTweet: Tweeti Büyüt +large: Büyük +medium: Orta +smtpConfig: SMTP Sunucusu Ayarları +smtpHost: Adres +emailServer: Mail sunucusu +edit: Düzenle +emailAddress: Mail adresi +smtpPort: Port +emptyToDisableSmtpAuth: SMTP doğrulamasını kapatmak için kullanıcı adı ve şifreyi + boş bırakın +makeActive: Aktif +display: Gösterim +copy: Kopyala +metrics: Metrikler +pollVotesCount: Gönderilen oylama sayısı +loadRawImages: Küçük resimleri göstermek yerine orijinal resimleri yükleyin +switchUi: Düzen +sentReactionsCount: Gönderilen tepki sayısı +receivedReactionsCount: Alınan tepki sayısı +pollVotedCount: Alınan oylama sayısı +pageLikedCount: Beğeni alan Sayfa sayısı +contact: Bağlantı +useSystemFont: Sistemin varsayılan yazı tipini kullan +usageAmount: Kullanım +inUse: Kullanılan +userInfo: Kullanıcı bilgisi +unknown: Bilinmiyor +customCssWarn: Bu ayar yalnızca ne işe yaradığını biliyorsanız kullanılmalıdır. Uygun + olmayan değerlerin girilmesi, istemcinin normal şekilde çalışmamasına neden olabilir. +memo: Not +allowedInstancesDescription: Her biri yeni bir satırla ayrılmış, federasyon için beyaz + listeye eklenecek sunucu ana bilgisayarları (yalnızca özel modda geçerlidir). +expiration: Bitiş +troubleshooting: Sorun giderme +usernameInfo: Hesabınızı bu sunucudaki diğerlerinden ayıran bir ad. Alfabeyi (a~z, + A~Z), rakamları (0~9) veya alt çizgileri (_) kullanabilirsiniz. Kullanıcı adları + daha sonra değiştirilemez. +size: Boyut +numberOfColumn: Sütun Sayısı +driveCapOverrideCaption: 0 veya daha düşük bir değer girerek kapasiteyi varsayılana + sıfırlayın. +requireAdminForView: Bunu görüntülemek için bir yönetici hesabıyla oturum açmalısınız. +userSaysSomethingReasonReply: '{name}, {reason} içeren bir gönderiye cevap verdi' +overview: Genel Bakış +logs: Günlükler +database: Veri Tabanı +reportAbuseOf: '{name} kullanıcısını raporla' +openInSideView: Yan görünümde aç +createNew: Yeni oluştur +createNewClip: Yeni ataç oluştur +unclip: Atacı Kaldır +notesCount: Gönderi sayısı +repliesCount: Gönderilen yanıt sayısı +renotesCount: Göndeirlen yükseltme sayısı +repliedCount: Alınan yanıt sayısı +driveUsage: Drive kullanımı +noCrawle: Tarayıcı dizine eklemeyi reddet +needReloadToApply: Bunun yansıtılması için bir yeniden yükleme gereklidir. +showTitlebar: Başlık çubuğunu göster +latestVersion: En Son Sürüm +capacity: Kapasite +userPagePinTip: Tek tek gönderiler menüsünden "Profile sabitle"yi seçerek gönderileri + burada görüntüleyebilirsiniz. +offline: Çevrimdışı +priority: Öncelik +ratio: Oran +secureMode: Güvenli Mod (Yetkili Getirme) +aiChanMode: Klasik kullanıcı arayüzünde Ai-chan +recommended: Önerilen +received: Alındı +classic: Ortalanmış +muteThread: Konuyu sessize al +deleteAccountConfirm: Bu, hesabınızı geri alınamaz bir şekilde silecektir. İlerle? +hide: Gizle +pubSub: Pub/Sub Hesapları +filter: Filtre +controlPanel: Kontrol Paneli +continueThread: Konuya devam et +incorrectPassword: Yanlış şifre. +voteConfirm: '"{choice}" için oyunuzu onaylıyor musunuz?' +failedToFetchAccountInformation: Hesap bilgileri getirilemedi +rateLimitExceeded: Hız limiti aşıldı +renotedBy: '{user} Yükseltti' +host: Host +objectStorage: Object Storage +objectStorageUseSSLDesc: API bağlantıları için HTTPS kullanmayacaksanız bunu kapatın +objectStorageUseProxyDesc: API bağlantıları için Proxy kullanmayacaksanız bunu kapatın +objectStorageSetPublicRead: Yüklendiğinde "public-read" kullan +serverLogs: Sunucu günlüğü +abuseReports: Raporlar +reportAbuse: Rapor +verificationEmailSent: Bir doğrulama maili gönderildi. Doğrulamayı tamamlamak için + lütfen verilen bağlantıyı takip edin. +hashtags: Etiketler +resolved: Çözüldü +flagShowTimelineRepliesDescription: Açıksa, kullanıcıların zaman çizelgesindeki diğer + kullanıcıların gönderilerine verdiği yanıtları gösterir. +clearQueueConfirmText: Kuyrukta kalan teslim edilmemiş gönderiler birleştirilmeyecektir. + Genellikle bu işleme gerek yoktur. +image: Resim +video: Video +showMore: Daha Fazla +showLess: Kapat +selectAntenna: Anten seç +selectWidget: Araç seç +unsuspendConfirm: Bu hesabın askıya almasını kaldırmak istediğinize emin misiniz? +selectList: Liste seç +editWidgets: Araçları düzenle +showEmojisInReactionNotifications: Tepki bildirimlerinde emojileri göster +renoteMute: Yükseltmeleri sustur +renoteUnmute: Yükseltmeleri susturmayı kaldır +loginFailed: Giriş yapılamadı +proxyAccount: Vekil Hesap +selectUser: Kullanıcı seç +recipient: Alıcı(lar) +annotation: Yorumlar +federation: Federasyon +registeredAt: Kayıtlı +latestRequestSentAt: Gönderilen son istek +latestRequestReceivedAt: Alınan son istek +latestStatus: Son durum +storageUsage: Depolama kullanımı +charts: Grafikler +perHour: Saat Başı +perDay: Gün Başı +stopActivityDelivery: Etkinlik göndermeyi durdur +blockThisInstance: Bu sunucuyu engelle +themeForDarkMode: Karanlık modda kullanmak için temalar +fileName: Dosya adı +selectFile: Dosya seç +emptyDrive: Drive'n boş +promote: Terfi +numberOfDays: Gün sayısı +hideThisNote: Bu gönderiyi gizle +file: Dosya +enableEmojiReactions: Emoji tepkilerini aç +cw: İçerik uyarısı +makeFollowManuallyApprove: Takip istekleri onay gerektirir +today: Bugün +enableRecommendedTimeline: Tavsiye edilen zaman çizgisini aktive et +state: Durum +sort: Sırala +script: Skript +keepCw: İçerik uyarılarını sakla +manageAccounts: Hesapları Düzenle +makeReactionsPublicDescription: Bu, tüm geçmiş tepkilerinizin listesini herkesin görebileceği + bir hale getirecektir. +unmuteThread: İleti dizisinin sesini aç +ffVisibility: Takipler/Takipçiler Görünürlüğü +reflectMayTakeTime: Bunun yansıması biraz zaman alabilir. +cropImageAsk: Bu resmi kırpmak istediğinize emin misiniz? +check: Kontrol Et +driveCapOverrideLabel: Bu kullanıcı için drive kapasitesini değiştirin +numberOfPageCache: Önbelleğe alınan sayfa sayısı +license: Lisans +indexFrom: Post ID'den itibaren dizin +xl: XL +notificationSetting: Bildirim ayarları +fillAbuseReportDescription: Lütfen bu raporla ilgili ayrıntıları doldurun. Belirli + bir gönderiyle ilgiliyse, lütfen URL'sini ekleyin. +forwardReportIsAnonymous: Uzak sunucuda, hesabınız yerine raportör olarak anonim bir + sistem hesabı görüntülenecektir. +abuseMarkAsResolved: Raporu çözüldü olarak işaretle +instanceTicker: Göndeirlerdeki sunucu bilgisi +waitingFor: '{x} bekleniyor' +random: Rastgele +public: Herkese açık +i18nInfo: Iceshrimp, gönüllüler tarafından çeşitli dillere çevriliyor. {link} adresinden + yardımcı olabilirsiniz. +disableShowingAnimatedImages: Animasyonlu görüntüleri oynatma +clips: Ataçlar +experimentalFeatures: Deneysel özellikler +developer: Geliştirici +left: Sol +center: Orta +wide: Geniş +narrow: Dar +reloadToApplySetting: Bu ayar yalnızca bir sayfa yeniden yüklendikten sonra geçerli + olacaktır. Şimdi yeniden yüklensin mi? +editCode: Kodu düzenle +apply: Uygula +receiveAnnouncementFromInstance: Bu sunucudan bildirimleri al +publish: Paylaş +quitFullView: Tam görünümden çık +addDescription: Açıklama ekle +info: Hakkında +noMaintainerInformationWarning: Yönetici bilgileri yapılandırılmadı. +noBotProtectionWarning: Bot koruması yapılandırılmamış. +postToGallery: Yeni galeri gönderisi oluştur +gallery: Galeri +privateModeInfo: Etkinleştirildiğinde, yalnızca beyaz listedeki sunucular sunucunuzla + birleşebilir. Tüm gönderiler halktan gizlenecektir. +itsOff: Etkinsizleştirilmiş +ffVisibilityDescription: Kimleri takip ettiğinizi ve kimlerin sizi takip ettiğini + kimlerin görebileceğini yapılandırmanıza izin verir. +themeColor: Sunucu Kayan Yazı Rengi +audio: Ses +recentNHours: Son {n} saat +isSystemAccount: Bu hesap sistem tarafından oluşturulur ve otomatik olarak işletilir. + Lütfen bu hesabı denetlemeyin, düzenlemeyin, silmeyin veya başka bir şekilde kurcalamayın, + aksi takdirde sunucunuz bozulabilir. +typeToConfirm: Lütfen onaylamak için {x} girin +remoteOnly: Sadece uzak +failedToUpload: Yükleme başarısız +cannotUploadBecauseInappropriate: Bu dosya, bazı bölümleri potansiyel olarak NSFW + olarak algılandığından yüklenemedi. +cannotUploadBecauseNoFreeSpace: Drive kapasitesi yetersiz olduğundan yükleme başarısız + oldu. +cannotUploadBecauseExceedsFileSizeLimit: Bu dosya, izin verilen maksimum boyutu aştığı + için yüklenemedi. +beta: Beta +enableAutoSensitive: Otomatik NSFW İşaretleme +enableAutoSensitiveDescription: Mümkün olduğunda Makine Öğrenimi yoluyla NSFW ortamının + otomatik olarak algılanmasına ve işaretlenmesine olanak tanır. Bu seçenek devre + dışı bırakılsa bile, sunucu çapında etkinleştirilebilir. +shuffle: Karıştır +pushNotification: Push bildirimleri +unsubscribePushNotification: Push bildirimlerini kapat +pushNotificationNotSupported: Tarayıcınız veya sunucunuz push bildirimleri desteklemiyor +caption: Otomatik Başlık +moveToLabel: 'Taşıyacağın hesap:' +moveFromDescription: Bu, eski hesabınızın bir takma adını belirleyecek ve böylece + o hesaptan bu mevcut hesaba geçebileceksiniz. Bunu eski hesabınızdan taşınmadan + ÖNCE yapın. Lütfen @person@server.com şeklinde biçimlendirilmiş hesabın etiketini + girin +migrationConfirm: "Hesabınızı {account} hesabına taşımak istediğinizden kesinlikle + emin misiniz? Bunu yaptığınızda, geri alamazsınız ve hesabınızı bir daha normal + şekilde kullanamazsınız.\nAyrıca, lütfen bu cari hesabı, taşındığınız hesap olarak + ayarladığınızdan emin olun." +indexFromDescription: Her gönderiyi dizine eklemek için boş bırakın +indexNotice: Şimdi indeksleniyor. Bu muhtemelen biraz zaman alacaktır, lütfen sunucunuzu + en az bir saat yeniden başlatmayın. +customKaTeXMacro: Özel KaTeX makroları +directNotes: Direkt Mesajlar +import: İçeri Aktar +export: Dışarı Aktar +mentions: Bahsetmeler +files: Dosyalar +driveFileDeleteConfirm: '"{name}" dosyasını silmek istediğinizden emin misiniz? Ek + olarak içeren tüm gönderilerden kaldırılacaktır.' +createList: Liste oluştur +listsDesc: Listeler, belirtilen kullanıcılarla zaman çizelgesi oluşturmanıza olanak + tanır. Zaman Çizelgesi sayfasından erişilebilirler. +note: Gönder +enterListName: Liste için isim gir +unfollow: Takipten Çık +privacy: Gizlilik +followRequestPending: Takip isteği bekleniyor +enterEmoji: Bir emoji gir +followRequest: Takip İsteği +followRequests: Takip istekleri +renoted: Yükseldi. +emoji: Emoji +cacheRemoteFiles: Uzak dosyaları önbellekle +flagAsBot: Bu hesabı robot olarak işaretle +flagAsBotDescription: Bu hesap bir program tarafından kontrol ediliyorsa bu seçeneği + etkinleştirin. Etkinleştirilirse, diğer geliştiricilerin diğer botlarla sonsuz etkileşim + zincirlerini önlemesi ve Iceshrimp'nin dahili sistemlerini bu hesabı bir bot olarak + ele alacak şekilde ayarlaması için bir bayrak görevi görür. +clearQueue: Sırayı Temizle +hiddenTags: Gizlenmiş Etiketler +done: Tamamlandı +processing: İşleniyor +silenced: Susturulmuş +darkThemes: Karanlık temalar +suspended: Askıya Alınmış +keepOriginalUploadingDescription: Orijinal olarak yüklenen görüntüyü olduğu gibi kaydeder. + Kapatılırsa, yükleme sırasında web'de görüntülenecek bir sürüm oluşturulur. +start: Başla +home: Ev +location: Konum +registeredDate: Katılım tarihi +yearX: '{year}' +pages: Sayfalar +integration: Entegrasyonlar +antennasDesc: "Antenler, belirlediğiniz kriterlere uyan yeni gönderiler görüntüler!\n + Zaman çizelgeleri sayfasından erişilebilirler." +notesAndReplies: Gönderiler ve yanıtlar +withReplies: Yanıtları da içer +connectedTo: Aşağıdaki hesap(lar) bağlı +silenceConfirm: Bu kullanıcıyı susturmak istediğinize emin misiniz? +messagingWithUser: Özel sohbet +resetPassword: Şifreyi sıfırla +registerSecurityKey: Yeni security key tanımla +docSource: Bu dökümanın kaynağı +ascendingOrder: Artan +tags: Etiketler +descendingOrder: Azalan +scratchpadDescription: Karalama defteri, AiScript deneyleri için bir ortam sağlar. + İçinde Iceshrimp ile etkileşime girerek sonuçlarını yazabilir, çalıştırabilir ve kontrol + edebilirsiniz. +local: Yerel +remote: Uzak +addRelay: Röle Ekle +accessibility: Erişilebilirlik +showFeaturedNotesInTimeline: Önerilen gönderileri zaman çizelgesinde göster +objectStorageBaseUrlDesc: "Referans olarak kullanılan URL. İkisinden birini kullanıyorsanız, + CDN veya Proxy'nizin URL'sini belirtin.\nS3 için 'https://.s3.amazonaws.com' + kullanın ve GCS veya eşdeğer hizmetler için 'https://storage.googleapis.com/' + vb. kullanın." +objectStoragePrefixDesc: Dosyalar bu prefix ile dizinler altında saklanacaktır. +objectStorageEndpointDesc: AWS S3 kullanıyorsanız bunu boş bırakın, aksi halde kullandığınız + hizmete bağlı olarak uç noktayı "" veya ":" olarak belirtin. +objectStorageRegion: Region +invisibleNote: Gizli Gönderi +deletedNote: Silinmiş Gönderi +visibility: Görünürlük +poll: Anket +themeEditor: Tema düzenleyicisi +enterFileDescription: Başlık gir +description: Açıklama +describeFile: Başlık ekle +system: Sistem +desktop: Masaüstü +confirmToUnclipAlreadyClippedNote: Bu gönderi zaten "{name}" atacının bir parçası. + Bunun yerine onu bu ataçtan kaldırmak istiyor musunuz? +sendErrorReportsDescription: "Açıldığında, bir sorun oluştuğunda ayrıntılı hata bilgileri + Iceshrimp ile paylaşılarak Iceshrimp kalitesinin artırılmasına yardımcı olur.\nBu, işletim + sisteminizin sürümü, kullandığınız tarayıcı, Iceshrimp'deki etkinliğiniz vb. bilgileri + içerecektir." +closeAccount: Hesabı kapat +markAllAsRead: Okunmuş olarak işaretle +allowedInstances: Beyaz Listedeki Sunucular +squareAvatars: Kare avatarları göster +unread: Okunmaımş +instanceDefaultDarkTheme: Sunucu genelinde varsayılan karanlık tema +oneDay: Bir gün +showAds: Reklamları göster +adminCustomCssWarn: Bu ayar yalnızca ne işe yaradığını biliyorsanız kullanılmalıdır. + Yanlış değerler girilmesi, HERKESİN istemcilerinin normal şekilde çalışmamasına + neden olabilir. Lütfen CSS'nizi kullanıcı ayarlarınızda test ederek düzgün çalıştığından + emin olun. +customSplashIcons: Özel açılış ekranı simgeleri (url'ler) +recommendedInstancesDescription: Önerilen zaman çizelgesinde görünmesi için satır + sonlarıyla ayrılmış önerilen sunucular. +recommendedInstances: Önerilen sunucular +enableServerMachineStats: Sunucu donanımı istatistiklerini etkinleştir +_sensitiveMediaDetection: + sensitivityDescription: Hassasiyetin düşürülmesi daha az yanlış tespite yol açarken, + hassasiyeti artırmak daha az tespitin gözden kaçmasına yol açacaktır. + setSensitiveFlagAutomaticallyDescription: Bu seçenek kapatılsa bile dahili algılamanın + sonuçları korunacaktır. + description: Makine Öğrenimi yoluyla NSFW ortamını otomatik olarak tanıyarak sunucu + denetleme çabasını azaltır. Bu, sunucudaki yükü biraz artıracaktır. + sensitivity: Algılama hassasiyeti + analyzeVideos: Videoların analizini etkinleştir + setSensitiveFlagAutomatically: NSFW olarak işaretle + analyzeVideosDescription: Görüntülere ek olarak videoları da analiz eder. Bu, sunucudaki + yükü biraz artıracaktır. +enableIdenticonGeneration: Kimlik oluşturmayı etkinleştir +reactionPickerSkinTone: Tercih edilen emoji cilt tonu +noteId: Gönderi ID +preventAiLearning: AI bot öğrenmesini önleyin +preventAiLearningDescription: Gönderiler ve resimler gibi yüklediğiniz içeriği incelememek + için üçüncü taraf yapay zeka dil modellerini isteyin. +isAdmin: Yönetici +_emailUnavailable: + disposable: Tek kullanımlık mail adresleri kullanılamaz + smtp: Bu mail sunucusu cevap vermiyor + mx: Bu mail sunucusu hatalı + used: Bu mail zaten kullanılıyor + format: Bu mail adresi yanlış +apps: Uygulamalar +findOtherInstance: Başka bir sunucu bul +showWithSparkles: Parıltılarla göster +showPopup: Kullanıcıları pop-up ile bilgilendirin +silencedWarning: Bu sayfa, bu kullanıcılar yöneticinizin susturduğu sunuculardan olduğu + için gösteriliyor, bu nedenle potansiyel olarak spam olabilirler. +isPatron: Iceshrimp Patronu +youHaveUnreadAnnouncements: Okunmamış duyurularınız var +donationLink: Bağış sayfası linki +neverShow: Birdaha gösterme +remindMeLater: Belki sonra +removeQuote: Alıntıyı sil +removeRecipient: Alıcıyı sil +removeMember: Kullanıcıyı sil +customKaTeXMacroDescription: 'Kolayca matematiksel ifadeler yazmak için makrolar kurun! + Gösterim, LaTeX komut tanımlarına uygundur ve \newcommand{\ name}{content} veya + \newcommand{\name}[argüman sayısı]{content} şeklinde yazılır. Örneğin, \newcommand{\add}[2]{#1 + + #2}, \add{3}{foo} öğesini 3 + foo olarak genişletir. Makro adını çevreleyen süslü + parantezler, yuvarlak veya köşeli parantezler olarak değiştirilebilir. Bu, bağımsız + değişkenler için kullanılan parantezleri etkiler. Satır başına bir (ve yalnızca + bir) makro tanımlanabilir ve satırı tanımın ortasından ayıramazsınız. Geçersiz satırlar + basitçe yoksayılır. Yalnızca basit dizi değiştirme işlevleri desteklenir; koşullu + dallanma gibi gelişmiş söz dizimi burada kullanılamaz.' +enableCustomKaTeXMacro: Özel KaTeX makrolarını aktif et +isLocked: Bu hesabın takip onayları var +isModerator: Moderatör +signupsDisabled: Bu sunucudaki kayıtlar şu anda devre dışı, ancak istediğiniz zaman + başka bir sunucuya kaydolabilirsiniz! Bu sunucu için bir davet kodunuz varsa, lütfen + aşağıya girin. +sendModMail: Moderasyon Bildirimi Gönder +noGraze: Iceshrimp ile çakıştığı için lütfen "Graze for Mastodon" tarayıcı uzantısını + devre dışı bırakın. +isBot: Bu hesap bir bottur +_2fa: + renewTOTPOk: Yeniden Yapılandır + registerTOTP: Doğrulayıcı uygulamasını kaydedin + renewTOTPCancel: İptal Et + renewTOTPConfirm: Bu, önceki uygulamanızdaki doğrulama kodlarının çalışmamasına + neden olur + alreadyRegistered: Zaten bir 2 faktörlü kimlik doğrulama cihazını kaydettiniz. + chromePasskeyNotSupported: Chrome geçiş anahtarları şu anda desteklenmemektedir. + registerSecurityKey: Bir güvenlik veya geçiş anahtarı kaydedin + securityKeyName: Bir anahtar adı girin + removeKey: Güvenlik anahtarını kaldır + removeKeyConfirm: '{name} anahtarı gerçekten silinsin mi?' + renewTOTP: Kimlik doğrulayıcı uygulamasını yeniden yapılandırın + token: 2FA Tokeni + step1: Öncelikle, cihazınıza bir kimlik doğrulama uygulaması ({a} veya {b} gibi) + yükleyin. + step2Click: Bu QR koduna tıklamak, 2FA'yı güvenlik anahtarınıza veya telefon kimlik + doğrulayıcı uygulamanıza kaydetmenize olanak tanır. + step3Title: Bir kimlik doğrulama kodu girin + securityKeyNotSupported: Tarayıcınız güvenlik anahtarlarını desteklemiyor. + step2: Ardından, bu ekranda görüntülenen QR kodunu tarayın. + step2Url: "Bir masaüstü programı kullanıyorsanız bu URL'yi de girebilirsiniz:" + step3: Kurulumu tamamlamak için uygulamanız tarafından sağlanan tokeni girin. + step4: Şu andan itibaren, gelecekteki herhangi bir oturum açma denemesi böyle bir + oturum açma tokeni isteyecektir. + securityKeyInfo: Parmak izi veya PIN kimlik doğrulamasının yanı sıra, hesabınızın + güvenliğini daha da artırmak için FIDO2'yi destekleyen donanım güvenlik anahtarları + aracılığıyla kimlik doğrulama ayarlayabilirsiniz. + tapSecurityKey: Güvenlik veya geçiş anahtarını kaydetmek için lütfen tarayıcınızı + takip edin + registerTOTPBeforeKey: Bir güvenlik veya geçiş anahtarı kaydetmek için lütfen bir + kimlik doğrulama uygulaması kurun. + whyTOTPOnlyRenew: Kimlik doğrulayıcı uygulaması, bir güvenlik anahtarı kaydedildiği + sürece kaldırılamaz. +_poll: + voted: Oylandı + deadlineTime: Zaman + remainingDays: '{d} gün {h} saat kaldı' + remainingHours: '{h} saat {m} dakika kaldı' + remainingSeconds: '{s} saniye kaldı' + remainingMinutes: '{m} dakika {s} saniye kaldı' + noOnlyOneChoice: En az 2 seçenek gerekiyor + noMore: Daha fazla seçenek ekleyemezsin + at: Bitiş... + deadlineDate: Bitiş tarihi + duration: Süre + votesCount: '{n} oy' + expiration: Anketi bitir + totalVotes: toplam {n} oy + closed: Bitti + infinite: Asla + vote: Oyla + showResult: Sonuçları görüntüle + after: "'den sonra bitiş..." + choiceN: Seçenek {n} + canMultipleVote: Birden fazla seçime izin ver +_theme: + code: Tema kodu + description: Açıklama + builtinThemes: Yerleşik temalar + color: Renk + keys: + fgHighlighted: Vurgulanan Metin + infoWarnFg: Uyarı metni + mention: Bahsetme + mentionMe: Bahsetme (Kendim) + buttonBg: Düğme arka planı + buttonHoverBg: Düğme arka planı (Üstüne Gelince) + shadow: Gölge + navBg: Kenar çubuğu arka planı + accent: Vurgu + fg: Yazı + dateLabelFg: Tarih etiketi metni + navActive: Kenar çubuğu metni (Etkin) + wallpaperOverlay: Arkaplan arayüzü + messageBg: Sohbet arkaplanı + focus: Fokus + accentLighten: Vurgu (Aydınlık) + bg: Arkaplan + indicator: Gösterge + hashtag: Etiket + renote: Yükseltme + modalBg: Modal arka plan + divider: Bölücü + scrollbarHandle: Kaydırma çubuğu kolu + scrollbarHandleHover: Kaydırma çubuğu tutacağı (Üzerine Gelince) + infoBg: Bilgi geçmişi + accentDarken: Vurgu (Karanlık) + header: Başlık + navFg: Kenar çubuğu metni + navIndicator: Kenar çubuğu göstergesi + link: Link + infoFg: Bilgi metni + infoWarnBg: Uyarı arka planı + badge: Rozen + panel: Panel + navHoverFg: Kenar çubuğu metni (Üzerine Gelince) + cwBg: CW düğmesi arka planı + cwFg: CW düğmesi meni + cwHoverBg: CW düğmesi arka planı (Üzerine Gelince) + toastFg: Bildirim metni + inputBorder: Giriş alanı sınırı + listItemHoverBg: Liste öğesi arka planı (Üstüne Gelince) + toastBg: Bildirim arka planı + driveFolderBg: Drive klasörü arkaplanı + funcKind: Fonksiyon tipi + argument: Argüman + lighten: Aydınlat + inputConstantName: Bu sabit için bir ad girin + deleteConstantConfirm: '{const} sabitini gerçekten silmek istiyor musunuz?' + explore: Temaları Keşfet + darken: Karart + base: Temel + manage: Temaları düzenle + installedThemes: Yüklenen temalar + invalid: Bu temanın biçimi geçersiz + make: Tema oluştur + key: Anahtar + alpha: Opaklık + install: Tema yükle + installed: '{name} başarıyla yüklendi' + alreadyInstalled: Bu tema zaten yüklendi + importInfo: Buraya tema kodunu girerseniz, onu tema düzenleyiciye aktarabilirsiniz + func: Fonksiyonlar + basedProp: Referenslanan özellik + constant: Sabit + defaultValue: Varsayılan değer + refConst: Bir sabiti referansla + refProp: Bir mülkü referansla + addConstant: Sabit ekle +_menuDisplay: + sideIcon: Yan (Simgeler) + sideFull: Yan + hide: Gizle + top: Üst +_filters: + fromUser: Kullanıcıdan + withFile: Dosya ile + notesBefore: Gönderiden önce + notesAfter: Gönderiden sonra + followingOnly: Sadece takip ettiklerim + fromDomain: Alan adı ile + followersOnly: Sadece takipçiler +_permissions: + "write:blocks": Engelli kullanıcıları düzenle + "read:drive": Drive dosya ve klasörlerine eriş + "read:favorites": Yer imlerini görüntüle + "write:mutes": Susturulmuş kullanıcıları düzenle + "read:notifications": Bildirimleri görüntüle + "write:notifications": Bildirimleri düzenle + "write:page-likes": Sayfalardaki beğenilerini düzenle + "read:user-groups": Kullanıcı gruplarını göster + "write:reactions": Tepkilerini düzenle + "read:pages": Sayfalarını göster + "write:channels": Kanallarını düzenle + "read:gallery": Galerini göster + "read:gallery-likes": Beğenilen galeri gönderilerini göster + "write:gallery-likes": Galeri gönderilerini düzenle + "write:messaging": Sohbet mesajı oluştur veya sil + "write:user-groups": Kullanıcı gruplarını düzenle veya sil + "read:messaging": Sohbetlerini görüntüle + "read:mutes": Susturulmuş kullanıcıları göster + "write:votes": Bir ankete oy ver + "read:page-likes": Beğenilen sayfalarını göster + "read:reactions": Tepkilerini göster + "read:channels": Kanallarını göster + "write:notes": Gönderi oluştur veya sil + "write:drive": Drive dosya ve klasörlerini düzenle + "write:favorites": Yer imlerini düzenle + "read:following": Kimleri takip ettiğini göster + "write:account": Hesap bilgisini düzenle + "read:account": Hesap bilgisini görüntüle + "read:blocks": Engelli kullanıcıları gör + "write:following": Hesapları takip et veya takipten çıkar + "write:pages": Sayfalarını düzenle veya sil + "write:gallery": Galerini düzenle +_auth: + pleaseGoBack: Lütfen uygulamaya geri dönün + callback: Uygulamaya geri dönülüyor + shareAccess: '"{name}" adlı kişinin bu hesaba erişmesine izin vermek ister misiniz?' + permissionAsk: 'Bu uygulama aşağıdaki izinleri ister:' + allPermissions: Tam hesap erişimi + denied: Erişim reddedildi + copyAsk: 'Lütfen aşağıdaki yetkilendirme kodunu uygulamaya yapıştırın:' + shareAccessAsk: Bu uygulamanın hesabınıza erişmesine izin vermek istediğinizden + emin misiniz? +_antennaSources: + users: Belirli kullanıcılardan gönderiler + homeTimeline: Takip edilen kullanıcılardan gönderiler + all: Tüm gönderiler + instances: Bir sunucudaki tüm kullanıcılardan gelen gönderiler + userList: Belirli bir kullanıcı listesinden gönderiler + userGroup: Belirli bir gruptaki kullanıcıların gönderileri +_charts: + usersIncDec: Kullanıcı sayısı farkı + usersTotal: Toplam kullanıcı sayısı + remoteNotesIncDec: Uzak gönderilerin sayısındaki fark + notesTotal: Toplam gönderi sayısı + filesTotal: Toplam dosya sayısı + apRequest: İstekler + storageUsageIncDec: Depolama kullanımındaki fark + localNotesIncDec: Yerel gönderilerin sayısındaki fark + storageUsageTotal: Toplam depolama kullanımı + federation: Federasyon + notesIncDec: Gönderi sayısındaki fark + activeUsers: Aktif kullanıcılar + filesIncDec: Dosya sayısındaki fark +_pages: + fontSerif: Serif + fontSansSerif: Sans Serif + chooseBlock: Bloğu sil + blocks: + _canvas: + id: Tuval ID + height: Yükseklik + width: Genişlik + _button: + _action: + resetRandom: Rastgele çekirdeği sıfırla + _pushEvent: + no-variable: Hiçbiri + event: Etkinlik ismi + message: Aktif olduğunda gösterilecek mesaj + variable: Gönderilecek değişken + callAiScript: AiScript'i çağırın + _callAiScript: + functionName: Fonksiyon ismi + dialog: Dialog göster + _dialog: + content: İçerik + pushEvent: Etkinlik gönder + text: Başlık + action: Düğmeye basıldığında olacaklar + colored: Renkli + text: Yazı + if: Eğer + _if: + variable: Değişken + canvas: Tuval + note: Gömülü yazı + _note: + id: Gönderi ID + idDescription: Alternatif olarak gönderi URL'sini buraya yapıştırabilirsiniz. + detailed: Detaylı görüntüleme + _counter: + text: Başlık + inc: Adım + name: Değer ismi + radioButton: Seçenek + _radioButton: + name: Değişken ismi + values: Seçenekleri satırlarla ayırın + title: Başlık + default: Varsayılan değer + _post: + text: İçerik + attachCanvasImage: Tuval resmi ekle + canvasId: Tuval ID + _textInput: + text: Başlık + name: Değişken ismi + default: Varsayılan değer + _numberInput: + name: Değiken ismi + text: Başlık + default: Varsayılan değer + _textareaInput: + text: Başlık + name: Değişken ismi + default: Varsayılan değer + textarea: Yazı alanı + _switch: + name: Değişken ismi + default: Varsayılan değer + text: Başlık + counter: Sayaç + switch: Değiştir + post: Gönderi formu + image: Resimler + section: Bölüm + textareaInput: Çok satırlı yazı girişi + button: Düğme + textInput: Yazı girişi + numberInput: Sayısal giriş + script: + categories: + text: Yazı işlemleri + flow: Akış kontrolü + random: Rastgele + fn: Fonksiyonlar + convert: Dönüşümler + list: Listeler + logical: Mantıksal işlem + operation: Hesaplama + comparison: Karşılaştırma + value: Değerler + blocks: + and: A ve B + _or: + arg2: B + arg1: A + _lt: + arg1: A + arg2: B + _ltEq: + arg1: A + arg2: B + textList: Yazı listesi + strReverse: Yazıyı çevir + multiply: Çarp + subtract: Çıkar + _mod: + arg1: A + arg2: B + _divide: + arg2: B + arg1: A + round: Ondalık yuvarlama + _round: + arg1: Sayı + _eq: + arg1: A + arg2: B + notEq: A ve B farklıysa + _notEq: + arg1: A + arg2: B + or: A veya B + gt: "> A, B'den çoksa" + ltEq: <= A, B'den az veya eşitse + gtEq: ">= A, B'den çok veya eşitse" + _gtEq: + arg1: A + arg2: B + _not: + arg1: OLUMSUZ + random: Rastgele + randomPick: Listeden rastgele seç + seedRandom: Random (çekirdek ile) + _for: + arg1: Tekrarlama sayısı + arg2: Eylem + _seedRannum: + arg3: Maksimum değer + arg2: Minimum değer + arg1: Çekirdek + _strReplace: + arg3: ile değiştir + arg2: Değiştirilecek yazı + arg1: Yazı + _subtract: + arg2: B + arg1: A + mod: Kalan + _and: + arg1: A + arg2: B + _DRPWPM: + arg1: Yazı listesi + _fn: + slots-info: Her yuvayı bir satır sonu ile ayırın + arg1: Çıkış + slots: Yuvalar + for: dögü + dailyRandomPick: Listeden rastgele seçim yapın (Her kullanıcı için günde bir + kez değişir) + _dailyRannum: + arg1: Minimum değer + arg2: Maksimum değer + _seedRandomPick: + arg2: Liste + arg1: Çekirdek + _pick: + arg2: Pozisyon + arg1: Liste + number: Sayı + _if: + arg3: Yoksa + arg1: Eğer + arg2: Sonra + _rannum: + arg1: Minimum değer + arg2: Maksimum değer + eq: A ve B eşitse + _gt: + arg1: A + arg2: B + rannum: Rastgele sayı + _randomPick: + arg1: Liste + pick: Listeden seç + _listLen: + arg1: Liste + _multiply: + arg2: B + arg1: A + divide: Böl + strPick: Dize ayıklayın + _strPick: + arg1: Yazı + arg2: Dize konunumu + dailyRandom: Rastgele (Her kullanıcı için günde bir kez değişir) + _dailyRandom: + arg1: Olasılık + dailyRannum: Rastgele sayı (Her kullanıcı için günde bir kez değişir) + _stringToNumber: + arg1: Yazı + if: Şube + strReplace: Yedek dize + text: Yazı + _splitStrByLine: + arg1: Yazı + not: OLUMSUZ + _seedRandom: + arg1: Çekirdek + arg2: Olasılık + seedRandomPick: Listeden rastgele seçim yapın (çekirdek ile) + fn: Fonksiyon + multiLineText: Yazı (çok satırlı) + _textList: + info: Her girişi satırlar ile ayırın + _strReverse: + arg1: Yazı + join: Yazıyı birleştirme + _join: + arg1: Listeler + arg2: Ayraç + add: Ekle + _add: + arg1: A + arg2: B + _strLen: + arg1: Yazu + aiScriptVar: AiScript Değişkeni + ref: Değişken + splitStrByLine: Yazıyı satır sonlarına göre bölme + strLen: Yazı uzunluğu + lt: < A, B'den azsa + _random: + arg1: Olasılık + DRPWPM: Ağırlıklı listeden rastgele seçim yapın (Her kullanıcı için günde bir + kez değişir) + listLen: Listenin uzunluğunu al + numberToString: Sayıdan yazıya + _dailyRandomPick: + arg1: Liste + stringToNumber: Yazıdan Sayıya + seedRannum: Rastgele sayı (çekirdek ile) + _numberToString: + arg1: Sayı + types: + number: Sayı + boolean: Etiket + array: Liste + stringArray: Yazı listesi + string: Yazı + emptySlot: Boş yuva + enviromentVariables: Ortam değikenleri + argVariables: Giriş yuvaları + thereIsEmptySlot: Yuva {slot} boş! + typeError: Yuva {slot}, "{expect}" türündeki değerleri kabul eder, ancak sağlanan + değer "{actual}" türündedir! + pageVariables: Sayfa değişkenleri + readPage: Bu sayfanın kaynağını görüntüle + created: Sayfa başarıyla oluşturuldu + eyeCatchingImageRemove: Afişi sil + selectType: Tip seç + pageSetting: Sayfa ayarları + viewSource: Kaynağı görüntüle + variables: Değişkenler + url: Sayfa URL'si + unlike: Beğeniyi kaldır + my: Sayfalarım + content: Sayfa bloğu + deleted: Sayfa başarıyla silindi + newPage: Yeni sayfa oluştur + editPage: Bu sayfayı düzenle + viewPage: Sayfalarını görüntüle + like: Beğen + nameAlreadyExists: Belirtilen Sayfa URL'si zaten var + invalidNameTitle: Belirtilen Sayfa URL'si geçersiz + invalidNameText: Sayfa başlığının boş olmadığından emin olun + editThisPage: Sayfayı düzenle + featured: Popüler + inspector: Denetçi + contents: İçerik + title: Başlık + liked: Beğenilen Sayfalar + font: Yazı Tipi + alignCenter: İçerikleri ortala + eyeCatchingImageSet: Afiş ayarla + enterVariableName: Değişken ismi ekle + hideTitleWhenPinned: Profile sabitlendiğinde Sayfa başlığını gizle + variableNameIsAlreadyUsed: Bu değişken adı zaten kullanımda + contentBlocks: İçerik + inputBlocks: Giriş + specialBlocks: Özel + updated: Sayfa başarıyla düzenlendi + summary: Sayfa özeti +_notification: + _types: + follow: Yeni takipçiler + mention: Bahsetmeler + app: Bağlı uygulamalardan bildirimler + pollEnded: Biten anket + receiveFollowRequest: Takip istekleri alındı + reaction: Tepkiler + all: Hepsi + followRequestAccepted: Takip istekleri kabul edildi + pollVote: Anket oylamaları + renote: Yükseltmeler + reply: Yanıtlar + groupInvited: Grup davetleri + quote: Alıntılar + pollEnded: Anket sonuçları açıklandı + fileUploaded: Dosya başarıyla yüklendi + youRenoted: '{name} tarafından yükseltildin' + _actions: + followBack: Seni geri takip etti + reply: Yanıtla + renote: Yükseltmeler + youGotMention: '{name} senden bahsetti' + youWereFollowed: seni takip etti + youGotMessagingMessageFromGroup: '{name} grubuna bir sohbet mesajı gönderildi' + renoted: gönderini yükseltti + youGotQuote: '{name} seni alıntıladı' + youGotReply: '{name} seni yanıtladı' + reacted: gönderine tepki ekledi + yourFollowRequestAccepted: Takip isteğin kabul edildi + emptyPushNotificationMessage: Push bildirimleri güncellendi + youWereInvitedToGroup: '{userName} seni gruba davet etti' + voted: anketine oy verdi + youReceivedFollowRequest: Bir takip isteği geldi + youGotPoll: '{name} anketinde oylama yaptı' + youGotMessagingMessageFromUser: '{name} sana bir sohbet mesajı gönderdi' +_dialog: + charactersExceeded: 'Maksimum karakter aşıldı! Geçerli: {current}/Sınır: {max}' + charactersBelow: 'Yeterli karakter yok! Geçerli: {current}/Sınır: {min}' +_signup: + emailSent: Mail adresinize ({email}) bir onay maili gönderildi. Hesap oluşturmayı + tamamlamak için lütfen verilen bağlantıya tıklayın. + almostThere: Neredeyse vardık + emailAddressInfo: Lütfen mail adresinizi giriniz. Herkese açık gözükmeyecektir. +_ad: + back: Geri + reduceFrequencyOfThisAd: Daha az reklam göster +_accountDelete: + accountDelete: Hesabı sil + mayTakeTime: Hesap silme, kaynak yoğun bir işlem olduğundan, ne kadar içerik oluşturduğunuza + ve ne kadar dosya yüklediğinize bağlı olarak tamamlanması biraz zaman alabilir. + sendEmail: Hesap silme işlemi tamamlandıktan sonra, bu hesapta kayıtlı olan mail + adresine bir mail gönderilecektir. + started: Silme işlemi başlatıldı. + requestAccountDelete: Hesap silme talebinde bulun + inProgress: Silme işlemi şu anda devam ediyor +_forgotPassword: + enterEmail: Kaydolmak için kullandığınız mail adresini girin. Parolanızı sıfırlayabileceğiniz + bir bağlantı daha sonra ona gönderilecektir. + contactAdmin: Bu sunucu, mail adreslerinin kullanılmasını desteklemiyor, bunun yerine + şifrenizi sıfırlamak için lütfen sunucu yöneticisiyle iletişime geçin. + ifNoEmail: Kayıt sırasında bir mail kullanmadıysanız, sunucu yöneticisiyle iletişime + geçin. +_gallery: + my: Galerim + liked: Beğenilen Gönderiler + like: Beğen + unlike: Beğeniyi kaldır +_registry: + key: Anahtar + scope: Kapsam + keys: Anahtarlar + createKey: Anahtar oluştur + domain: Alan adı +_email: + _follow: + title: Yeni bir takipçin var + _receiveFollowRequest: + title: Yeni bir takip isteğin var +_preferencesBackups: + apply: Bu cihaza uygula + invalidFile: Geçersiz dosya formatı + applyConfirm: '"{name}" yedeğini bu cihaza gerçekten uygulamak istiyor musunuz? + Bu cihazın mevcut ayarlarının üzerine yazılacak.' + inputName: Lütfen bu yedekleme için bir ad girin + cannotSave: Kaydedilemedi + saveConfirm: Yedekleme {name} olarak kaydedilsin mi? + renameConfirm: '"{old}" olan bu yedeğin adı "{new}" olarak değiştirilsin mi?' + createdAt: 'Oluşturma tarihi: {date} {time}' + save: Değişiklikleri Kaydet + nameAlreadyExists: '"{name}" adlı bir yedek zaten var. Lütfen farklı bir ad girin.' + deleteConfirm: '{name} yedeği silinsin mi?' + noBackups: Yedekleme yok. "Yeni yedekleme oluştur" seçeneğini kullanarak bu sunucudaki + istemci ayarlarınızı yedekleyebilirsiniz. + list: Oluşturulan yedekler + saveNew: Yeni bir yedek oluştur + loadFile: Dosyadan yükle + updatedAt: 'Güncelleme tarihi: {date} {time}' + cannotLoad: Yüklenemedi +_aboutIceshrimp: + patronsList: Bağış büyüklüğüne göre değil, kronolojik olarak listelenmiştir. Adınızı + buraya almak için yukarıdaki bağlantıyla bağış yapın! + about: Iceshrimp, 2022'den beri geliştirilmekte olan ThatOneCalculator tarafından + yapılan bir Iceshrimp çatalıdır. + allContributors: Tüm katkıda bulunanlar + patrons: Iceshrimp patronları + morePatrons: Burada listelenmeyen diğer birçok yardımcının desteğini de takdir ediyoruz. + Teşekkür ederim! 🥰 + donate: Iceshrimp'e bağışta bulunun + contributors: Ana katkıda bulunanlar + source: Kaynak Kodu + translation: Iceshrimp'i tercüme et + donateTitle: Iceshrimp'den hoşlanıyor musunuz? + pleaseDonateToIceshrimp: Lütfen gelişimini desteklemek için Iceshrimp'e bağış yapmayı + düşünün. + pleaseDonateToHost: İşletme maliyetlerini desteklemek için lütfen ev sunucunuz {host}'a + bağış yapmayı da düşünün. + donateHost: '{ev sahibi} için bağış yapın' + sponsors: Iceshrimp sponsorları +_weekday: + saturday: Cumartesi + sunday: Pazar + wednesday: Çarşamba + friday: Cuma + thursday: Perşembe + monday: Pazartesi + tuesday: Salı +_serverDisconnectedBehavior: + reload: Otomatik olarak yenile + quiet: Göze çarpmayan uyarı göster + nothing: Hiçbir şey yapma + dialog: Uyarı mesajını göster +_channel: + removeBanner: Afişi sil + owned: Sahip Olunan + nameOnly: Sadece isim + featured: Popüler + setBanner: Afiş ayarla + usersCount: '{n} Katılımcı' + create: Kanal oluştur + following: Takip + notesCount: '{n} Gönderi' + nameAndDescription: İsim ve açıklama + edit: Kanalı düzenle +_messaging: + groups: Gruplar + dms: Özel +_tutorial: + step5_5: Sosyal {icon} zaman çizelgesi, Ev ve Yerel zaman çizelgelerinin bir kombinasyonudur. + step5_6: Önerilen {icon} zaman çizelgesi, yöneticilerin önerdiği sunuculardan gelen + gönderileri görebileceğiniz yerdir. + step6_1: Peki burası neresi? + title: Iceshrimp nasıl kullanılır + step3_2: "Ev ve sosyal zaman çizelgeleriniz, kimi takip ettiğinize bağlıdır, bu + nedenle başlamak için birkaç hesabı takip etmeyi deneyin.\nTakip etmek için bir + profilin sağ üstündeki artı dairesine tıklayın." + step5_3: Ana Sayfa {icon} zaman çizelgesi, takip ettiğiniz hesaplardan gelen gönderileri + görebileceğiniz yerdir. + step5_4: Yerel {icon} zaman çizelgesi, bu sunucudaki diğer herkesin gönderilerini + görebileceğiniz yerdir. + step6_2: Iceshrimp'e öylece katılmadın. Binlerce sunucudan oluşan birbirine bağlı + bir ağ olan Fediverse'e giden bir portala katıldınız. + step6_4: Şimdi gidin, keşfedin ve eğlenin! + step5_7: Global {icon} zaman çizelgesi, bağlı diğer tüm sunuculardan gelen gönderileri + görebileceğiniz yerdir. + step2_1: Öncelikle lütfen profilinizi doldurunuz. + step2_2: Kim olduğunuz hakkında biraz bilgi vermeniz, başkalarının gönderilerinizi + görmek mi yoksa sizi takip etmek mi istediklerini anlamalarını kolaylaştıracaktır. + step3_1: Şimdi birkaç kullanıcı takip etme zamanı! + step1_1: Hoşgeldin! + step1_2: Hadi seni hazırlayalım. Kısa sürede kullanmaya başlayacaksınız! + step5_1: Zaman çizelgeleri, her yerde zaman çizelgeleri! + step6_3: Her sunucu farklı şekillerde çalışır ve tüm sunucular Iceshrimp'i çalıştırmaz. + Ama bu sunucu kullanıyor! Biraz karışık ama kısa sürede anlayacaksın. + step4_1: Seni oradan çıkaralım. + step5_2: Sunucunuzda etkinleştirilmiş {timelines} farklı zaman çizelgesi var. + step4_2: İlk gönderiniz için, bazı insanlar bir {introduction} gönderisi veya basit + bir "Merhaba dünya!" gönderir +_visibility: + public: Herkese açık + publicDescription: Gönderiniz herkese açık tüm zaman çizelgelerinde görünür olacak + specified: Direkt + followersDescription: Yalnızca takipçilerinize ve adı geçen kullanıcılara görünür + kılın + localOnlyDescription: Uzak kullanıcılara gözükmez + home: Listelenmemiş + homeDescription: Yalnızca ev zaman çizelgesine yayınla + followers: Takipçiler + specifiedDescription: Belirli kullanıcılara özel yapın + localOnly: Sadece yerel +_postForm: + quotePlaceholder: Bu gönderiyi alıntıla... + _placeholders: + a: Ne ile meşgulsün? + b: Etrafında neler oluyor? + f: Yazman bekleniyor... + c: Aklınızdan ne geçiyor? + d: Ne demek istiyorsun? + e: Yazmaya başka... + replyPlaceholder: Bu gönderiyi yanıtla... + channelPlaceholder: Bir kanala gönder... +_exportOrImport: + allNotes: Tüm gönderiler + followingList: Takip edilen kullanıcılar + muteList: Susturulmuş kullanıcılar + excludeMutingUsers: Susturulmuş kullanıcıları hariç tut + excludeInactiveUsers: Aktif olmayan kullanıcıları hariç tut + userLists: Kullanıcı listeleri + blockingList: Engellenimş kullanıcılar +_instanceCharts: + notes: Gönderi sayısındaki fark + notesTotal: Toplu gönderi sayısı + files: Dosya sayısındaki fark + filesTotal: Toplu dosya sayısı + requests: İstekler + usersTotal: Toplu kullanıcı sayısı + users: Kullanıcı sayısı farkı + cacheSize: Önbellek boyutundaki fark + ff: 'Takip edilen / Takipçi sayısındaki fark ' + cacheSizeTotal: Toplam önbellek boyutu + ffTotal: Toplu Takip edilen / Takipçi sayısı +_wordMute: + soft: Yumuşak + muteWords: Susturulmuş kelimeler + muteWordsDescription: AND koşulu için boşluklarla veya OR koşulu için satır sonlarıyla + ayırın. + softDescription: Belirlenen koşulları karşılayan gönderileri zaman çizelgesinden + gizleyin. + hardDescription: Belirlenen koşulları sağlayan gönderilerin zaman çizelgesineeklenmesini + engeller. Ayrıca bu gönderiler, koşullar değişse dahi zaman tüneline eklenmeyecektir. + mutedNotes: Susturulmuş gönderiler + hard: Sert + muteWordsDescription2: Normal ifadeleri kullanmak için anahtar kelimeleri eğik çizgilerle + çevreleyin. +_ago: + weeksAgo: "{n}hafta {n2}gün önce" + minutesAgo: "{n}dakika {n2}saniye önce" + daysAgo: "{n}gün {n2}saat önce" + future: Gelecek + justNow: Şimdi + secondsAgo: '{n}saniye önce' + hoursAgo: "{n}saat {n2}dakika önce" + monthsAgo: "{n}ay {n2}hafta önce" + yearsAgo: "{n}yıl {n2}ay önce" +_timelines: + home: Ev + local: Yerel + social: Sosyal + global: Global + recommended: Tavsiye Edilen +_nsfw: + respect: NSFW medyasını gizle + force: Tüm medyayı gizle + ignore: NSFW medyasını gizleme +_cw: + files: '{count} dosya(lar)' + chars: '{count} harf' + hide: Gizle + show: İçeriği göster +_relayStatus: + rejected: Reddedildi + accepted: Kabul edildi + requesting: Bekleniyor +_time: + day: Gün(ler) + hour: Saat(ler) + second: Saniye(ler) + minute: Dakika(lar) +_skinTones: + light: Aydınlık + medium: Orta + mediumLight: Orta Aydınlık + dark: Karanlık + yellow: Sarı + mediumDark: Orta Karanlık +_plugin: + install: Eklenti indir + installWarn: Lütfen güvenli olmayan eklentiler kurmayınız. + manage: Eklentileri yönet +_instanceTicker: + remote: Uzak kullanıcılar için göster + always: Her zaman göster + none: Asla gösterme +_instanceMute: + heading: Sessize alınacak sunucuların listesi + instanceMuteDescription2: Yeni satırlarla ayırın + title: Listelenen sunuculardan gönderileri gizler. + instanceMuteDescription: Bu, sessize alınmış bir sunucudan bir kullanıcıya yanıt + veren kullanıcılarınkiler de dahil olmak üzere, listelenen sunuculardan gelen + tüm gönderileri/yükseltmeleri sessize alacaktır. +_ffVisibility: + followers: Takipçilere açık + private: Gizli + public: Herkese açık diff --git a/locales/ug-CN.yml b/locales/ug-CN.yml new file mode 100644 index 0000000..a750454 --- /dev/null +++ b/locales/ug-CN.yml @@ -0,0 +1,6 @@ +--- +_lang_: "ياپونچە" +search: "ئىزدەش" +searchByGoogle: "ئىزدەش" +_mfm: + search: "ئىزدەش" diff --git a/locales/uk-UA.yml b/locales/uk-UA.yml new file mode 100644 index 0000000..ea1d19b --- /dev/null +++ b/locales/uk-UA.yml @@ -0,0 +1,2198 @@ +_lang_: "Українська" +headlineIceshrimp: "Мережа об'єднана записами" +introIceshrimp: "Ласкаво просимо! Iceshrimp це децентралізована служба мікроблогів, + вільна назавжди 🚀" +monthAndDay: "{month}/{day}" +search: "Пошук" +notifications: "Сповіщення" +username: "Ім'я користувача" +password: "Пароль" +forgotPassword: "Я забув пароль" +fetchingAsApObject: "Отримуємо з федіверсу" +ok: "OK" +gotIt: "Зрозуміло!" +cancel: "Скасувати" +enterUsername: "Введіть ім'я користувача" +renotedBy: "Поширено {user}" +noNotes: "Немає записів" +noNotifications: "Немає сповіщень" +instance: "Сервер" +settings: "Налаштування" +basicSettings: "Основні налаштування" +otherSettings: "Інші налаштування" +openInWindow: "Відкрити у вікні" +profile: "Профіль" +timeline: "Стрічка" +noAccountDescription: "Цей користувач ще нічого не написав про себе." +login: "Увійти" +loggingIn: "Здійснюємо вхід" +logout: "Вийти" +signup: "Реєстрація" +uploading: "Завантаження…" +save: "Зберегти" +users: "Користувачі" +addUser: "Додати користувача" +favorite: "Обране" +favorites: "Обране" +unfavorite: "Видалити з обраного" +favorited: "Додано до вподобаних." +alreadyFavorited: "Вже додано до вподобаних." +cantFavorite: "Неможливо вподобати." +pin: "Закріпити" +unpin: "Відкріпити" +copyContent: "Скопіювати контент" +copyLink: "Скопіювати посилання" +delete: "Видалити" +deleteAndEdit: "Видалити й редагувати" +deleteAndEditConfirm: "Ви впевнені, що хочете видалити цей запис та відредагувати + його? Ви втратите всі реакції, поширення та відповіді на нього." +addToList: "Додати до списку" +sendMessage: "Надіслати повідомлення" +copyUsername: "Скопіювати ім’я користувача" +searchUser: "Пошук користувачів" +reply: "Відповісти" +loadMore: "Показати більше" +showMore: "Показати більше" +showLess: "Закрити" +youGotNewFollower: "Новий підписник" +receiveFollowRequest: "Отримано запит на підписку" +followRequestAccepted: "Підписка прийнята" +mention: "Згадка" +mentions: "Згадки" +directNotes: "Прямі повідомлення" +importAndExport: "Імпорт та експорт" +import: "Імпорт" +export: "Експорт" +files: "Файли" +download: "Завантажити" +driveFileDeleteConfirm: "Ви впевнені, що хочете видалити файл {name}? Його буде видалено + з усіх записів які містили його." +unfollowConfirm: "Ви впевнені, що хочете відписатися від {name}?" +exportRequested: "Експортування розпочато. Це може зайняти деякий час. Після завершення + експорту отриманий файл буде додано на диск." +importRequested: "Імпортування розпочато. Це може зайняти деякий час." +lists: "Списки" +noLists: "Немає списків" +note: "Запис" +notes: "Записи" +following: "Підписки" +followers: "Підписники" +followsYou: "Підписаний(-а) на вас" +createList: "Створити список" +manageLists: "Управління списками" +error: "Помилка" +somethingHappened: "Щось пішло не так" +retry: "Спробувати знову" +pageLoadError: "Помилка при завантаженні сторінки." +pageLoadErrorDescription: "Зазвичай це пов’язано з помилками мережі або кешем браузера. + Спробуйте очистити кеш або почекайте трохи й спробуйте ще раз." +serverIsDead: "Відповіді від сервера немає. Зачекайте деякий час і повторіть спробу." +youShouldUpgradeClient: "Перезавантажте та використовуйте нову версію клієнта, щоб + переглянути цю сторінку." +enterListName: "Введіть назву списку" +privacy: "Конфіденційність" +makeFollowManuallyApprove: "Підтверджувати підписників уручну" +defaultNoteVisibility: "Видимість за замовчуванням" +follow: "Підписатись" +followRequest: "Запит на підписку" +followRequests: "Запити на підписку" +unfollow: "Відписатись" +followRequestPending: "Очікуючі запити на підписку" +enterEmoji: "Введіть емодзі" +renote: "Поширити" +unrenote: "скасувати поширення" +renoted: "Поширено." +cantRenote: "Цей запис неможливо поширити." +cantReRenote: "Поширення неможливо поширити." +quote: "Цитата" +pinnedNote: "Закріплений запис" +pinned: "Закріпити" +you: "Ви" +clickToShow: "Натисніть для перегляду" +sensitive: "Не небезпечний вміст" +add: "Додати" +reaction: "Реакції" +reactionSetting: "Налаштування реакцій" +reactionSettingDescription2: "Перемістити щоб змінити порядок, Клацнути мишою щоб + видалити, Натиснути \"+\" щоб додати." +rememberNoteVisibility: "Пам’ятати параметри видимісті" +attachCancel: "Видалити вкладення" +markAsSensitive: "Позначити як не небезпечний вміст" +unmarkAsSensitive: "Зняти позначку \"Не небезпечний вміст\"" +enterFileName: "Введіть ім'я файлу" +mute: "Ігнорувати" +unmute: "Показувати" +block: "Заблокувати" +unblock: "Розблокувати" +suspend: "Призупинити" +unsuspend: "Відновити" +blockConfirm: "Ви впевнені, що хочете заблокувати цей акаунт?" +unblockConfirm: "Ви впевнені, що хочете розблокувати цей акаунт?" +suspendConfirm: "Ви впевнені, що хочете призупинити цей акаунт?" +unsuspendConfirm: "Ви впевнені, що хочете відновити цей акаунт?" +selectList: "Виберіть список" +selectAntenna: "Виберіть антену" +selectWidget: "Виберіть віджет" +editWidgets: "Редагувати віджети" +editWidgetsExit: "Готово" +customEmojis: "Кастомні емоджі" +emoji: "Емоджі" +emojis: "Емоджі" +emojiName: "Назва емоджі" +emojiUrl: "URL емодзі" +addEmoji: "Додати емодзі" +settingGuide: "Рекомендована конфігурація" +cacheRemoteFiles: "Кешувати дані з інших інстансів" +cacheRemoteFilesDescription: "Якщо кешування вимкнено, віддалені файли завантажуються + безпосередньо з віддаленого серверу. Це зменшує використання сховища, але збільшує + трафік, оскільки не генеруются ескізи." +flagAsBot: "Акаунт бота 🤖" +flagAsBotDescription: "Ввімкніть якщо цей обліковий запис використовується ботом. + Ця опція позначить обліковий запис як бота. Це потрібно щоб виключити безкінечну + інтеракцію між ботами а також відповідного підлаштування Iceshrimp." +flagAsCat: "Акаунт кота" +flagAsCatDescription: "Ввімкніть, щоб позначити, що обліковий запис є котиком, та + отримати котячі вуха!" +flagShowTimelineReplies: "Показувати відповіді на записи в стрічці" +flagShowTimelineRepliesDescription: "Показує відповіді користувачів на записи інших + користувачів у стрічці." +autoAcceptFollowed: "Автоматично приймати запити на підписку від користувачів, на + яких ви підписані" +addAccount: "Додати акаунт" +loginFailed: "Не вдалося увійти" +showOnRemote: "Переглянути в оригіналі" +general: "Загальні налаштування" +wallpaper: "Шпалери" +setWallpaper: "Встановити шпалери" +removeWallpaper: "Прибрати шпалери" +searchWith: "Пошук: {q}" +youHaveNoLists: "У вас немає списків" +followConfirm: "Підписатися на {name}?" +proxyAccount: "Обліковий запис проксі" +proxyAccountDescription: "Обліковий запис проксі – це обліковий запис, який діє як + віддалений підписник для користувачів за певних умов. Наприклад, коли користувач + додає віддаленого користувача до списку, активність віддаленого користувача не буде + доставлена на сервер, якщо жоден локальний користувач не стежить за цим користувачем, + то замість нього буде використовуватися обліковий запис проксі-сервера." +host: "Хост" +selectUser: "Виберіть користувача" +recipient: "Отримувач" +annotation: "Коментарі" +federation: "Федіверс" +instances: "Сервери" +registeredAt: "Приєднався(лась)" +latestRequestSentAt: "Останній запит надіслано" +latestRequestReceivedAt: "Останній запит прийнято" +latestStatus: "Останній статус" +storageUsage: "Використання простору" +charts: "Графіки" +perHour: "Щогодинно" +perDay: "Щоденно" +stopActivityDelivery: "Припинити розсилання активності" +blockThisInstance: "Заблокувати цей сервер" +operations: "Операції" +software: "Програмне забезпечення" +version: "Версія" +metadata: "Метадані" +monitor: "Монітор" +jobQueue: "Черга завдань" +cpuAndMemory: "ЦП та пам'ять" +network: "Мережа" +disk: "Диск" +instanceInfo: "Про цей сервер" +statistics: "Статистика" +clearQueue: "Очистити чергу" +clearQueueConfirmTitle: "Ви впевнені, що хочете очистити чергу?" +clearQueueConfirmText: "Будь-які невідправлені записи, що залишилися в черзі, не будуть + передані. Зазвичай ця операція НЕ потрібна." +clearCachedFiles: "Очистити кеш" +clearCachedFilesConfirm: "Ви впевнені, що хочете видалити всі кешовані файли?" +blockedInstances: "Заблоковані сервери" +blockedInstancesDescription: "Вкажіть сервери, які потрібно заблокувати. Перелічені + сервери більше не зможуть спілкуватися з цим сервером." +muteAndBlock: "Заглушення і блокування" +mutedUsers: "Заглушені користувачі" +blockedUsers: "Заблоковані користувачі" +noUsers: "Немає користувачів" +editProfile: "Редагувати обліковий запис" +noteDeleteConfirm: "Ви дійсно хочете видалити цей запис?" +pinLimitExceeded: "Ви не можете закріпити більше записів" +intro: "Встановлення Iceshrimp завершено! Будь ласка, створіть обліковий запис адміністратора." +done: "Готово" +processing: "Працюємо…" +preview: "Попередній перегляд" +default: "За умовчанням" +noCustomEmojis: "Немає нетипових емоджі" +noJobs: "Немає завдань" +federating: "Федерується" +blocked: "Заблоковано" +suspended: "Призупинено" +all: "Всі" +subscribing: "Підписка" +publishing: "Публікація" +notResponding: "Не відповідає" +instanceFollowing: "Підписка на сервер" +instanceFollowers: "Підписники серверу" +instanceUsers: "Користувачі цього серверу" +changePassword: "Змінити пароль" +security: "Безпека" +retypedNotMatch: "Введені дані не збігаються." +currentPassword: "Поточний пароль" +newPassword: "Новий пароль" +newPasswordRetype: "Новий пароль (повторно)" +attachFile: "Прикріпити файл" +more: "Бiльше" +featured: "Популярні" +usernameOrUserId: "Ім'я або ID користувача" +noSuchUser: "Користувача не знайдено" +lookup: "Пошук" +announcements: "Оголошення" +imageUrl: "Посилання на зображення" +remove: "Видалити" +removed: "Видалено" +removeAreYouSure: "Ви впевнені, що хочете прибрати \"{x}\"?" +deleteAreYouSure: "Ви впевнені, що хочете видалити \"{x}\"?" +resetAreYouSure: "Ви впевнені, що хочете скинути?" +saved: "Збережено" +messaging: "Чати" +upload: "Завантажити" +keepOriginalUploading: "Зберегти оригінальне зображення" +keepOriginalUploadingDescription: "Зберігає початково завантажене зображення як є. + Якщо вимкнено, версія для відображення в Інтернеті буде створена під час завантаження." +fromDrive: "З диска" +fromUrl: "З посилання" +uploadFromUrl: "Завантажити з посилання" +uploadFromUrlDescription: "Посилання на файл для завантаження" +uploadFromUrlRequested: "Завантаження розпочалось" +uploadFromUrlMayTakeTime: "Завантаження може зайняти деякий час." +explore: "Огляд" +messageRead: "Прочитано" +noMoreHistory: "Подальшої історії немає" +startMessaging: "Розпочати діалог" +nUsersRead: "Прочитали {n}" +agreeTo: "Я погоджуюсь з {0}" +tos: "Умови використання" +start: "Розпочати" +home: "Домівка" +remoteUserCaution: "Інформація може бути неповною, оскільки це віддалений користувач." +activity: "Активність" +images: "Зображення" +birthday: "День народження" +yearsOld: "{age} років" +registeredDate: "Приєднався(лась)" +location: "Локація" +theme: "Тема" +themeForLightMode: "Світла тема" +themeForDarkMode: "Темна тема" +light: "Світла" +dark: "Темна" +lightThemes: "Світлі теми" +darkThemes: "Темні теми" +syncDeviceDarkMode: "Синхронізувати темний режим із налаштуваннями вашого пристрою" +drive: "Диск" +fileName: "Ім'я файлу" +selectFile: "Вибрати файл" +selectFiles: "Вибрати файли" +selectFolder: "Вибрати теку" +selectFolders: "Вибрати теки" +renameFile: "Перейменувати файл" +folderName: "Ім'я теки" +createFolder: "Створити теку" +renameFolder: "Перейменувати теку" +deleteFolder: "Видалити теку" +addFile: "Додати файл" +emptyDrive: "Диск порожній" +emptyFolder: "Тека порожня" +unableToDelete: "Видалення неможливе" +inputNewFileName: "Введіть ім'я нового файлу" +inputNewDescription: "Введіть новий заголовок" +inputNewFolderName: "Введіть ім'я нової теки" +circularReferenceFolder: "Ви намагаєтесь перемістити папку в її підпапку." +hasChildFilesOrFolders: "Ця тека не порожня і не може бути видалена." +copyUrl: "Копіювати URL" +rename: "Перейменувати" +avatar: "Аватар" +banner: "Банер" +nsfw: "Чутливий вміст" +whenServerDisconnected: "Коли зв’язок із сервером втрачено" +disconnectedFromServer: "Зв’язок із сервером було перервано" +reload: "Оновити" +doNothing: "Нічого не робити" +reloadConfirm: "Перезавантажити стрічку?" +watch: "Стежити" +unwatch: "Не стежити" +accept: "Прийняти" +reject: "Відхилити" +normal: "Нормальний" +instanceName: "Назва серверу" +instanceDescription: "Опис серверу" +maintainerName: "Ім'я адміністратора" +maintainerEmail: "Email адміністратора" +tosUrl: "URL умов використання" +thisYear: "Рік" +thisMonth: "Місяць" +today: "День" +dayX: "{day}" +monthX: "{month}" +yearX: "{year}" +pages: "Сторінки" +integration: "Інтеграції" +connectService: "Під’єднати" +disconnectService: "Відключитися" +enableLocalTimeline: "Увімкнути локальну стрічку" +enableGlobalTimeline: "Увімкнути глобальну стрічку" +disablingTimelinesInfo: "Адміністратори та модератори завжди мають доступ до всіх + стрічок, навіть якщо вони вимкнуті." +registration: "Реєстрація" +enableRegistration: "Дозволити реєстрацію" +invite: "Запросити" +driveCapacityPerLocalAccount: "Об'єм диска на одного локального користувача" +driveCapacityPerRemoteAccount: "Об'єм диска на одного віддаленого користувача" +inMb: "В мегабайтах" +iconUrl: "URL аватара" +bannerUrl: "URL банера" +backgroundImageUrl: "URL-адреса фонового зображення" +basicInfo: "Основна інформація" +pinnedUsers: "Закріплені користувачі" +pinnedUsersDescription: "Впишіть в список користувачів, яких хочете закріпити на сторінці + \"Знайти\", ім'я в стовпчик." +pinnedPages: "Закріплені сторінки" +pinnedPagesDescription: "Введіть шляхи сторінок, які ви бажаєте закріпити на головній + сторінці цього сервера, розділені новими рядками." +pinnedClipId: "Ідентифікатор закріпленої замітки" +pinnedNotes: "Закріплений запис" +hcaptcha: "h-Капча" +enableHcaptcha: "Увімкнути hCaptcha" +hcaptchaSiteKey: "Ключ сайту" +hcaptchaSecretKey: "Секретний ключ" +recaptcha: "reCAPTCHA" +enableRecaptcha: "Увімкнути reCAPTCHA" +recaptchaSiteKey: "Ключ сайту" +recaptchaSecretKey: "Секретний ключ" +avoidMultiCaptchaConfirm: "Використання кількох систем Captcha може спричинити перешкоди + між ними. Бажаєте вимкнути інші активні системи Captcha? Якщо ви хочете, щоб вони + залишалися ввімкненими, натисніть «Скасувати»." +antennas: "Антени" +manageAntennas: "Налаштування антен" +name: "Ім'я" +antennaSource: "Джерело антени" +antennaKeywords: "Ключові слова антени" +antennaExcludeKeywords: "Винятки" +antennaKeywordsDescription: "Відокремте пробілами для умови \"І\" або перенесенням + до нового рядка для умови \"АБО\"." +notifyAntenna: "Сповіщати про нові записи" +withFileAntenna: "Тільки записи з вкладеними файлами" +enableServiceworker: "Ввімкнути ServiceWorker" +antennaUsersDescription: "Список імя користувачів в стопчик" +caseSensitive: "З урахуванням регістру" +withReplies: "Включаючи відповіді" +connectedTo: "Наступні акаунти під'єднані" +notesAndReplies: "Записи та відповіді" +withFiles: "З прикріпленими файлами" +silence: "Заглушити" +silenceConfirm: "Ви впевнені, що хочете заглушити цього користувача?" +unsilence: "Не глушити" +unsilenceConfirm: "Ви впевнені, що хочете скасувати глушіння цього користувача?" +popularUsers: "Популярні користувачі" +recentlyUpdatedUsers: "Нещодавно активні користувачі" +recentlyRegisteredUsers: "Нещодавно зареєстровані користувачі" +recentlyDiscoveredUsers: "Нещодавно знайдені користувачі" +exploreUsersCount: "{count} користувачів" +exploreFediverse: "Огляд федіверсу" +popularTags: "Популярні теги" +userList: "Списки" +about: "Інформація" +aboutIceshrimp: "Про Iceshrimp" +administrator: "Адмін" +token: "Токен" +twoStepAuthentication: "Двохфакторна аутентифікація" +moderator: "Модератор" +nUsersMentioned: "Згадали: {n}" +securityKey: "Ключ захисту" +securityKeyName: "Назва ключа" +registerSecurityKey: "Зареєструвати ключ захисту" +lastUsed: "Востаннє використано" +unregister: "Скасувати реєстрацію" +passwordLessLogin: "Налаштувати вхід без пароля" +resetPassword: "Скинути пароль" +newPasswordIs: "Новий пароль: {password}" +reduceUiAnimation: "Зменшити анімацію інтерфейсу" +share: "Поділитись" +notFound: "Не знайдено" +notFoundDescription: "Сторінка за вказаною адресою не знайдена." +uploadFolder: "Місце для завантаження за замовчуванням" +cacheClear: "Очистити кеш" +markAsReadAllNotifications: "Позначити всі сповіщення як прочитані" +markAsReadAllUnreadNotes: "Позначити всі записи як прочитані" +markAsReadAllTalkMessages: "Позначити всі повідомлення як прочитані" +help: "Допомога" +inputMessageHere: "Введіть повідомлення тут" +close: "Закрити" +group: "Група" +groups: "Групи" +createGroup: "Створити групу" +ownedGroups: "Власні групи" +joinedGroups: "Членство в групах" +invites: "Запросити" +groupName: "Назва групи" +members: "Учасники" +transfer: "Передача" +messagingWithUser: "Чат з користувачами" +messagingWithGroup: "Чат з групою" +title: "Тема" +text: "Текст" +enable: "Увімкнути" +next: "Далі" +retype: "Введіть ще раз" +noteOf: "Запис {user}" +inviteToGroup: "Запрошення до групи" +quoteAttached: "Цитата" +quoteQuestion: "Ви хочете додати цитату?" +noMessagesYet: "Ще немає повідомлень" +newMessageExists: "Є нові повідомлення" +onlyOneFileCanBeAttached: "До повідомлення можна вкласти лише один файл" +signinRequired: "Будь ласка, авторизуйтесь" +invitations: "Запрошення" +invitationCode: "Код запрошення" +checking: "Перевірка…" +available: "Доступно" +unavailable: "Недоступно" +usernameInvalidFormat: "Ви можете використовувати великі та малі літери, цифри та + підкреслення." +tooShort: "Занадто короткий" +tooLong: "Занадто довгий" +weakPassword: "Слабкий пароль" +normalPassword: "Достатній пароль" +strongPassword: "Міцний пароль" +passwordMatched: "Все вірно" +passwordNotMatched: "Паролі не співпадають" +signinWith: "Увійти за допомогою {x}" +signinFailed: "Не вдалося увійти. Введені ім’я користувача або пароль неправильнi." +tapSecurityKey: "Торкніться ключа безпеки" +or: "або" +language: "Мова" +uiLanguage: "Мова інтерфейсу" +groupInvited: "Запрошення до групи" +aboutX: "Про {x}" +useOsNativeEmojis: "Використовувати емодзі ОС" +disableDrawer: "Не використовувати висувні меню" +youHaveNoGroups: "Немає груп" +joinOrCreateGroup: "Отримуйте запрошення до груп або створюйте свої власні групи." +noHistory: "Історія порожня" +signinHistory: "Історія входів" +disableAnimatedMfm: "Відключити анімації MFM" +doing: "Виконується…" +category: "Категорія" +tags: "Теги" +docSource: "Джерело цього документа" +createAccount: "Створити акаунт" +existingAccount: "Існуючий обліковий запис" +regenerate: "Оновити" +fontSize: "Розмір шрифту" +noFollowRequests: "Немає запитів на підписку" +openImageInNewTab: "Відкривати зображення в новій вкладці" +dashboard: "Панель приладів" +local: "Локальні" +remote: "Віддалені" +total: "Всього" +weekOverWeekChanges: "Тиждень" +dayOverDayChanges: "Доба" +appearance: "Вигляд" +clientSettings: "Налаштування клієнта" +accountSettings: "Налаштування акаунта" +promotion: "Виділене" +promote: "Виділити" +numberOfDays: "Кількість днів" +hideThisNote: "Сховати цей запис" +showFeaturedNotesInTimeline: "Показувати популярні записи у стрічці" +objectStorage: "Сховище" +useObjectStorage: "Використовувати object storage" +objectStorageBaseUrl: "Базовий URL" +objectStorageBaseUrlDesc: "URL-адреса, що використовується як джерело. Вкажіть URL-адресу + вашого CDN або проксі-сервера, якщо ви їх використовуєте.\nДля S3 використовуйте + 'https://.s3.amazonaws.com', а для GCS або подібних сервісів - 'https://storage.googleapis.com/', + тощо." +objectStorageBucket: "Сховище (Bucket)" +objectStorageBucketDesc: "Будь ласка вкажіть назву відра в налаштованому сервісі." +objectStoragePrefix: "Prefix" +objectStoragePrefixDesc: "Файли будуть зберігатись у розташуванні з цим префіксом." +objectStorageEndpoint: "Кінцевий пункт" +objectStorageEndpointDesc: "Залиште пустим при використанні AWS S3. Інакше введіть + кінцевий пункт як '' або ':' слідуючи інструкціям сервісу, який + використовується." +objectStorageRegion: "Region" +objectStorageRegionDesc: "Введіть регіон у формі 'xx-east-1'. Залиште пустим, якщо + ваш сервіс не різниться відповідно до регіонів, або введіть 'us-east-1'." +objectStorageUseSSL: "Використовувати SSL" +objectStorageUseSSLDesc: "Вимкніть коли не використовується HTTPS для з'єднання API" +objectStorageUseProxy: "Використовувати Proxy" +objectStorageUseProxyDesc: "Вимкніть коли проксі не використовується для з'єднання + ObjectStorage" +objectStorageSetPublicRead: "Встановіть 'публічне читання' при завантаженні" +serverLogs: "Журнал сервера" +deleteAll: "Видалити все" +showFixedPostForm: "Показати форму запису над стрічкою новин" +newNoteRecived: "Є нові записи" +sounds: "Звуки" +listen: "Слухати" +none: "Відсутній" +showInPage: "Показати на сторінці" +popout: "Від'єднати" +volume: "Гучність" +masterVolume: "Загальна гучність" +details: "Детальніше" +chooseEmoji: "Виберіть емодзі" +unableToProcess: "Не вдається завершити операцію" +recentUsed: "Нещодавні" +install: "Встановити" +uninstall: "Видалити" +installedApps: "Встановлені аплікації" +nothing: "Тут нічого немає" +installedDate: "Дата встановлення" +lastUsedDate: "Дата використання" +state: "Стан" +sort: "Сортування" +ascendingOrder: "За зростанням" +descendingOrder: "За спаданням" +scratchpad: "Чернетка" +scratchpadDescription: "Scratchpad надає середовище для експериментів з AiScript. + Ви можете писати, виконувати його і тестувати взаємодію з Iceshrimp." +output: "Вихід" +script: "Скрипт" +disablePagesScript: "Вимкнути AiScript на Сторінках" +updateRemoteUser: "Оновити інформацію про віддаленого користувача" +deleteAllFiles: "Видалити всі файли" +deleteAllFilesConfirm: "Ви дійсно хочете видалити всі файли?" +removeAllFollowing: "Скасувати всі підписки" +removeAllFollowingDescription: "Скасувати підписку на всі акаунти з {host}. Будь ласка, + робіть це, якщо сервер більше не існує." +userSuspended: "Обліковий запис заблокований." +userSilenced: "Обліковий запис приглушено." +yourAccountSuspendedTitle: "Цей обліковий запис заблоковано" +yourAccountSuspendedDescription: "Цей обліковий запис було заблоковано через порушення + умов надання послуг сервера. Зв'яжіться з адміністратором, якщо ви хочете дізнатися + докладнішу причину. Будь ласка, не створюйте новий обліковий запис." +menu: "Меню" +divider: "Розділювач" +addItem: "Додати елемент" +relays: "Ретранслятори" +addRelay: "Додати ретранслятор" +inboxUrl: "URL адреса поштової скриньки" +addedRelays: "Додані ретранслятори" +serviceworkerInfo: "Повинен бути ввімкнений для push-сповіщень." +deletedNote: "Видалений запис" +invisibleNote: "Прихований запис" +enableInfiniteScroll: "Увімкнути нескінченну прокрутку" +visibility: "Видимість" +poll: "Опитування" +useCw: "Приховати вміст" +enablePlayer: "Відкрити відеоплеєр" +disablePlayer: "Закрити відеоплеєр" +expandTweet: "Розгорнути твіт" +themeEditor: "Редактор тем" +description: "Опис" +describeFile: "Додати підпис" +enterFileDescription: "Введіть підпис" +author: "Автор" +leaveConfirm: "Зміни не збережені. Ви дійсно хочете скасувати зміни?" +manage: "Управління" +plugins: "Плагіни" +deck: "Дек" +undeck: "Залишити Дек" +useBlurEffectForModal: "Ефект розмиття під модальними діалогами" +useFullReactionPicker: "Повнорозмірний селектор реакцій" +width: "Ширина" +height: "Висота" +large: "Крупний" +medium: "Середній" +small: "Маленький" +generateAccessToken: "Згенерувати токен доступу" +permission: "Права" +enableAll: "Увімкнути все" +disableAll: "Вимкнути все" +tokenRequested: "Надати доступ до акаунту" +pluginTokenRequestedDescription: "Цей плагін зможе використовувати дозволи які тут + вказані." +notificationType: "Тип сповіщення" +edit: "Редагувати" +emailServer: "Сервер електронної пошти" +enableEmail: "Увімкнути функцію доставки пошти" +emailConfigInfo: "Використовується для підтвердження електронної пошти під час реєстрації, + а також для відновлення паролю" +email: "E-mail" +emailAddress: "E-mail адреса" +smtpConfig: "Налаштування сервера SMTP" +smtpHost: "Хост" +smtpPort: "Порт" +smtpUser: "Ім'я користувача" +smtpPass: "Пароль" +emptyToDisableSmtpAuth: "Залиште назву користувача і пароль пустими для вимкнення + підтвердження SMTP" +smtpSecure: "Використовувати безумовне шифрування SSL/TLS для з'єднань SMTP" +smtpSecureInfo: "Вимкніть при використанні STARTTLS" +testEmail: "Тестовий email" +wordMute: "Блокування слів" +regexpError: "Помилка регулярного виразу" +regexpErrorDescription: "Сталася помилка в регулярному виразі в рядку {line} вашого + слова {tab} слова що ігноруються:" +instanceMute: "Приглушення серверів" +userSaysSomething: "{name} щось сказав(ла)" +makeActive: "Активувати" +display: "Відображення" +copy: "Скопіювати" +metrics: "Показники" +overview: "Огляд" +logs: "Журнал" +delayed: "Затримка" +database: "База даних" +channel: "Канали" +create: "Створити" +notificationSetting: "Параметри сповіщень" +notificationSettingDesc: "Оберіть типи сповіщень для відображення." +useGlobalSetting: "Застосувати глобальнi параметри" +useGlobalSettingDesc: "Якщо увімкнено, то будуть використовуватись налаштування повідомлень + облікового запису, інакше можливо налаштувати індивідуально." +other: "Інше" +regenerateLoginToken: "Оновити Login Token" +regenerateLoginTokenDescription: "Регенерувати внутрішній ключ використовуваний під + час входу. Зазвичай цього не потрібно робити. При регенерації всі пристрої вийдуть + з системи." +setMultipleBySeparatingWithSpace: "Можна вказати кілька значень, відділивши їх пробілом." +fileIdOrUrl: "Ідентифікатор файлу або посилання" +behavior: "Поведінка" +sample: "Приклад" +abuseReports: "Скарги" +reportAbuse: "Поскаржитись" +reportAbuseOf: "Поскаржитись на {name}" +fillAbuseReportDescription: "Будь ласка вкажіть подробиці скарги. Якщо скарга стосується + запису, вкажіть посилання на нього." +abuseReported: "Дякуємо. Ваш звіт було відправлено." +reporter: "Репортер" +reporteeOrigin: "Про кого повідомлено" +reporterOrigin: "Хто повідомив" +forwardReport: "Переслати звіт на віддалений сервер" +forwardReportIsAnonymous: "Замість вашого облікового запису, анонімний системний обліковий + запис буде відображатися як доповідач на віддаленому сервері." +send: "Відправити" +abuseMarkAsResolved: "Позначити скаргу як вирішену" +openInNewTab: "Відкрити в новій вкладці" +openInSideView: "Відкрити збоку" +defaultNavigationBehaviour: "Поведінка навігації за замовчуванням" +editTheseSettingsMayBreakAccount: "Зміна цих параметрів може призвести до пошкодження + вашого акаунта." +instanceTicker: "Інформація про записи на сервері" +waitingFor: "Чекаємо на {x}" +random: "Випадковий" +system: "Система" +switchUi: "Змінити компоновку" +desktop: "Десктоп" +clip: "Підбірка" +createNew: "Створити новий" +optional: "Необов'язково" +createNewClip: "Створити підбірку" +public: "Публічний" +i18nInfo: "Iceshrimp перекладається на різні мови волонтерами. Ви можете допомогти + за посиланням: {link}." +manageAccessTokens: "Керування токенами доступу" +accountInfo: "Інформація про акаунт" +notesCount: "Кількість записів" +repliesCount: "Кількість надісланих відповідей" +renotesCount: "Кількість поширень" +repliedCount: "Кількість отриманих відповідей" +renotedCount: "Кількість отриманих поширень" +followingCount: "Кількість підписок" +followersCount: "Кількість підписників" +sentReactionsCount: "Кількість надісланих реакцій" +receivedReactionsCount: "Кількість отриманих реакцій" +pollVotesCount: "Кількість надісланих голосів" +pollVotedCount: "Кількість отриманих голосів" +yes: "Так" +no: "Ні" +driveFilesCount: "Кількість файлів на диску" +driveUsage: "Використання місця на диску" +noCrawle: "Заборонити індексацію" +noCrawleDescription: "Просити пошукові системи не індексувати ваш профіль, записи, + сторінки тощо." +lockedAccountInfo: "Якщо видимість вашого запису не встановлена як \"Тільки підписники\"\ + , то кожен зможе побачити ваш запис, навіть якщо ви вимагаєте підтвердження підписок + вручну." +alwaysMarkSensitive: "Позначати як \"Чутливий Зміст\" за замовчуванням" +loadRawImages: "Відображати вкладені зображення повністю замість ескізів" +disableShowingAnimatedImages: "Не програвати анімовані зображення" +verificationEmailSent: "Електронний лист з підтвердженням відісланий. Будь ласка перейдіть + по посиланню в листі для підтвердження." +notSet: "Не налаштовано" +emailVerified: "Електронну пошту підтверджено" +noteFavoritesCount: "Кількість улюблених записів" +pageLikesCount: "Кількість отриманих вподобань сторінки" +pageLikedCount: "Кількість вподобаних сторінок" +contact: "Контакт" +useSystemFont: "Використовувати стандартний шрифт системи" +clips: "Добірка" +experimentalFeatures: "Експериментальні функції" +developer: "Розробник" +makeExplorable: "Зробіть обліковий запис видимим у розділі \"Огляд\"" +makeExplorableDescription: "Вимкніть, щоб обліковий запис не показувався у розділі + \"Огляд\"." +showGapBetweenNotesInTimeline: "Показувати розрив між записами у стрічці новин" +duplicate: "Дублікат" +left: "Лівий" +center: "Центр" +wide: "Широкий" +narrow: "Вузький" +reloadToApplySetting: "Налаштування ввійде в дію при перезавантаженні. Перезавантажити?" +needReloadToApply: "Зміни набудуть чинності після перезавантаження сторінки." +showTitlebar: "Показати титульний рядок" +clearCache: "Очистити кеш" +onlineUsersCount: "{n} користувачів онлайн" +nUsers: "{n} Користувачів" +nNotes: "{n} Записів" +sendErrorReports: "Надіслати звіт про помилки" +sendErrorReportsDescription: "Якщо увімкнено, детальна інформація про помилки буде + передаватися до Iceshrimp, коли виникає проблема, це допоможе покращити якість роботи + Iceshrimp.\nЦе буде включати інформацію таку як: версія вашої ОС, який браузер ви + використовуєте, ваша активність в Iceshrimp тощо." +myTheme: "Моя тема" +backgroundColor: "Фон" +accentColor: "Акцент" +textColor: "Текст" +saveAs: "Зберегти як…" +advanced: "Розширені" +value: "Значення" +createdAt: "Створено" +updatedAt: "Останнє оновлення" +saveConfirm: "Зберегти зміни?" +deleteConfirm: "Ви дійсно бажаєте це видалити?" +invalidValue: "Некоректне значення." +registry: "Реєстр" +closeAccount: "Закрити обліковий запис" +currentVersion: "Версія, що використовується" +latestVersion: "Сама свіжа версія" +youAreRunningUpToDateClient: "У вас найсвіжіша версія клієнта." +newVersionOfClientAvailable: "Доступніша свіжа версія клієнта." +usageAmount: "Використане" +capacity: "Ємність" +inUse: "Зайнято" +editCode: "Редагувати вихідний текст" +apply: "Застосувати" +receiveAnnouncementFromInstance: "Отримувати сповіщення з серверу" +emailNotification: "Сповіщення електронною поштою" +publish: "Опублікувати" +inChannelSearch: "Пошук за каналом" +useReactionPickerForContextMenu: "Відкривати палітру реакцій правою кнопкою" +typingUsers: "{users} пише" +goBack: "Назад" +info: "Інформація" +user: "Користувач" +administration: "Управління" +expiration: "Опитування закінчується" +middle: "Середній" +global: "Глобальна" +sent: "Відправлене" +hashtags: "Хештеґ" +hide: "Сховати" +searchByGoogle: "Пошук" +indefinitely: "Ніколи" +file: "Файли" +reverse: "Переворот" +colored: "Кольоровий" +label: "Назва" +localOnly: "Локально" +_ffVisibility: + public: "Опублікувати" + private: Приватні + followers: Доступно тільки для підписників +_ad: + back: "Назад" + reduceFrequencyOfThisAd: Менше показувати цю рекламу +_gallery: + unlike: "Не вподобати" + liked: Вподобані записи + like: Подобається + my: Моя галерея +_email: + _follow: + title: "Новий підписник" + _receiveFollowRequest: + title: Ви отримали запит на підписку +_registry: + key: "Ключ" + keys: "Ключі" + domain: "Домен" + createKey: "Створити ключ" + scope: Область +_aboutIceshrimp: + about: "Iceshrimp - це програмне забезпечення з відкритим кодом, яке розробляє syuilo + з 2014 року." + contributors: "Головні помічники" + allContributors: "Всі помічники" + source: "Розробка Iceshrimp" + translation: "Переклади" + donate: "Пожертвувати Iceshrimp" + morePatrons: "Ми дуже цінуємо підтримку багатьох інших помічників, не перелічених + тут. Дякуємо! 🥰" + patrons: "Підтримали" + patronsList: Перераховані в хронологічному порядку, а не за розміром пожертви. Зробіть + внесок за посиланням вище, щоб ваше ім'я було тут! + donateTitle: Сподобався Iceshrimp? + pleaseDonateToIceshrimp: Будь ласка, підтримайте розробку Iceshrimp. + pleaseDonateToHost: Також не забудьте підтримати ваш домашній сервер {host}, щоб + допомогти з його операційними витратами. + donateHost: Зробити внесок на рахунок {host} + sponsors: Спонсори Iceshrimp + chatroom: Чат + documentation: Документація + roadmap: План розвитку + changelog: Журнал змін +_nsfw: + respect: "Приховувати медіа з чутливим вмістом" + ignore: "Не приховувати медіа з чутливим вмістом" + force: "Приховувати всі медіа файли" +_mfm: + cheatSheet: "Довідка MFM" + intro: "MFM це мова розмітки тексту, яка використовується в Iceshrimp, Misskey, + Akkoma, та ін. яку можна використовувати в дописах та чатах Тут ви можете переглянути + її синтаксис." + dummy: "Iceshrimp розширює світ Федіверсу" + mention: "Згадка" + mentionDescription: "За допомогою знака \"@\" перед ім'ям можна згадати конкретного + користувача." + hashtag: "Хештеґ" + hashtagDescription: "За допомогою знака \"решітка\" перед словом задається хештег." + url: "URL" + urlDescription: "Відображаються URL-адреси." + link: "Посилання" + linkDescription: "Окремі частини тексту можуть містити посилання." + bold: "Жирний шрифт" + boldDescription: "Виділяє літери, роблячи їх товщими." + small: "Дрібний шрифт" + smallDescription: "Робить текст маленьким і тонким." + center: "По центру" + centerDescription: "Показує вміст у центрі." + inlineCode: "Код (у рядку)" + inlineCodeDescription: "Відображає підсвічування синтаксису для коду (програми)." + blockCode: "Код (блок)" + blockCodeDescription: "Відображає підсвічування синтаксису для багаторядкового (програмного) + коду в блоці." + inlineMath: "Формула (у рядку)" + inlineMathDescription: "Відображення математичних формул (KaTeX) у рядку" + blockMath: "Формули (блок)" + blockMathDescription: "Відображати математичні формули (KaTeX) блоками" + quote: "Цитата" + quoteDescription: "Відображає зміст як цитату." + emoji: "Кастомні емоджі" + emojiDescription: "Щоб показати нетиповий емоджі, потрібно додати до та після нього + двокрапки." + search: "Пошук" + searchDescription: "Відображає вікно пошуку з попередньо введеним текстом." + flip: "Перевернути" + flipDescription: "Віддзеркалює вміст по горизонталі або вертикалі." + jelly: "Анімація (желе)" + jellyDescription: "Створює желеподібну анімацію." + tada: "Анімація (Тада!)" + tadaDescription: "Створює анімацію з відчуттям \"Тада!\"." + jump: "Анімація (стрибки)" + jumpDescription: "Надає вмісту стрибучу анімацію." + bounce: "Анімація (пружина)" + shake: "Анімація (Shake)" + twitch: "Анімація (Twitch)" + spin: "Анімація (Spin)" + x2: "Великий" + x2Description: "Показує контент збільшеним." + x3: "Дуже великий" + x3Description: "Показує контент ще більшим." + x4: "Надзвичайно великий" + x4Description: "Показує контент надзвичайно великим." + blur: "Розмиття" + blurDescription: "Цей ефект зробить контент розмитим. Контент можна зробити чітким, + якщо навести на нього вказівник миші." + font: "Шрифт" + fontDescription: "Встановлює шрифт для контенту." + rotate: "Обертати" + play: Відтворити MFM + alwaysPlay: Завжди автозапускати всі анімовані MFM + twitchDescription: Надає контенту анімацію, що сильно сіпається. + spinDescription: Надає контенту анімацію обертання. + sparkle: Блиск + sparkleDescription: Надає вмісту ефект мерехтливого блиску. + fade: Згасання + fadeDescription: Зменшує та збільшує видимість контенту. + crop: Обрізати + cropDescription: Обрізати вміст. + scale: Масштабувати + positionDescription: Перемістити вміст на вказане значення. + scaleDescription: Масштабувати вміст на вказану величину. + background: Фоновий колір + foreground: Колір переднього плану + foregroundDescription: Змінити колір тексту на передньому плані. + bounceDescription: Надає контенту пружної анімації. + shakeDescription: Надає контенту тремтливої анімації. + rainbowDescription: Робить вміст веселковим. + rotateDescription: Повертає вміст на вказаний кут. + advancedDescription: Якщо вимкнено, дозволяє лише базову розмітку, якщо не відтворюється + анімований MFM + plainDescription: Вимикає ефекти всіх MFM, що містяться в цьому MFM-ефекті. + stop: Зупинити MFM + plain: Звичайний текст + advanced: Розширені MFM + warn: MFM може містити швидко-рухому або яскраву анімацію + position: Розташування + rainbow: Веселка + backgroundDescription: Змінити колір фону тексту. +_instanceTicker: + none: "Не відображати" + remote: "Відображати для віддалених користувачів" + always: "Відображати завжди" +_serverDisconnectedBehavior: + reload: "Автоматично перезавантажити" + dialog: "Показати діалогове вікно" + quiet: "Показати ненав’язливе попередження" + nothing: Нічого не робити +_channel: + create: "Створити канал" + edit: "Редагувати канал" + setBanner: "Встановити банер" + removeBanner: "Видалити банер" + featured: "Тренди" + following: "Підписки" + usersCount: "{n} учасників" + notesCount: "{n} записів" + nameOnly: Тільки назва + nameAndDescription: Назва та опис + owned: Власні +_menuDisplay: + hide: "Сховати" + sideFull: Збоку + sideIcon: Збоку (тільки іконки) + top: Верх +_wordMute: + muteWords: "Заглушені слова" + muteWordsDescription: "Відокремліть ключові слова пробілами для умови \"І\" або + з нового рядку для умови \"АБО\"." + muteWordsDescription2: "Для використання RegEx, ключові слова потрібно вписати поміж + слешів \"/\"." + softDescription: "Приховати записи які відповідають критеріям зі стрічки." + hardDescription: "Приховати записи які відповідають критеріям зі стрічки подій. + Також приховані записи не будуть додані до стрічки навіть якщо критерії буде змінено." + soft: "М'яко" + hard: "Жорстко" + mutedNotes: "Ігноровані записи" +_theme: + explore: "Оглянути теми" + install: "Встановити тему" + manage: "Керування темами" + code: "Код теми" + description: "Опис" + installed: "Тему {name} встановлено" + installedThemes: "Встановлені теми" + builtinThemes: "Вбудоваі теми" + alreadyInstalled: "Тему вже встановлено" + invalid: "Неправильний формат теми" + make: "Створити тему" + base: "Основа" + defaultValue: "Значення за замовчуванням" + func: "Функції" + lighten: "Яскравість" + inputConstantName: "Введіть назву константи" + importInfo: "Вставляючи сюди код теми, ви можете добавити її до редактору тем" + deleteConstantConfirm: "Ви дійсно бажаєте видалити константу \"{const}\"?" + keys: + accent: "Акцент" + bg: "Фон" + fg: "Текст" + focus: "Фокус" + indicator: "Індикатор" + panel: "Панель" + shadow: "Тінь" + header: "Заголовок" + navBg: "Фон бокової панелі" + navFg: "Текст бокової панелі" + navHoverFg: "Текст бокової панелі (під курсором)" + navActive: "Текст бокової панелі (активне)" + navIndicator: "Індикатор бокової панелі" + link: "Посилання" + hashtag: "Хештеґ" + mention: "Згадка" + mentionMe: "Згадки (мене)" + renote: "Поширити" + modalBg: "Модальний фон" + divider: "Розділювач" + scrollbarHandle: "Ручка смуги прокрутки" + scrollbarHandleHover: "Ручка смуги прокрутки (при наведенні)" + dateLabelFg: "Текст позначок дати" + infoBg: "Фон інформації" + infoFg: "Текст інформації" + infoWarnBg: "Фон попередження" + infoWarnFg: "Текст попередження" + cwBg: "Фон чутливого змісту" + cwFg: "Текст чутливого змісту" + cwHoverBg: "Фон чутливого змісту (при наведенні)" + toastBg: "Фон повідомлення" + toastFg: "Текст повідомлення" + buttonBg: "Фон кнопки" + buttonHoverBg: "Фон кнопки (при наведенні)" + inputBorder: "Край поля вводу" + listItemHoverBg: "Фон елементу в списку (при наведенні)" + driveFolderBg: "Фон папки на диску" + wallpaperOverlay: "Накладання шпалер" + badge: "Значок" + messageBg: "Фон переписки" + accentDarken: "Акцент (Затемлений)" + accentLighten: "Акцент (Освітлений)" + fgHighlighted: "Виділений текст" + color: Колір + refProp: Посилання на властивість + alpha: Прозорість + constant: Стала + refConst: Посилання на сталу + key: Ключ + funcKind: Тип функції + darken: Затемнення + argument: Аргумент + basedProp: Початкова властивість + addConstant: Додати сталу +_sfx: + note: "Новий запис" + noteMy: "Мої записи" + notification: "Сповіщення" + chat: "Чати" + chatBg: "Чати (фон)" + antenna: "Прийом антени" + channel: "Повідомлення каналу" +_ago: + future: "Майбутнє" + justNow: "Щойно" + secondsAgo: "{n}с тому" + minutesAgo: "{n}хв {n2}с тому" + hoursAgo: "{n}г {n2}хв тому" + daysAgo: "{n}д {n2}г тому" + weeksAgo: "{n} тиж. {n2}д тому" + monthsAgo: "{n} міс {n2} тиж. тому" + yearsAgo: "{n} р {n2} міс. тому" +_time: + second: "Секунд(а)" + minute: "Хвилин(а)" + hour: "Годин(а)" + day: "Дня(днів)" +_tutorial: + title: "Як використовувати Iceshrimp" + step1_1: "Ласкаво просимо!" + step1_2: "Давайте налаштуємо вас. Ви будете працювати в найкоротші терміни!" + step2_1: "Спочатку, будь ласка, заповніть свій профіль." + step2_2: "Після надання інформації про себе, іншим людям буде легше зрозуміти, чи + хочуть вони бачити ваші записи або стежити за вами." + step3_1: "Тепер настав час на когось підписатися!" + step3_2: "Ваша домашня і соціальна стрічки ґрунтуються на тому, за ким ви стежите, + тому для початку спробуйте стежити за кількома акаунтами.\nНатисніть на гурток + із плюсом у правому верхньому кутку профілю, щоб стежити за ним." + step4_1: "Давайте вийдемо на вас." + step4_2: "Для свого першого повідомлення деякі люди люблять робити {introduction} + повідомлення або просте \"Hello world!\"" + step5_1: "Стрічки, скрізь одні стрічки!" + step5_2: "У вашому сервері включені {timelines} різні стрічки." + step5_3: "Головна {icon} стрічка - це стрічка, де ви можете бачити записи тих, на + кого ви підписалися." + step5_4: "Місцева {icon} стрічка - це стрічка, де ви можете бачити записи всіх інших + користувачів даного серверу." + step5_5: "Стрічка рекомендованих {icon} - це комбінація домашньої та місцевої стрічок." + step5_6: "На стрічці Рекомендованих {icon} ви можете бачити записи з серверів, які + рекомендують адміністратори." + step5_7: "Глобальна {icon} стрічка - це місце, де ви можете бачити записи від усіх + інших приєднаних серверів." + step6_1: "Отже, що це за місце?" + step6_2: "Ну, ви не просто приєдналися до Iceshrimp. Ви увійшли в Fediverse, взаємопов'язану + мережу з тисяч серверів." + step6_3: "Кожен сервер працює по-своєму, і не на всіх серверах працює Iceshrimp. + Але цей працює! Це трохи складно, але ви швидко розберетеся." + step6_4: "Тепер ідіть, вивчайте і розважайтеся!" +_2fa: + registerSecurityKey: "Зареєструвати новий ключ безпеки" + registerTOTP: Зареєструйте новий пристрій + tapSecurityKey: Будь ласка, дотримуйтесь інструкцій вашого браузера, щоб зареєструвати + апаратний ключ безпеки або ключ-пароль + securityKeyName: Введіть назву ключа + chromePasskeyNotSupported: Паролі Chrome наразі не підтримуються. + renewTOTPOk: Переналаштувати + removeKey: Видалити ключ безпеки + alreadyRegistered: 2FA вже налаштовано. + step2Click: Натиснувши на цей QR-код, ви зможете зареєструвати 2FA у вашому ключі + безпеки або додатку-автентифікаторі для телефону. + step3Title: Введіть код автентифікації + step1: По-перше, встановіть програму 2FA (наприклад, {a} або {b}) на свій пристрій. + securityKeyNotSupported: Ваш браузер не підтримує ключі безпеки. + step4: Відтепер при наступних спробах входу в систему буде запитуватися такий токен. + securityKeyInfo: Окрім автентифікації за відбитком пальця або PIN-кодом, ви також + можете налаштувати автентифікацію за допомогою апаратних ключів безпеки, які підтримують + FIDO2, щоб додатково захистити свій обліковий запис. + removeKeyConfirm: Дійсно видалити ключ {name}? + whyTOTPOnlyRenew: Додаток автентифікатора не можна видалити, доки зареєстровано + ключ безпеки. + renewTOTP: Переналаштувати додаток-автентифікатор + renewTOTPCancel: Скасувати + renewTOTPConfirm: Це призведе до того, що коди підтвердження з попереднього додатку + перестануть працювати + token: 2FA Токен + registerTOTPBeforeKey: Будь ласка, налаштуйте додаток-автентифікатор, щоб зареєструвати + ключ безпеки або пароль. + step2Url: 'Також, ви можете ввести цю URL-адресу, якщо використовуєте десктопну + програму:' + step3: Введіть токен, наданий вашим додатком, щоб завершити налаштування. + step2: Потім відскануйте QR-код, що відображається на цьому екрані. +_permissions: + "read:account": "Переглядати дані профілю" + "write:account": "Змінити дані акаунту" + "read:blocks": "Переглянути список заблокованих" + "write:blocks": "Редагувати список заблокованих" + "read:drive": "Переглянути вміст Диска" + "write:drive": "Змінювати вміст Диска" + "read:favorites": "Переглядати обране" + "write:favorites": "Змінювати обране" + "read:following": "Переглядати підписки" + "write:following": "Змінювати підписки" + "read:messaging": "Переглядати повідомлення" + "write:messaging": "Створювати та видаляти повідомлення" + "read:mutes": "Переглядати список ігнорованих" + "write:mutes": "Змінювати список ігнорованих" + "write:notes": "Створення та видалення записів" + "read:notifications": "Переглядати сповіщення" + "read:reactions": "Переглядати реакції" + "write:reactions": "Змінювати реакції" + "write:votes": "Голосувати в опитуваннях" + "read:pages": "Переглядати сторінки" + "write:pages": "Змінювати і видаляти сторінки" + "read:page-likes": "Переглядати вподобання сторінок" + "write:page-likes": "Змінювати вподобання сторінок" + "read:user-groups": "Переглядати групи користувача" + "write:user-groups": "Змінювати групи користувача" + "read:channels": "Переглядати канали" + "write:channels": "Змінювати канали" + "read:gallery": Переглянути галерею + "write:gallery": Редагування галереї + "read:gallery-likes": Переглянути список вподобаних записів галереї + "write:notifications": Керування сповіщеннями + "write:gallery-likes": Редагувати список вподобаних записів галереї +_auth: + shareAccess: "Ви хочете надати \"{name}\" доступ до цього акаунту?" + shareAccessAsk: "Ви впевнені, що хочете надати цій програмі доступ до вашого акаунту?" + denied: "У доступі відмовлено" + allPermissions: Повний доступ до облікового запису + permissionAsk: 'Цей додаток запитує наступні дозволи:' + copyAsk: 'Будь ласка, вставте наступний код авторизації в додаток:' + pleaseGoBack: Будь ласка, поверніться до додатку + callback: Повернення до додатку + signedInAs: Увійшов як + authRequired: Потрібна авторизація +_antennaSources: + all: "Усі записи" + homeTimeline: "Записи тих, на кого ви підписані" + instances: Записи від усіх користувачів на сервері + userGroup: Записи від користувачів у вказаній групі + users: Записи обраних користувачів + userList: Дописи користувачів із вказаного списку +_weekday: + sunday: "Неділя" + monday: "Понеділок" + tuesday: "Вівторок" + wednesday: "Середа" + thursday: "Четвер" + friday: "П'ятниця" + saturday: "Субота" +_widgets: + memo: "Нагадування" + notifications: "Сповіщення" + timeline: "Стрічка" + calendar: "Календар" + trends: "Тенденції" + clock: "Годинник" + rss: "RSS-читач" + activity: "Активність" + photos: "Фото" + digitalClock: "Цифровий годинник" + federation: "Федіверс" + postForm: "Створення запису" + slideshow: "Слайд-шоу" + button: "Кнопка" + onlineUsers: "Користувачі онлайн" + jobQueue: "Черга завдань" + serverMetric: "Показники сервера" + aiscript: "Консоль AiScript" + _userList: + chooseList: Оберіть список + meiliStatus: Стан сервера + meiliSize: Розмір індексу + rssTicker: RSS-тікер + unixClock: Годинник UNIX + userList: Список користувачів + serverInfo: Інформація про сервер + meiliIndexCount: Індексовані записи +_cw: + hide: "Сховати" + show: "Показати більше" + chars: "{count} символів" + files: "{count} файлів" +_poll: + noOnlyOneChoice: "Потрібні принаймні два варіанти" + choiceN: "Варіант {n}" + noMore: "Більше варіантів додати не можна" + canMultipleVote: "Можна вибрати кілька варіантів" + expiration: "Опитування закінчується" + infinite: "Ніколи" + at: "На даті…" + after: "Через…" + deadlineDate: "Дата закінчення" + deadlineTime: "г" + duration: "Тривалість" + votesCount: "{n} голосів" + totalVotes: "Всього {n} голосів" + vote: "Голосувати" + showResult: "Переглянути результати" + voted: "Проголосовано" + closed: "Завершено" + remainingDays: "Залишилось {d} днів {h} годин" + remainingHours: "Залишилось {h} годин {m} хвилин" + remainingMinutes: "Залишилось {m} хвилин {s} секунд" + remainingSeconds: "Залишилось {s} секунд" +_visibility: + public: "Публічний" + publicDescription: "Ваш запис буде видно в усіх публічних стрічках" + home: "Домашній" + homeDescription: "Лише на домашній стрічці" + followers: "Підписники" + followersDescription: "Зробити видимим тільки для ваших підписників і згаданих користувачів" + specified: "Особисто" + specifiedDescription: "Лише для певних користувачів" + localOnly: "Локально" + localOnlyDescription: "Приховано для віддалених користувачів" +_postForm: + replyPlaceholder: "Відповідь на цей допис…" + quotePlaceholder: "Прокоментуйте цей допис…" + channelPlaceholder: "Опублікувати у каналі…" + _placeholders: + a: "Чим займаєтесь?" + b: "Що відбувається навколо вас?" + c: "Що у вас на думці?" + d: "Що ви хочете висловити?" + e: "Напишіть тут, будь ласка…" + f: "Чекаю коли ви напишете…" +_profile: + name: "Ім'я" + username: "Ім'я користувача" + description: "Про себе" + youCanIncludeHashtags: "Ви також можете включити хештеги у свій опис." + metadata: "Додаткова інформація" + metadataEdit: "Редагувати додаткову інформацію" + metadataDescription: "Ви можете вказати до чотирьох пунктів додаткової інформації + у своєму профілі. Ви можете додати тег {a} або {l} за допомогою {rel}, щоб підтвердити + посилання у своєму профілі!" + metadataLabel: "Назва" + metadataContent: "Вміст" + changeAvatar: "Змінити аватар" + changeBanner: "Змінити банер" + locationDescription: Якщо ви спочатку введете своє місто, іншим користувачам буде + показано ваш місцевий час. +_exportOrImport: + allNotes: "Всі записи" + followingList: "Підписки" + muteList: "Ігнорувати" + blockingList: "Заблокувати" + userLists: "Списки" + excludeInactiveUsers: Вилучити неактивних користувачів + excludeMutingUsers: Вилучити заглушених користувачів +_charts: + federation: "Федіверс" + apRequest: "Запити" + usersTotal: "Загальна кількість користувачів" + activeUsers: "Активні користувачі" + notesTotal: "Загальна кількість записів" + filesIncDec: "Зміни кількості файлів" + filesTotal: "Загальна кількість файлів" + storageUsageIncDec: Різниця в використанні ємності диску + remoteNotesIncDec: Різниця в кількості віддалених записів + notesIncDec: Різниця в кількості записів + localNotesIncDec: Різниця в кількості локальних записів + storageUsageTotal: Загальне використання пам'яті + usersIncDec: Різниця в кількості користувачів +_instanceCharts: + requests: "Запити" + usersTotal: "Сумарна кількість користувачів" + notes: "Різниця в кількості зроблених записів" + notesTotal: "Сумарна кількість записів" + ff: "Різниця кількості підписників " + ffTotal: "Кількість підписників" + cacheSizeTotal: "Сумарний розмір кешу" + files: "Різниця в кількості файлів" + filesTotal: "Сумарна кількість файлів" + users: Різниця в кількості користувачів + cacheSize: Різниця в розмірі кешу +_timelines: + home: "Домівка" + local: "Локальна" + social: "Соціальна" + global: "Глобальна" + recommended: Рекомендована +_pages: + newPage: "Створити сторінку" + editPage: "Редагувати сторінку" + readPage: "Перегляд вихідного коду" + created: "Сторінка успішно створена" + updated: "Сторінка успішно оновлена" + deleted: "Сторінку видалено" + pageSetting: "Налаштування сторінки" + nameAlreadyExists: "Вказана адреса сторінки вже існує" + invalidNameTitle: "Вказана адреса сторінки неприпустима" + invalidNameText: "Переконайтеся, що поле заголовка сторінки не порожнє" + editThisPage: "Редагувати цю сторінку" + viewSource: "Переглянути вихідний код" + viewPage: "Переглянути свої сторінки" + like: "Вподобати" + unlike: "Не вподобати" + my: "Мої сторінки" + liked: "Вподобані сторінки" + featured: "Популярні" + inspector: "Інспектор" + contents: "Вміст" + content: "Блок сторінки" + variables: "Змінні" + title: "Заголовок" + url: "URL сторінки" + summary: "Короткий зміст" + alignCenter: "Рівняти елементи по центру" + hideTitleWhenPinned: "Приховати заголовок сторінки при закріпленні в профілі" + font: "Шрифт" + fontSerif: "шрифт Serif" + fontSansSerif: "Sans serif" + eyeCatchingImageSet: "Встановити привабливе зображення" + eyeCatchingImageRemove: "Видалити привабливе зображення" + chooseBlock: "Додати блок" + selectType: "Виберіть тип" + enterVariableName: "Введіть назву для змінної" + variableNameIsAlreadyUsed: "Ця назва вже використовується іншою змінною" + contentBlocks: "Контент" + inputBlocks: "Ввід" + specialBlocks: "Особливе" + blocks: + text: "Текст" + textarea: "Текстова область" + section: "Розділ" + image: "Зображення" + button: "Кнопка" + if: "Якщо" + _if: + variable: "Змінні" + post: "Створення нотатки" + _post: + text: "Вміст" + canvasId: "Ідентифікатор полотна" + attachCanvasImage: Прикріпити зображення полотна + textInput: "Введення тексту" + _textInput: + name: "Ім'я змінної" + text: "Назва" + default: "Значення за замовчуванням" + textareaInput: "Багаторядкове введення тексту" + _textareaInput: + name: "Ім'я змінної" + text: "Назва" + default: "Значення за замовчуванням" + numberInput: "Числове введення" + _numberInput: + name: "Ім'я змінної" + text: "Назва" + default: "Значення за замовчуванням" + canvas: "Полотно" + _canvas: + id: "Ідентифікатор полотна" + width: "Ширина" + height: "Висота" + note: "Вбудований запис" + _note: + id: "Ідентифікатор запису" + idDescription: "Також можна вказати посилання на запис." + detailed: "Детальний вигляд" + switch: "Перемикач" + _switch: + name: "Ім'я змінної" + text: "Назва" + default: "Значення за замовчуванням" + counter: "Лічильник" + _counter: + name: "Ім'я змінної" + text: "Назва" + inc: "Збільшити на" + _button: + text: "Напис" + colored: "Кольоровий" + action: "Дія кнопки" + _action: + dialog: "Показати повідомлення" + _dialog: + content: "Вміст" + resetRandom: "Скидання генератора випадковості" + pushEvent: "Надіслати подію" + _pushEvent: + event: "Назві події" + message: "Повідомлення для відображення при активації" + variable: "Змінна для надсилання" + no-variable: "Відсутньо" + callAiScript: "Виклик AiScript" + _callAiScript: + functionName: "Ім'я функції" + radioButton: "Вибір" + _radioButton: + name: "Ім'я змінної" + title: "Напис" + values: "Варіанти, розділені розривами рядків" + default: "Значення за замовчуванням" + script: + categories: + flow: "Керування потоком" + logical: "Логічні операції" + operation: "Обчислення" + comparison: "Порівняння" + random: "Випадковість" + value: "Значення" + fn: "Функції" + text: "Дії з текстом" + convert: "Перетворення" + list: "Списки" + blocks: + text: "Текст" + multiLineText: "Текст (багаторядковий)" + textList: "Текстовий список" + _textList: + info: "Використовувати новий рядок як роздільник для вводу" + strLen: "Довжина тексту" + _strLen: + arg1: "Текст" + strPick: "Вибрати символ" + _strPick: + arg1: "Текст" + arg2: "Розташування символу" + strReplace: "Заміна тексту" + _strReplace: + arg1: "Текст" + arg2: "Текст, який потрібно замінити" + arg3: "Заміняти на" + strReverse: "Перевернути текст" + _strReverse: + arg1: "Текст" + join: "Конкатенація тексту" + _join: + arg1: "Списки" + arg2: "Розділювач" + add: "Додати" + _add: + arg1: "A" + arg2: "B" + subtract: "Відняти" + _subtract: + arg1: "A" + arg2: "B" + multiply: "Помножити" + _multiply: + arg1: "A" + arg2: "B" + divide: "Поділити" + _divide: + arg1: "A" + arg2: "B" + mod: "Остача" + _mod: + arg1: "A" + arg2: "B" + round: "Десяткове округлення" + _round: + arg1: "Число" + eq: "A дорівнює B" + _eq: + arg1: "A" + arg2: "B" + notEq: "A не дорівнює B" + _notEq: + arg1: "A" + arg2: "B" + and: "А І Б" + _and: + arg1: "A" + arg2: "B" + or: "A АБО B" + _or: + arg1: "A" + arg2: "B" + lt: "< A менше, ніж B" + _lt: + arg1: "A" + arg2: "B" + gt: "> A більше, ніж B" + _gt: + arg1: "A" + arg2: "B" + ltEq: "<= A менше або дорівнює B" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: ">= A більше або дорівнює B" + _gtEq: + arg1: "A" + arg2: "B" + if: "Умова" + _if: + arg1: "Якщо" + arg2: "Якщо так" + arg3: "Якщо ні" + not: "НЕ" + _not: + arg1: "НЕ" + random: "Випадково" + _random: + arg1: "Імовірність" + rannum: "Випадкове число" + _rannum: + arg1: "Мінімальне значення" + arg2: "Максимальне значення" + randomPick: "Випадковий вибір зі списку" + _randomPick: + arg1: "Списки" + dailyRandom: "Випадково (триває добу)" + _dailyRandom: + arg1: "Імовірність" + dailyRannum: "Випадкове число (триває добу)" + _dailyRannum: + arg1: "Мінімальне значення" + arg2: "Максимальне значення" + dailyRandomPick: "Випадково вибрати зі списку (триває добу)" + _dailyRandomPick: + arg1: "Списки" + seedRandom: "Випадковість (з насінням)" + _seedRandom: + arg1: "Насіння" + arg2: "Імовірність" + seedRannum: "Випадкове число (з насінням)" + _seedRannum: + arg1: "Насіння" + arg2: "Мінімальне значення" + arg3: "Максимальне значення" + seedRandomPick: "Випадково вибрати зі списку (з насінням)" + _seedRandomPick: + arg1: "Насіння" + arg2: "Списки" + DRPWPM: "Випадково вибрати зі зваженого списку (триває добу)" + _DRPWPM: + arg1: "Текстовий список" + pick: "Вибір зі списку" + _pick: + arg1: "Списки" + arg2: "Позиція" + listLen: "Отримати довжину списку" + _listLen: + arg1: "Списки" + number: "Число" + stringToNumber: "Текст на число" + _stringToNumber: + arg1: "Текст" + numberToString: "Число на текст" + _numberToString: + arg1: "Число" + splitStrByLine: "Розбиття тексту на рядки" + _splitStrByLine: + arg1: "Текст" + ref: "Змінні" + aiScriptVar: "Змінна AiScript" + fn: "Функція" + _fn: + slots: "Паз" + slots-info: "Використовувати нову лінію як роздільник пазів" + arg1: "Вивід" + for: "Повторення" + _for: + arg1: "Кількість повторень" + arg2: "Дія" + typeError: "Паз {slot} приймає \"{expect}\" тип, але надана змінна має тип \"\ + {actual}\"!" + thereIsEmptySlot: "Паз {slot} пустий!" + types: + string: "Текст" + number: "Число" + boolean: "Прапорець" + array: "Списки" + stringArray: "Текстовий список" + emptySlot: "Пустий паз" + enviromentVariables: "Змінні середовища" + pageVariables: "Елемент сторінки" + argVariables: "Стрічка вводу" +_relayStatus: + requesting: "Очікує затвердження" + accepted: "Затверджено" + rejected: "Відхилено" +_notification: + fileUploaded: "Файл успішно завантажено" + youGotMention: "{name} згадує вас" + youGotReply: "{name} відповідає" + youGotQuote: "{name} цитує вас" + youRenoted: "{name} поширює" + youGotPoll: "{name} бере участь в опитуванні" + youGotMessagingMessageFromUser: "Повідомлення від {name}" + youGotMessagingMessageFromGroup: "Нове повідомлення в групі {name}" + youWereFollowed: "Новий підписник" + youReceivedFollowRequest: "Ви отримали запит на підписку" + yourFollowRequestAccepted: "Запит на підписку прийнято" + youWereInvitedToGroup: "Запрошення до групи" + _types: + all: "Все" + follow: "Підписки" + mention: "Згадка" + reply: "Відповіді" + renote: "Поширення" + quote: "Цитування" + reaction: "Реакції" + pollVote: "Опитування" + receiveFollowRequest: "Запити на підписку" + followRequestAccepted: "Прийняті підписки" + groupInvited: "Запрошення до груп" + app: "Сповіщення від додатків" + pollEnded: Опитування закінчено + _actions: + reply: "Відповісти" + renote: "Поширення" + followBack: також підписався на вас + emptyPushNotificationMessage: Push-сповіщення були оновлені + voted: проголосував на вашому опитуванні + renoted: поширив ваш запис + reacted: відреагував на ваш запис + pollEnded: Стали доступні результати опитування +_deck: + alwaysShowMainColumn: "Завжди показувати головну колонку" + columnAlign: "Вирівняти стовпці" + addColumn: "Додати стовпець" + swapLeft: "Пересунути ліворуч" + swapRight: "Пересунути праворуч" + swapUp: "Пересунути вгору" + swapDown: "Пересунути вниз" + stackLeft: "У стовпчик вліво" + popRight: "Витягнути вправо" + profile: "Простір" + _columns: + main: "Головна" + widgets: "Віджети" + notifications: "Сповіщення" + tl: "Стрічка" + antenna: "Антена" + list: "Списки" + mentions: "Згадки" + direct: "Особисті повідомлення" + channel: Канал + newProfile: Новий простір + introduction2: Натисніть на + у правій частині екрана, щоб додавати нові стовпці + по бажанню. + configureColumn: Налаштування стовпців + introduction: Створіть ідеальний інтерфейс для себе, вільно розташовуючи стовпці! + widgetsIntroduction: Будь ласка, виберіть "Редагувати віджети" в меню колонки і + додайте віджет. + renameProfile: Перейменувати простір + deleteProfile: Видалити простір + nameAlreadyExists: Простір із такою назвою вже існує. +removeReaction: Видалити вашу реакцію +renoteMute: Ігнорувати поширення +renoteUnmute: Показувати поширення +flagSpeakAsCat: Говорити як кішка +accessibility: Доступність +priority: Пріорітет +high: Високий +customCss: Користувацькі CSS +itsOn: Увімкнено +showingPastTimeline: Наразі відображається стара стрічка +enabled: Увімкнено +noMaintainerInformationWarning: Інформація про супровідника не налаштована. +recommended: Рекомендоване +resolved: Вирішено +itsOff: Вимкнено +emailRequiredForSignup: Вимагати адресу електронної пошти для реєстрації +moderation: Модерація +selectInstance: Оберіть сервер +instanceSecurity: Безпека сервера +searchPlaceholder: Шукати в Федиверс +editNote: Відредагувати запис +enableEmojiReactions: Ввімкнути реакції емодзі +low: Низький +emailNotConfiguredWarning: Адрес електронної пошти не встановлено. +unresolved: Не вирішено +offline: Не в мережі +disabled: Вимкнено +configure: Налаштувати +popularPosts: Популярні сторінки +silenced: Ігнорується +manageGroups: Керування групами +active: Активний +whatIsNew: Показати зміни +deleted: Видалено +selectChannel: Виберіть канал +flagSpeakAsCatDescription: Ваші записи будуть няніфіковані у режимі кота +userSaysSomethingReason: '{name} сказав(ла) {reason}' +clear: Очистити +userInfo: Інформація про користувача +selectAccount: Оберіть обліковий запис +switchAccount: Змінити обліковий запис +accounts: Облікові записи +switch: Змінити +noBotProtectionWarning: Захист від ботів не налаштовано. +gallery: Галерея +recentPosts: Недавні сторінки +privateModeInfo: Якщо увімкнено, лише сервери з білого списку можуть федеруватися + з вашим сервером. Всі повідомлення будуть приховані від публіки. +troubleshooting: Вирішення проблем +customCssWarn: Цей параметр слід використовувати лише тоді, коли ви знаєте, що він + робить. Введення неправильних значень може призвести до того, що клієнт перестане + нормально функціонувати. +newer: новіші +older: старіші +addDescription: Додати опис +notSpecifiedMentionWarning: У цьому записі згадуються користувачі, яких не було включено + до списку одержувачів +markAllAsRead: Позначити все як прочитане +userPagePinTip: Ви можете відображати записи тут, вибравши "Прикріпити до профілю" + в меню окремих записів. +unknown: Невідомо +onlineStatus: Онлайн-статус +hideOnlineStatus: Приховати онлайн-статус +online: В мережі +breakFollow: Видалити підписника +translate: Перекласти +translatedFrom: Перекладено з {x} +userSaysSomethingReasonQuote: '{name} цитував запис з {reason}' +userSaysSomethingReasonRenote: '{name} поширив запис з {reason}' +notRecommended: Не рекомендується +botProtection: Захист від ботів +instanceBlocking: Керування Федерацією +privateMode: Приватний режим +allowedInstances: Перелік дозволених серверів +previewNoteText: Показати прев'ю +antennaInstancesDescription: Введіть по одному хосту сервера на рядок +breakFollowConfirm: Ви дійсно бажаєте видалити підписника? +ads: Реклама +cw: Попередження про вміст +hiddenTags: Приховані хештеги +noInstances: Немає серверів +iceshrimpUpdated: Iceshrimp оновлено! +received: Отримане +xl: Надвеликий +searchResult: Результати пошуку +useBlurEffect: Використовувати ефекти розмиття в інтерфейсі +learnMore: Дізнатися більше +usernameInfo: Ім'я, яке ідентифікує ваш обліковий запис серед інших на цьому сервері. Ви + можете використовувати алфавіт (a~z, A~Z), цифри (0~9) або знаки підкреслення (_). + Ім'я користувача не може бути змінено пізніше. +noThankYou: Ні, дякую +keepCw: Зберігати попередження про вміст +showEmojisInReactionNotifications: Показувати емодзі у сповіщеннях про реакції +accountMoved: 'Користувач мігрував на новий обліковий запис:' +expandOnNoteClickDesc: Якщо цю опцію вимкнено, ви все одно зможете відкривати дописи + в меню, клацнувши правою кнопкою миші або натиснувши на мітку часу. +deleteAccountConfirm: Це призведе до незворотного видалення вашого облікового запису. + Приступити? +unread: Непрочитане +filter: Фільтри +useDrawerReactionPickerForMobile: Відображати вибирач реакцій як шухляду на мобільному + телефоні +leaveGroupConfirm: Ви впевнені, що хочете залишити "{name}"? +clickToFinishEmailVerification: Будь ласка, натисніть [{ok}], щоб завершити перевірку + електронної пошти. +overridedDeviceKind: Тип пристрою +themeColor: Колір теми серверу +oneDay: Один день +instanceDefaultLightTheme: Світла тема за замовчуванням для сервера +oneWeek: Одна неділя +instanceDefaultDarkTheme: Темна тема за замовчуванням для сервера +video: Відео +audio: Аудіо +rateLimitExceeded: Перевищено ліміт +numberOfPageCacheDescription: Збільшення цієї величини покращить зручність для користувачів, + але призведе до збільшення навантаження на сервер та використання більшої кількості + пам'яті. +lastActiveDate: Останній раз використовувався у +statusbar: Панель статусу +speed: Швидкість +sensitiveMediaDetection: Виявлення медіа з чутливим контентом +cannotUploadBecauseNoFreeSpace: Завантаження не вдалося через брак місця на Диску. +cannotUploadBecauseExceedsFileSizeLimit: Цей файл не може бути завантажений, оскільки + він перевищує максимально дозволений розмір. +account: Обліковий запис +move: Перемістити +pushNotification: Push-сповіщення +subscribePushNotification: Увімкнути push-сповіщення +unsubscribePushNotification: Вимкнути push-сповіщення +pushNotificationAlreadySubscribed: Push-сповіщення вже увімкнено +enterSendsMessage: Натисніть Enter у повідомленнях, щоб надіслати повідомлення (якщо + вимкнено, то Ctrl + Enter) +showAds: Показувати рекламу +customMOTD: Користувацькі MOTD (повідомлення на заставці) +customSplashIcons: Користувацькі іконки заставки (URL) +splash: Заставка +adminCustomCssWarn: Цей параметр слід використовувати, тільки якщо ви знаєте, що він + робить. Введення неправильних значень може призвести до того, що ВСІ клієнти перестануть + нормально працювати. Будь ласка, переконайтеся, що ваш CSS працює належним чином, + протестувавши його в налаштуваннях користувача. +_filters: + followersOnly: Тільки підписники + fromUser: Від користувача + notesBefore: Записи до + withFile: Має вкладення + fromDomain: Тільки цей інстанс + notesAfter: Записи після + followingOnly: Тільки підписки + _dialog: + learnMore: Подивитися синтаксис пошукового фільтра + userDomain: Фільтрувати по автору, згаданих користувачах, користувачу, який відповів + або домену інстанса + attachmentType: Фільтрувати за типом прикріпленого файла/типами прикріплених файлів + info1: Текст у дужках означає доступні необов'язкові параметри фільтрації. Варіанти + параметрів відокремлюються символом pipe, '|'. + infoEnd1: Задля зручності і попередження помилок, деякі фільтри які вже мають + псевдоними, перелічені нижче. + title: Синтаксис пошукового фільтра + wordFilters: Фільтрувати за текстом дописа + inFilters: Фільтрувати за закладками та/або за статусом "Сподобалось" + miscFilters: Фільтрувати по відношенню до фоловера та/або типу приміток + postDate: Фільтрувати по даті публікації + exclusivity: Зауважте, що фільтр "before:" є ексклюзивним (виключаючим), тоді + як фільтр "after:" є інклюзивним (включаючим). + word: слово + phrase: буквальна фраза, яка містить (довільні) символи + matchOptions: Змінити чутливість до капіталізації літер та/або шукати повну відповідність + info: Номенклатура + info2: Тире у дужках, позначає здатність інвертувати/відкидати фільтр із символом + тире. + infoEnd: Фільтрувати за псевдонимами + replyTo: Відповідь на + mentioning: Згадування + inFavorites: Вподобання + inBookmarks: У закладках + repliesOnly: Тільки відповіді + excludeReplies: Виключаючи відповіді + excludeRenotes: Виключаючи бусти + caseSensitive: Враховувати капіталізацію літер + matchWords: Відповідати цілим словам +sendModMail: Надіслати повідомлення про модерацію +enableServerMachineStats: Увімкнути статистику серверного обладнання +enableIdenticonGeneration: Увімкнути генерацію Identicon +_sensitiveMediaDetection: + analyzeVideosDescription: Аналізує відео так само як і зображення. Це трохи збільшить + навантаження на сервер. + description: Зменшує навантаження на модерацію сервера завдяки автоматичному розпізнаванню + медіа з чутливим вмістом використовуючи машинне навчання. Це трохи збільшить навантаження + на сервер. + sensitivity: Чутливість виявлення + sensitivityDescription: Зменшення чутливості призведе до зменшення кількості хибних + спрацьовувань, тоді як збільшення чутливості призведе до зменшення кількості пропущених + спрацьовувань. + setSensitiveFlagAutomatically: Позначити як "Чутливий вміст" + setSensitiveFlagAutomaticallyDescription: Результати внутрішнього виявлення будуть + збережені, навіть якщо цю опцію вимкнено. + analyzeVideos: Ввімкнути аналіз відео +_emailUnavailable: + used: Ця електронна пошта вже використовується + format: Формат цієї адреси електронної пошти є неправильним + mx: Цей сервер електронної пошти є недійсним + disposable: Використовувати одноразові адреси електронної пошти заборонено + smtp: Цей поштовий сервер не відповідає +_messaging: + dms: Приватні + groups: Групи +_instanceMute: + instanceMuteDescription: Це приховає всі записи/поширення із вказаних серверів, + включно з відповідями користувачам заглушеного серверу. + title: Приховує записи з перелічених серверів. + instanceMuteDescription2: Розділити новими рядками + heading: Список серверів для заглушення +_dialog: + charactersExceeded: 'Перевищено максимальну кількість символів! Обмеження: {current}/{max}' + charactersBelow: 'Недостатньо символів! Обмеження: {current}/{min}' +jumpToSpecifiedDate: Перейти до конкретної дати +quitFullView: Закрити повний вигляд +ffVisibility: Видимість підписок/підписників +numberOfColumn: Кількість стовпців +failedToFetchAccountInformation: Не вдалося отримати інформацію про обліковий запис +reflectMayTakeTime: Може пройти деякий час, перш ніж зміни набудуть чинності. +recentNHours: Останні {n} годин +logoutConfirm: Ви впевнені, що хочете вийти? +enableRecommendedTimeline: Увімкнути рекомендовану стрічку +_accountDelete: + requestAccountDelete: Запросити видалення облікового запису + accountDelete: Видалити обліковий запис + mayTakeTime: Оскільки видалення облікового запису є ресурсоємним процесом, він може + зайняти деякий час, залежно від того, скільки контенту ви створили та скільки + файлів завантажили. + sendEmail: Коли ваш обліковий запис буде видалено, ми повідомимо на вказану вами + електронну пошту. + started: Процес видалення розпочався. + inProgress: Аккаунт видаляється +_preferencesBackups: + deleteConfirm: Видалити резервну копію {name}? + applyConfirm: Ви дійсно хочете застосувати резервну копію "{name}" до цього пристрою? + Існуючі налаштування цього пристрою буде замінено. + saveConfirm: Зберегти резервну копію як {name}? + saveNew: Зберегти нову резервну копію + save: Зберегти зміни + inputName: Будь ласка, введіть назву для цієї резервної копії + loadFile: Завантажити з файлу + updatedAt: 'Оновлено: {date} {time}' + invalidFile: Неправильний формат файлу + apply: Застосувати до цього пристрою + list: Створені резервні копії + cannotSave: Збереження невдале + nameAlreadyExists: Резервна копія з назвою "{name}" вже існує. Будь ласка, введіть + іншу назву. + renameConfirm: Перейменувати цю резервну копію з "{old}" на "{new}"? + noBackups: Резервних копій немає. Ви можете створити резервну копію налаштувань + клієнта на цьому сервері за допомогою "Створити нову резервну копію". + createdAt: 'Створено: {date} {time}' + cannotLoad: Не вдалося завантажити + delete: Видалити резервну копію +beta: Бета +customMOTDDescription: Користувацькі повідомлення для MOTD (заставки), розділені новими + рядками, які будуть показуватися випадковим чином щоразу, коли користувач завантажує/перезавантажує + сторінку. +replayTutorial: Перезапустити туторіал +_forgotPassword: + ifNoEmail: Якщо ви не використовували електронну пошту під час реєстрації, зверніться + до адміністратора серверу. + enterEmail: Введіть адресу електронної пошти, яку ви використовували для реєстрації. + На неї буде надіслано посилання, за яким ви зможете скинути пароль. + contactAdmin: Цей сервер не підтримує використання адрес електронної пошти, будь + ласка, зверніться до адміністратора сервера, щоб скинути пароль. +reactionPickerSkinTone: Бажаний колір шкіри емодзі +addInstance: Додати сервер +jumpToPrevious: Перейти до попереднього +listsDesc: Списки дозволяють створювати стрічки із вказаними користувачами. Доступ + до них можна отримати на сторінці стрічок. +channelFederationWarn: Канали наразі федеруються з іншими серверами +lastCommunication: Останнє повідомлення +edited: Відредаговано {date} о {time} +confirmToUnclipAlreadyClippedNote: Цей запис уже в підбірці "{name}". Чи бажаєте ви + натомість видалити пост із підбірки? +quickAction: Швидкі дії +remoteOnly: Тільки віддалені +failedToUpload: Помилка завантаження +moveFrom: Мігрувати на цей обліковий запис зі старого облікового запису +preventAiLearning: Захист від скрепінгу ШІ-ботів +moveAccountDescription: Цей процес є незворотнім. Переконайтеся, що ви створили псевдонім + для цього акаунта в новому акаунті перед переїздом. Будь ласка, введіть тег акаунта + у форматі @person@server.com +_signup: + almostThere: Майже готово + emailAddressInfo: Будь ласка, введіть свою адресу електронної пошти. Вона не буде + опублікована. + emailSent: На вашу електронну адресу ({email}) було надіслано лист із підтвердженням. + Будь ласка, перейдіть за посиланням, щоб завершити створення облікового запису. +defaultValueIs: 'За замовчуванням: {value}' +shareWithNote: Поділитися з записом +classic: Відцентрований +size: Розмір +slow: Повільно +alt: ALT +auto: Автоматично +oneHour: Одна година +instanceDefaultThemeDescription: Введіть тему в форматі JSON. +cropImageAsk: Чи бажаєте ви обрізати це зображення? +noEmailServerWarning: Поштовий сервер не налаштовано. +thereIsUnresolvedAbuseReportWarning: Є не розглянуті звіти. +image: Зображення +check: Перевірити +isSystemAccount: Цей акаунт створений і автоматично управляється системою. Будь ласка, + не модеруйте, не редагуйте, не видаляйте та не втручайтеся в цей акаунт будь-яким + іншим чином, інакше це може призвести до поломки вашого серверу. +document: Документація +driveCapOverrideCaption: Ви можете скинути ємність до значення за замовчуванням, ввівши + значення 0 або менше. +numberOfPageCache: Кількість кешованих сторінок +pleaseSelect: Оберіть варіант +refreshInterval: 'Інтервал оновлення' +enableAutoSensitive: Автоматичне маркування "Чутливий контент" +cannotUploadBecauseInappropriate: Цей файл не може бути завантажений тому що його + частина містить потенційний чутливий контент. +sendPushNotificationReadMessageCaption: На короткий час буде показано сповіщення з + текстом "{emptyPushNotificationMessage}". Це може призвести до збільшення споживання + заряду акумулятора вашого пристрою, якщо це можливо. +pushNotificationNotSupported: Ваш браузер або сервер не підтримує push-сповіщення +showUpdates: Показувати спливаюче вікно при оновленні Iceshrimp +updateAvailable: Можливо, є доступне оновлення! +recommendedInstancesDescription: Рекомендовані сервери відокремлюються переведенням + рядка, щоб з'явитися на стрічці рекомендацій. +caption: Автоматичний підпис +showAdminUpdates: Вказати, що доступна нова версія Iceshrimp (тільки для адміністратора) +defaultReaction: Емодзі реакція за замовчуванням для вихідних і вхідних записів +license: Ліцензія +indexPosts: Індексувати пости +indexFrom: Індексувати записи з ID +indexFromDescription: Залиште порожнім, щоб індексувати кожен запис +indexNotice: Зараз відбувається індексація. Це, ймовірно, займе деякий час, будь ласка, + не перезавантажуйте сервер принаймні годину. +signupsDisabled: Реєстрація на цьому сервері наразі відключена, але ви завжди можете + зареєструватися на іншому сервері! Якщо у вас є код запрошення на цей сервер, будь + ласка, введіть його нижче. +findOtherInstance: Знайти інший сервер +customKaTeXMacro: Користувацькі макроси KaTeX +enableCustomKaTeXMacro: Увімкнути користувацькі макроси KaTeX +apps: Додатки +isModerator: Модератор +isAdmin: Адміністратор +isPatron: Патрон Iceshrimp +swipeOnMobile: Дозволити гортання між сторінками +migration: Міграція +swipeOnDesktop: Дозволити свайп у мобільному стилі на десктопі +logoImageUrl: URL-адреса зображення логотипу +moveTo: Перенести поточний обліковий запис на новий +moveFromDescription: Це встановить псевдонім вашого старого облікового запису, щоб + ви могли перейти зі старого облікового запису до цього поточного. Зробіть це ДО + переходу зі старого акаунта. Будь ласка, введіть тег акаунта у форматі @person@server.com +moveToLabel: 'Обліковий запис, на який ви мігруєте:' +moveAccount: Перемістити обліковий запис! +moveFromLabel: 'Обліковий запис, з якого ви мігруєте:' +_plugin: + install: Встановлення плагінів + manage: Керування плагінами + installWarn: Будь ласка, не встановлюйте ненадійні плагіни. +_skinTones: + yellow: Жовтий + mediumLight: Помірно-світлий + medium: Помірний + mediumDark: Помірно-темний + dark: Темний + light: Світлий +tenMinutes: 10 хвилин +expandOnNoteClick: Відкрити запис кліком +preferencesBackups: Резервне копіювання +unlikeConfirm: Дійсно видалити вподобайку? +fullView: Повний вигляд +postToGallery: Опублікувати в галереї +memo: Нотатки +allowedInstancesDescription: Перелік серверів, з якіми дозволено федеруватись, кожен + відокремлено новим рядком (стосується лише приватного режиму). +squareAvatars: Квадратні аватарки +aiChanMode: Режим ШІ +controlPanel: Панель керування +manageAccounts: Керування обліковими записами +incorrectPassword: Неправильний пароль. +voteConfirm: Підтвердити свій голос за "{choice}"? +leaveGroup: Залишити групу +smartphone: Смартфон +mutePeriod: Тривалість глушіння +requireAdminForView: Ви маєте увійти з облікового запису адміністратора, щоб переглянути + це. +fast: Швидко +isBot: Цей обліковий запис є ботом +isLocked: Цей обліковий запис має схвалення запитів на підписку +silenceThisInstance: Ігнорувати цей сервер +hideOnlineStatusDescription: Приховування вашого онлайн-статусу знижує зручність деяких + функцій, таких як пошук. +accountDeletionInProgress: Наразі триває видалення облікового запису +makeReactionsPublic: Зробити історію реакцій публічною +continueThread: Показати наступні відповіді +unmuteThread: Скасувати глушіння гілки +ffVisibilityDescription: Дозволяє налаштувати, хто може бачити, на кого ви підписані + і хто підписаний на вас. +tablet: Планшет +cropImage: Обрізати зображення +recentNDays: Останні {n} днів +navbar: Панель навігації +noGraze: Будь ласка, вимкніть розширення браузера "Graze для Mastodon", оскільки воно + заважає роботі Iceshrimp. +preventAiLearningDescription: Попросити сторонні мовні моделі ШІ не вивчати вміст, + який ви завантажуєте, наприклад, записи та зображення. +userSaysSomethingReasonReply: '{name} відповів на пост з {reason}' +secureMode: Безпечний режим (Authorized Fetch) +seperateRenoteQuote: Розділити кнопки поширення та цитати +makeReactionsPublicDescription: Це зробить список усіх ваших минулих реакцій публічно + видимим. +muteThread: Заглушити гілку +sendPushNotificationReadMessage: Видаляти push-сповіщення після того, як відповідні + сповіщення або повідомлення будуть прочитані +unclip: Видалити з підбірки +silencedInstances: Ігноровані сервери +typeToConfirm: Введіть {x} щоб підтвердити +silencedWarning: Ця сторінка відображається тому, що ці користувачі з серверів, які + ваш адміністратор заглушив, тому вони потенційно можуть бути спамом. +shuffle: Перетасувати +ratio: Співвідношення +secureModeInfo: У разі запитів з інших серверів не надсилати непідтверджену відповідь. +pubSub: Облікові записи Pub/Sub +driveCapOverrideLabel: Змінити ємність диску для цього користувача +deleteAccount: Видалити обліковий запис +type: Тип +enableAutoSensitiveDescription: Дозволяє автоматично виявляти та позначати медіафайли + "Чутливий Контент" за допомогою машинного навчання, де це можливо. Навіть якщо цю + опцію вимкнено, вона може бути увімкнена на всьому сервері. +recommendedInstances: Рекомендовані сервери +noteId: Ідентифікатор запису +showPopup: Сповіщати користувачів спливаючим вікном +showWithSparkles: Показати з блиском +youHaveUnreadAnnouncements: У вас є непрочитані оголошення +donationLink: Посилання на сторінку для внесків +neverShow: Не показувати знову +remindMeLater: Можливо пізніше +removeQuote: Видалити цитату +removeRecipient: Видалити одержувача +removeMember: Видалити члена +silencedInstancesDescription: Вкажіть імена хостів серверів, які ви хочете ігнорувати. + Облікові записи на перелічених серверах вважаються "Ігнорованими", можуть робити + лише запити на підписку і не можуть згадувати локальні облікові записи, якщо на + них не підписалися. Це не вплине на заблоковані сервери. +hiddenTagsDescription: 'Перелічіть хештеги (без #), які ви хочете приховати з трендів + і дослідження. Приховані хештеги все одно можна знайти іншими способами.' +antennasDesc: "Антени показують нові дописи, що відповідають встановленим вами критеріям!\n + Доступ до них можна отримати зі сторінки стрічок." +clipsDesc: Підбірки схожі на категоризовані закладки, до яких можна надавати спільний + доступ. Ви можете створювати підбірки з меню окремих записів. +migrationConfirm: "Ви точно впевнені, що хочете перенести свій обліковий запис на + {account}? Якщо ви це зробите, ви не зможете скасувати цю операцію і не зможете + користуватися своїм обліковим записом як раніше.\nТакож, будь ласка, переконайтеся, + що ви вибрали цей поточний обліковий запис як обліковий запис, з якого ви переходите." +customKaTeXMacroDescription: 'Налаштуйте макроси, щоб легко писати математичні вирази! + Позначення відповідає визначенню команд LaTeX і записується у вигляді \newcommand{\ + name}{content} або \newcommand{\name}[number of arguments]{content}. Наприклад, + \newcommand{\add}[2]{#1 + #2} розширить \add{3}{foo} to 3 + foo. Фігурні дужки навколо + назви макросу можна змінити на круглі або квадратні. Це вплине на дужки, що використовуються + для аргументів. В одному рядку можна визначити один (і тільки один) макрос, і жоден + рядок не можна розривати посередині визначення. Неправильні рядки просто ігноруються. + Підтримуються лише прості функції заміни рядків; розширений синтаксис, такий як + умовне розгалуження, не може бути використаний тут.' +activeEmailValidationDescription: Вмикає більш сувору перевірку адрес електронної + пошти, яка включає перевірку на наявність одноразових адрес і перевірку того, чи + дійсно з нею можна зв'язатися. Якщо цей прапорець знято, перевіряється лише формат + електронної пошти. +customSplashIconsDescription: URL-адреси іконок для заставки, розділені новими рядками, + які будуть показуватися випадковим чином щоразу, коли користувач завантажує/перезавантажує + сторінку. Будь ласка, переконайтеся, що зображення знаходяться на статичній URL-адресі, + бажано, щоб вони були змінені до розміру 192x192. +verifiedLink: Перевірене посилання +_wellness: + newPostsButton: Увімкнути кнопку сповіщення про нові публікації + newPostsGlowOpacity: Прозорість нових дописів + immediacy: Терміновість + name: Благополуччя + description: Ці налаштування дозволяють коригувати можливе звикання або тривожні + аспекти які можуть викликати соціальні мережі. Виберіть налаштування, які краще + підходять для вас. +hideFromHome: Сховати з основної стрічки +expandAllCws: Відображати вміст для усіх відповідей +collapseAllCws: Сховати вміст для усіх відповідей +_feeds: + copyFeed: Копіювати стрічку + rss: RSS + atom: Atom + jsonFeed: Стрічка у форматі JSON +cwStyle: Вигляд Попередження про Вміст +alwaysExpandCws: Завжди розгортати дописи з попередженням про чутливий вміст +antennaTimelineHint: Антенні показують відповідні пости у порядку, в кому вони були + отримані, що не обов'язково є хронологічним. +cannotChangeScopeWhenEditing: Ви не можете змінити видимість цієї публікації під час + редагування +openInMainColumn: Відкрити у головній стрічці +searchEmptyQuery: Будь ласка, введіть пошуковий запит. +_cwStyle: + modern: Сучасний + classic: Класичний (схожий на Misskey/Foundkey) + alternative: Альтернативний (схожий на Firefish) +searchNotLoggedIn_1: Вам потрібно аутентіфікуватись для того, щоб юзати повнотекстний + пошук. +searchNotLoggedIn_2: У будь-якому випадку, ви можете шукати використовуючи хештеги + та шукати користувачів. diff --git a/locales/vi-VN.yml b/locales/vi-VN.yml new file mode 100644 index 0000000..a0d8c19 --- /dev/null +++ b/locales/vi-VN.yml @@ -0,0 +1,1738 @@ +--- +_lang_: "Tiếng Việt" +headlineIceshrimp: "Mạng xã hội liên hợp" +introIceshrimp: "Xin chào! Iceshrimp là một nền tảng tiểu blog phi tập trung mã nguồn mở.\nViết \"tút\" để chia sẻ những suy nghĩ của bạn 📡\nBằng \"biểu cảm\", bạn có thể bày tỏ nhanh chóng cảm xúc của bạn với các tút 👍\nHãy khám phá một thế giới mới! 🚀" +monthAndDay: "{day} tháng {month}" +search: "Tìm kiếm" +notifications: "Thông báo" +username: "Tên người dùng" +password: "Mật khẩu" +forgotPassword: "Quên mật khẩu" +fetchingAsApObject: "Đang nạp dữ liệu từ Fediverse" +ok: "Đồng ý" +gotIt: "Đã hiểu!" +cancel: "Hủy" +enterUsername: "Nhập tên người dùng" +renotedBy: "Chia sẻ bởi {user}" +noNotes: "Chưa có tút nào." +noNotifications: "Không có thông báo" +instance: "Máy chủ" +settings: "Cài đặt" +basicSettings: "Thiết lập chung" +otherSettings: "Thiết lập khác" +openInWindow: "Mở trong cửa sổ mới" +profile: "Trang cá nhân" +timeline: "Bảng tin" +noAccountDescription: "Người này chưa viết mô tả." +login: "Đăng nhập" +loggingIn: "Đang đăng nhập..." +logout: "Đăng xuất" +signup: "Đăng ký" +uploading: "Đang tải lên…" +save: "Lưu" +users: "Người dùng" +addUser: "Thêm người dùng" +favorite: "Thêm vào yêu thích" +favorites: "Lượt thích" +unfavorite: "Bỏ thích" +favorited: "Đã thêm vào yêu thích." +alreadyFavorited: "Đã thêm vào yêu thích rồi." +cantFavorite: "Không thể thêm vào yêu thích." +pin: "Ghim" +unpin: "Bỏ ghim" +copyContent: "Chép nội dung" +copyLink: "Chép liên kết" +delete: "Xóa" +deleteAndEdit: "Sửa" +deleteAndEditConfirm: "Bạn có chắc muốn sửa tút này? Những biểu cảm, lượt trả lời và đăng lại sẽ bị mất." +addToList: "Thêm vào danh sách" +sendMessage: "Gửi tin nhắn" +copyUsername: "Chép tên người dùng" +searchUser: "Tìm kiếm người dùng" +reply: "Trả lời" +loadMore: "Tải thêm" +showMore: "Xem thêm" +showLess: "Đóng" +youGotNewFollower: "đã theo dõi bạn" +receiveFollowRequest: "Đã yêu cầu theo dõi" +followRequestAccepted: "Đã chấp nhận yêu cầu theo dõi" +mention: "Nhắc đến" +mentions: "Lượt nhắc" +directNotes: "Nhắn riêng" +importAndExport: "Nhập và xuất dữ liệu" +import: "Nhập dữ liệu" +export: "Xuất dữ liệu" +files: "Tập tin" +download: "Tải xuống" +driveFileDeleteConfirm: "Bạn có chắc muốn xóa tập tin \"{name}\"? Tút liên quan cũng sẽ bị xóa theo." +unfollowConfirm: "Bạn có chắc muốn ngưng theo dõi {name}?" +exportRequested: "Đang chuẩn bị xuất tập tin. Quá trình này có thể mất ít phút. Nó sẽ được tự động thêm vào Drive sau khi hoàn thành." +importRequested: "Bạn vừa yêu cầu nhập dữ liệu. Quá trình này có thể mất ít phút." +lists: "Danh sách" +noLists: "Bạn chưa có danh sách nào" +note: "Tút" +notes: "Tút" +following: "Đang theo dõi" +followers: "Người theo dõi" +followsYou: "Theo dõi bạn" +createList: "Tạo danh sách" +manageLists: "Quản lý danh sách" +error: "Lỗi" +somethingHappened: "Xảy ra lỗi" +retry: "Thử lại" +pageLoadError: "Xảy ra lỗi khi tải trang." +pageLoadErrorDescription: "Có thể là do bộ nhớ đệm của trình duyệt. Hãy thử xóa bộ nhớ đệm và thử lại sau ít phút." +serverIsDead: "Máy chủ không phản hồi. Vui lòng thử lại sau giây lát." +youShouldUpgradeClient: "Để xem trang này, hãy làm tươi để cập nhật ứng dụng." +enterListName: "Đặt tên cho danh sách" +privacy: "Bảo mật" +makeFollowManuallyApprove: "Yêu cầu theo dõi cần được duyệt" +defaultNoteVisibility: "Kiểu tút mặc định" +follow: "Đang theo dõi" +followRequest: "Gửi yêu cầu theo dõi" +followRequests: "Yêu cầu theo dõi" +unfollow: "Ngưng theo dõi" +followRequestPending: "Yêu cầu theo dõi đang chờ" +enterEmoji: "Chèn emoji" +renote: "Đăng lại" +unrenote: "Hủy đăng lại" +renoted: "Đã đăng lại." +cantRenote: "Không thể đăng lại tút này." +cantReRenote: "Không thể đăng lại một tút đăng lại." +quote: "Trích dẫn" +pinnedNote: "Tút ghim" +pinned: "Ghim" +you: "Bạn" +clickToShow: "Nhấn để xem" +sensitive: "Nhạy cảm" +add: "Thêm" +reaction: "Biểu cảm" +reactionSetting: "Chọn những biểu cảm hiển thị" +reactionSettingDescription2: "Kéo để sắp xếp, nhấn để xóa, nhấn \"+\" để thêm." +rememberNoteVisibility: "Lưu kiểu tút mặc định" +attachCancel: "Gỡ tập tin đính kèm" +markAsSensitive: "Đánh dấu là nhạy cảm" +unmarkAsSensitive: "Bỏ đánh dấu nhạy cảm" +enterFileName: "Nhập tên tập tin" +mute: "Ẩn" +unmute: "Bỏ ẩn" +block: "Chặn" +unblock: "Bỏ chặn" +suspend: "Vô hiệu hóa" +unsuspend: "Bỏ vô hiệu hóa" +blockConfirm: "Bạn có chắc muốn chặn người này?" +unblockConfirm: "Bạn có chắc muốn bỏ chặn người này?" +suspendConfirm: "Bạn có chắc muốn vô hiệu hóa người này?" +unsuspendConfirm: "Bạn có chắc muốn bỏ vô hiệu hóa người này?" +selectList: "Chọn danh sách" +selectAntenna: "Chọn một antenna" +selectWidget: "Chọn tiện ích" +editWidgets: "Sửa tiện ích" +editWidgetsExit: "Xong" +customEmojis: "Tùy chỉnh emoji" +emoji: "Emoji" +emojis: "Emoji" +emojiName: "Tên emoji" +emojiUrl: "URL Emoji" +addEmoji: "Thêm emoji" +settingGuide: "Cài đặt đề xuất" +cacheRemoteFiles: "Tập tin cache từ xa" +cacheRemoteFilesDescription: "Khi tùy chọn này bị tắt, các tập tin từ xa sẽ được tải trực tiếp từ máy chủ khác. Điều này sẽ giúp giảm dung lượng lưu trữ nhưng lại tăng lưu lượng truy cập, vì hình thu nhỏ sẽ không được tạo." +flagAsBot: "Đánh dấu đây là tài khoản bot" +flagAsBotDescription: "Bật tùy chọn này nếu tài khoản này được kiểm soát bởi một chương trình. Nếu được bật, nó sẽ được đánh dấu để các nhà phát triển khác ngăn chặn chuỗi tương tác vô tận với các bot khác và điều chỉnh hệ thống nội bộ của Iceshrimp để coi tài khoản này như một bot." +flagAsCat: "Tài khoản này là mèo" +flagAsCatDescription: "Bật tùy chọn này để đánh dấu tài khoản là một con mèo." +flagShowTimelineReplies: "Hiện lượt trả lời trong bảng tin" +flagShowTimelineRepliesDescription: "Hiện lượt trả lời của người bạn theo dõi trên tút của những người khác." +autoAcceptFollowed: "Tự động phê duyệt theo dõi từ những người mà bạn đang theo dõi" +addAccount: "Thêm tài khoản" +loginFailed: "Đăng nhập không thành công" +showOnRemote: "Truy cập trang của người này" +general: "Tổng quan" +wallpaper: "Ảnh bìa" +setWallpaper: "Đặt ảnh bìa" +removeWallpaper: "Xóa ảnh bìa" +searchWith: "Tìm kiếm: {q}" +youHaveNoLists: "Bạn chưa có danh sách nào" +followConfirm: "Bạn có chắc muốn theo dõi {name}?" +proxyAccount: "Tài khoản proxy" +proxyAccountDescription: "Tài khoản proxy là tài khoản hoạt động như một người theo dõi từ xa cho người dùng trong những điều kiện nhất định. Ví dụ: khi người dùng thêm người dùng từ xa vào danh sách, hoạt động của người dùng từ xa sẽ không được chuyển đến phiên bản nếu không có người dùng cục bộ nào theo dõi người dùng đó, vì vậy tài khoản proxy sẽ theo dõi." +host: "Host" +selectUser: "Chọn người dùng" +recipient: "Người nhận" +annotation: "Bình luận" +federation: "Liên hợp" +instances: "Máy chủ" +registeredAt: "Đăng ký vào" +latestRequestSentAt: "Yêu cầu cuối gửi lúc" +latestRequestReceivedAt: "Yêu cầu cuối nhận lúc" +latestStatus: "Trạng thái cuối cùng" +storageUsage: "Dung lượng lưu trữ" +charts: "Đồ thị" +perHour: "Mỗi Giờ" +perDay: "Mỗi Ngày" +stopActivityDelivery: "Ngưng gửi hoạt động" +blockThisInstance: "Chặn máy chủ này" +operations: "Vận hành" +software: "Phần mềm" +version: "Phiên bản" +metadata: "Metadata" +monitor: "Giám sát" +jobQueue: "Công việc chờ xử lý" +cpuAndMemory: "CPU và Dung lượng" +network: "Mạng" +disk: "Ổ đĩa" +instanceInfo: "Thông tin máy chủ" +statistics: "Thống kê" +clearQueue: "Xóa hàng đợi" +clearQueueConfirmTitle: "Bạn có chắc muốn xóa hàng đợi?" +clearQueueConfirmText: "Mọi tút chưa được gửi còn lại trong hàng đợi sẽ không được liên hợp. Thông thường thao tác này không cần thiết." +clearCachedFiles: "Xóa bộ nhớ đệm" +clearCachedFilesConfirm: "Bạn có chắc muốn xóa sạch bộ nhớ đệm?" +blockedInstances: "Máy chủ đã chặn" +blockedInstancesDescription: "Danh sách những máy chủ bạn muốn chặn. Chúng sẽ không thể giao tiếp với máy chủy này nữa." +muteAndBlock: "Ẩn và Chặn" +mutedUsers: "Người đã ẩn" +blockedUsers: "Người đã chặn" +noUsers: "Chưa có ai" +editProfile: "Sửa hồ sơ" +noteDeleteConfirm: "Bạn có chắc muốn xóa tút này?" +pinLimitExceeded: "Bạn đã đạt giới hạn số lượng tút có thể ghim" +intro: "Đã cài đặt Iceshrimp! Xin hãy tạo tài khoản admin." +done: "Xong" +processing: "Đang xử lý" +preview: "Xem trước" +default: "Mặc định" +defaultValueIs: "Mặc định: {value}" +noCustomEmojis: "Không có emoji" +noJobs: "Không có công việc" +federating: "Đang liên hợp" +blocked: "Đã chặn" +suspended: "Đã vô hiệu hóa" +all: "Tất cả" +subscribing: "Đang đăng ký" +publishing: "Đang đăng" +notResponding: "Không có phản hồi" +instanceFollowing: "Đang theo dõi máy chủ" +instanceFollowers: "Người theo dõi của máy chủ" +instanceUsers: "Người dùng trên máy chủ này" +changePassword: "Đổi mật khẩu" +security: "Bảo mật" +retypedNotMatch: "Mật khẩu không trùng khớp." +currentPassword: "Mật khẩu hiện tại" +newPassword: "Mật khẩu mới" +newPasswordRetype: "Nhập lại mật khẩu mới" +attachFile: "Đính kèm tập tin" +more: "Thêm nữa!" +featured: "Nổi bật" +usernameOrUserId: "Tên người dùng hoặc ID" +noSuchUser: "Không tìm thấy người dùng" +lookup: "Tìm kiếm" +announcements: "Thông báo" +imageUrl: "URL ảnh" +remove: "Xóa" +removed: "Đã xóa" +removeAreYouSure: "Bạn có chắc muốn gỡ \"{x}\"?" +deleteAreYouSure: "Bạn có chắc muốn xóa \"{x}\"?" +resetAreYouSure: "Bạn có chắc muốn đặt lại?" +saved: "Đã lưu" +messaging: "Trò chuyện" +upload: "Tải lên" +keepOriginalUploading: "Giữ hình ảnh gốc" +keepOriginalUploadingDescription: "Giữ nguyên như hình ảnh được tải lên ban đầu. Nếu tắt, một phiên bản để hiển thị trên web sẽ được tạo khi tải lên." +fromDrive: "Từ ổ đĩa" +fromUrl: "Từ URL" +uploadFromUrl: "Tải lên bằng một URL" +uploadFromUrlDescription: "URL của tập tin bạn muốn tải lên" +uploadFromUrlRequested: "Đã yêu cầu tải lên" +uploadFromUrlMayTakeTime: "Sẽ mất một khoảng thời gian để tải lên xong." +explore: "Khám phá" +messageRead: "Đã đọc" +noMoreHistory: "Không còn gì để đọc" +startMessaging: "Bắt đầu trò chuyện" +nUsersRead: "đọc bởi {n}" +agreeTo: "Tôi đồng ý {0}" +tos: "Điều khoản dịch vụ" +start: "Bắt đầu" +home: "Trang chính" +remoteUserCaution: "Vì người dùng này ở máy chủ khác, thông tin hiển thị có thể không đầy đủ." +activity: "Hoạt động" +images: "Hình ảnh" +birthday: "Sinh nhật" +yearsOld: "{age} tuổi" +registeredDate: "Tham gia" +location: "Đến từ" +theme: "Chủ đề" +themeForLightMode: "Chủ đề dùng trong trong chế độ Sáng" +themeForDarkMode: "Chủ đề dùng trong chế độ Tối" +light: "Sáng" +dark: "Tối" +lightThemes: "Những chủ đề sáng" +darkThemes: "Những chủ đề tối" +syncDeviceDarkMode: "Đồng bộ với thiết bị" +drive: "Ổ đĩa" +fileName: "Tên tập tin" +selectFile: "Chọn tập tin" +selectFiles: "Chọn nhiều tập tin" +selectFolder: "Chọn thư mục" +selectFolders: "Chọn nhiều thư mục" +renameFile: "Đổi tên tập tin" +folderName: "Tên thư mục" +createFolder: "Tạo thư mục" +renameFolder: "Đổi tên thư mục" +deleteFolder: "Xóa thư mục" +addFile: "Thêm tập tin" +emptyDrive: "Ổ đĩa của bạn trống trơn" +emptyFolder: "Thư mục trống" +unableToDelete: "Không thể xóa" +inputNewFileName: "Nhập tên mới cho tập tin" +inputNewDescription: "Nhập mô tả mới" +inputNewFolderName: "Nhập tên mới cho thư mục" +circularReferenceFolder: "Thư mục đích là một thư mục con của thư mục bạn muốn di chuyển." +hasChildFilesOrFolders: "Không thể xóa cho đến khi không còn gì trong thư mục." +copyUrl: "Sao chép URL" +rename: "Đổi tên" +avatar: "Ảnh đại diện" +banner: "Ảnh bìa" +nsfw: "Nhạy cảm" +whenServerDisconnected: "Khi mất kết nối tới máy chủ" +disconnectedFromServer: "Mất kết nối tới máy chủ" +reload: "Tải lại" +doNothing: "Bỏ qua" +reloadConfirm: "Bạn có muốn thử tải lại bảng tin?" +watch: "Xem" +unwatch: "Ngừng xem" +accept: "Đồng ý" +reject: "Từ chối" +normal: "Bình thường" +instanceName: "Tên máy chủ" +instanceDescription: "Mô tả máy chủ" +maintainerName: "Đội ngũ vận hành" +maintainerEmail: "Email đội ngũ" +tosUrl: "URL Điều khoản dịch vụ" +thisYear: "Năm" +thisMonth: "Tháng" +today: "Hôm nay" +dayX: "{day}" +monthX: "{month}" +yearX: "{year}" +pages: "Trang" +integration: "Tương tác" +connectService: "Kết nối" +disconnectService: "Ngắt kết nối" +enableLocalTimeline: "Bật bảng tin máy chủ" +enableGlobalTimeline: "Bật bảng tin liên hợp" +disablingTimelinesInfo: "Quản trị viên và Kiểm duyệt viên luôn có quyền truy cập mọi bảng tin, kể cả khi chúng không được bật." +registration: "Đăng ký" +enableRegistration: "Cho phép đăng ký mới" +invite: "Mời" +driveCapacityPerLocalAccount: "Dung lượng ổ đĩa tối đa cho mỗi người dùng" +driveCapacityPerRemoteAccount: "Dung lượng ổ đĩa tối đa cho mỗi người dùng từ xa" +inMb: "Tính bằng MB" +iconUrl: "URL Icon" +bannerUrl: "URL Ảnh bìa" +backgroundImageUrl: "URL Ảnh nền" +basicInfo: "Thông tin cơ bản" +pinnedUsers: "Những người thú vị" +pinnedUsersDescription: "Liệt kê mỗi hàng một tên người dùng xuống dòng để ghim trên tab \"Khám phá\"." +pinnedPages: "Trang đã ghim" +pinnedPagesDescription: "Liệt kê các trang thú vị để ghim trên máy chủ." +pinnedClipId: "ID của clip muốn ghim" +pinnedNotes: "Tút ghim" +hcaptcha: "hCaptcha" +enableHcaptcha: "Bật hCaptcha" +hcaptchaSiteKey: "Khóa của trang" +hcaptchaSecretKey: "Khóa bí mật" +recaptcha: "reCAPTCHA" +enableRecaptcha: "Bật reCAPTCHA" +recaptchaSiteKey: "Khóa của trang" +recaptchaSecretKey: "Khóa bí mật" +avoidMultiCaptchaConfirm: "Dùng nhiều hệ thống Captcha có thể gây nhiễu giữa chúng. Bạn có muốn tắt các hệ thống Captcha khác hiện đang hoạt động không? Nếu bạn muốn chúng tiếp tục được bật, hãy nhấn hủy." +antennas: "Trạm phát sóng" +manageAntennas: "Quản lý trạm phát sóng" +name: "Tên" +antennaSource: "Nguồn trạm phát sóng" +antennaKeywords: "Từ khóa để nghe" +antennaExcludeKeywords: "Từ khóa để lọc ra" +antennaKeywordsDescription: "Phân cách bằng dấu cách cho điều kiện AND hoặc bằng xuống dòng cho điều kiện OR." +notifyAntenna: "Thông báo có tút mới" +withFileAntenna: "Chỉ những tút có media" +enableServiceworker: "Bật ServiceWorker" +antennaUsersDescription: "Liệt kê mỗi hàng một tên người dùng" +caseSensitive: "Trường hợp nhạy cảm" +withReplies: "Bao gồm lượt trả lời" +connectedTo: "Những tài khoản sau đã kết nối" +notesAndReplies: "Tút kèm trả lời" +withFiles: "Media" +silence: "Ẩn" +silenceConfirm: "Bạn có chắc muốn ẩn người này?" +unsilence: "Bỏ ẩn" +unsilenceConfirm: "Bạn có chắc muốn bỏ ẩn người này?" +popularUsers: "Những người nổi tiếng" +recentlyUpdatedUsers: "Hoạt động gần đây" +recentlyRegisteredUsers: "Mới tham gia" +recentlyDiscoveredUsers: "Mới khám phá" +exploreUsersCount: "Có {count} người" +exploreFediverse: "Khám phá Fediverse" +popularTags: "Hashtag thông dụng" +userList: "Danh sách" +about: "Giới thiệu" +aboutIceshrimp: "Về Iceshrimp" +administrator: "Quản trị viên" +token: "Token" +twoStepAuthentication: "Xác minh 2 bước" +moderator: "Kiểm duyệt viên" +moderation: "Kiểm duyệt" +nUsersMentioned: "Dùng bởi {n} người" +securityKey: "Khóa bảo mật" +securityKeyName: "Tên khoá" +registerSecurityKey: "Đăng ký khóa bảo mật" +lastUsed: "Dùng lần cuối" +unregister: "Hủy đăng ký" +passwordLessLogin: "Đăng nhập không mật khẩu" +resetPassword: "Đặt lại mật khẩu" +newPasswordIs: "Mật khẩu mới là \"{password}\"" +reduceUiAnimation: "Giảm chuyển động UI" +share: "Chia sẻ" +notFound: "Không tìm thấy" +notFoundDescription: "Không tìm thấy trang nào tương ứng với URL này." +uploadFolder: "Thư mục tải lên mặc định" +cacheClear: "Xóa bộ nhớ đệm" +markAsReadAllNotifications: "Đánh dấu tất cả các thông báo là đã đọc" +markAsReadAllUnreadNotes: "Đánh dấu tất cả các tút là đã đọc" +markAsReadAllTalkMessages: "Đánh dấu tất cả các tin nhắn là đã đọc" +help: "Trợ giúp" +inputMessageHere: "Nhập nội dung tin nhắn" +close: "Đóng" +group: "Nhóm" +groups: "Các nhóm" +createGroup: "Tạo nhóm" +ownedGroups: "Nhóm tôi quản lý" +joinedGroups: "Nhóm tôi tham gia" +invites: "Mời" +groupName: "Tên nhóm" +members: "Thành viên" +transfer: "Chuyển giao" +messagingWithUser: "Nhắn riêng" +messagingWithGroup: "Chat nhóm" +title: "Tựa đề" +text: "Nội dung" +enable: "Bật" +next: "Kế tiếp" +retype: "Nhập lại" +noteOf: "Tút của {user}" +inviteToGroup: "Mời vào nhóm" +quoteAttached: "Trích dẫn" +quoteQuestion: "Trích dẫn lại?" +noMessagesYet: "Chưa có tin nhắn" +newMessageExists: "Bạn có tin nhắn mới" +onlyOneFileCanBeAttached: "Bạn chỉ có thể đính kèm một tập tin" +signinRequired: "Vui lòng đăng nhập" +invitations: "Mời" +invitationCode: "Mã mời" +checking: "Đang kiểm tra..." +available: "Khả dụng" +unavailable: "Không khả dụng" +usernameInvalidFormat: "Bạn có thể dùng viết hoa/viết thường, chữ số, và dấu gạch dưới." +tooShort: "Quá ngắn" +tooLong: "Quá dài" +weakPassword: "Mật khẩu yếu" +normalPassword: "Mật khẩu tạm được" +strongPassword: "Mật khẩu mạnh" +passwordMatched: "Trùng khớp" +passwordNotMatched: "Không trùng khớp" +signinWith: "Đăng nhập bằng {x}" +signinFailed: "Không thể đăng nhập. Vui lòng kiểm tra tên người dùng và mật khẩu của bạn." +tapSecurityKey: "Nhấn mã bảo mật của bạn" +or: "Hoặc" +language: "Ngôn ngữ" +uiLanguage: "Ngôn ngữ giao diện" +groupInvited: "Bạn đã được mời tham gia nhóm" +aboutX: "Giới thiệu {x}" +useOsNativeEmojis: "Dùng emoji hệ thống" +disableDrawer: "Không dùng menu thanh bên" +youHaveNoGroups: "Không có nhóm nào" +joinOrCreateGroup: "Tham gia hoặc tạo một nhóm mới." +noHistory: "Không có dữ liệu" +signinHistory: "Lịch sử đăng nhập" +disableAnimatedMfm: "Tắt MFM với chuyển động" +doing: "Đang xử lý..." +category: "Phân loại" +tags: "Thẻ" +docSource: "Nguồn tài liệu" +createAccount: "Tạo tài khoản" +existingAccount: "Tài khoản hiện có" +regenerate: "Tạo lại" +fontSize: "Cỡ chữ" +noFollowRequests: "Bạn không có yêu cầu theo dõi nào" +openImageInNewTab: "Mở ảnh trong tab mới" +dashboard: "Trang chính" +local: "Máy chủ này" +remote: "Máy chủ khác" +total: "Tổng cộng" +weekOverWeekChanges: "Thay đổi tuần rồi" +dayOverDayChanges: "Thay đổi hôm qua" +appearance: "Giao diện" +clientSettings: "Cài đặt Client" +accountSettings: "Cài đặt tài khoản" +promotion: "Quảng cáo" +promote: "Quảng cáo" +numberOfDays: "Số ngày" +hideThisNote: "Ẩn tút này" +showFeaturedNotesInTimeline: "Hiện tút nổi bật trong bảng tin" +objectStorage: "Đối tượng lưu trữ" +useObjectStorage: "Dùng đối tượng lưu trữ" +objectStorageBaseUrl: "Base URL" +objectStorageBaseUrlDesc: "URL được sử dụng làm tham khảo. Chỉ định URL của CDN hoặc Proxy của bạn nếu bạn đang sử dụng. Với S3 dùng 'https://.s3.amazonaws.com', còn GCS hoặc dịch vụ tương tự dùng 'https://storage.googleapis.com/', etc." +objectStorageBucket: "Bucket" +objectStorageBucketDesc: "Nhập tên bucket dùng ở nhà cung cấp của bạn." +objectStoragePrefix: "Tiền tố" +objectStoragePrefixDesc: "Các tập tin sẽ được lưu trữ trong các thư mục có tiền tố này." +objectStorageEndpoint: "Đầu cuối" +objectStorageEndpointDesc: "Để trống nếu bạn đang dùng AWS S3, nếu không thì chỉ định đầu cuối là '' hoặc ':', tùy thuộc vào nhà cung cấp dịch vụ." +objectStorageRegion: "Khu vực" +objectStorageRegionDesc: "Nhập một khu vực cụ thể như 'xx-east-1'. Nếu nhà cung cấp dịch vụ của bạn không phân biệt giữa các khu vực, hãy để trống hoặc nhập 'us-east-1'." +objectStorageUseSSL: "Dùng SSL" +objectStorageUseSSLDesc: "Tắt nếu bạn không dùng HTTPS để kết nối API" +objectStorageUseProxy: "Kết nối thông qua Proxy" +objectStorageUseProxyDesc: "Tắt nếu bạn không dùng Proxy để kết nối API" +objectStorageSetPublicRead: "Đặt \"public-read\" khi tải lên" +serverLogs: "Nhật ký máy chủ" +deleteAll: "Xóa tất cả" +showFixedPostForm: "Hiện khung soạn tút ở phía trên bảng tin" +newNoteRecived: "Đã nhận tút mới" +sounds: "Âm thanh" +listen: "Nghe" +none: "Không" +showInPage: "Hiện trong trang" +popout: "Pop-out" +volume: "Âm lượng" +masterVolume: "Âm thanh chung" +details: "Chi tiết" +chooseEmoji: "Chọn emoji" +unableToProcess: "Không thể hoàn tất hành động" +recentUsed: "Sử dụng gần đây" +install: "Cài đặt" +uninstall: "Gỡ bỏ" +installedApps: "Ứng dụng đã cài đặt" +nothing: "Không có gì ở đây" +installedDate: "Cho phép vào" +lastUsedDate: "Dùng gần nhất" +state: "Trạng thái" +sort: "Sắp xếp" +ascendingOrder: "Tăng dần" +descendingOrder: "Giảm dần" +scratchpad: "Scratchpad" +scratchpadDescription: "Scratchpad cung cấp môi trường cho các thử nghiệm AiScript. Bạn có thể viết, thực thi và kiểm tra kết quả tương tác với Iceshrimp trong đó." +output: "Nguồn ra" +script: "Kịch bản" +disablePagesScript: "Tắt AiScript trên Trang" +updateRemoteUser: "Cập nhật thông tin người dùng ở máy chủ khác" +deleteAllFiles: "Xóa toàn bộ tập tin" +deleteAllFilesConfirm: "Bạn có chắc xóa toàn bộ tập tin?" +removeAllFollowing: "Ngưng theo dõi tất cả mọi người" +removeAllFollowingDescription: "Thực hiện điều này sẽ ngưng theo dõi tất cả các tài khoản khỏi {host}. Chỉ thực hiện điều này nếu máy chủ không còn tồn tại." +userSuspended: "Người này đã bị vô hiệu hóa." +userSilenced: "Người này đã bị ẩn" +yourAccountSuspendedTitle: "Tài khoản bị vô hiệu hóa" +yourAccountSuspendedDescription: "Tài khoản này đã bị vô hiệu hóa do vi phạm quy tắc máy chủ hoặc điều tương tự. Liên hệ với quản trị viên nếu bạn muốn biết lý do chi tiết hơn. Vui lòng không tạo tài khoản mới." +menu: "Menu" +divider: "Phân chia" +addItem: "Thêm mục" +relays: "Chuyển tiếp" +addRelay: "Thêm chuyển tiếp" +inboxUrl: "URL Hộp thư đến" +addedRelays: "Đã thêm các chuyển tiếp" +serviceworkerInfo: "Phải được bật cho thông báo đẩy." +deletedNote: "Tút đã bị xóa" +invisibleNote: "Tút ẩn" +enableInfiniteScroll: "Tự động tải tút mới" +visibility: "Hiển thị" +poll: "Bình chọn" +useCw: "Ẩn nội dung" +enablePlayer: "Mở trình phát video" +disablePlayer: "Đóng trình phát video" +expandTweet: "Mở rộng tweet" +themeEditor: "Công cụ thiết kế theme" +description: "Mô tả" +describeFile: "Thêm mô tả" +enterFileDescription: "Nhập mô tả" +author: "Tác giả" +leaveConfirm: "Có những thay đổi chưa được lưu. Bạn có muốn bỏ chúng không?" +manage: "Quản lý" +plugins: "Plugin" +preferencesBackups: "Sao lưu thiết lập" +deck: "Deck" +undeck: "Bỏ Deck" +useBlurEffectForModal: "Sử dụng hiệu ứng mờ cho các hộp thoại" +useFullReactionPicker: "Dùng bộ chọn biểu cảm cỡ lớn" +width: "Chiều rộng" +height: "Chiều cao" +large: "Lớn" +medium: "Vừa" +small: "Nhỏ" +generateAccessToken: "Tạo mã truy cập" +permission: "Cho phép " +enableAll: "Bật toàn bộ" +disableAll: "Tắt toàn bộ" +tokenRequested: "Cấp quyền truy cập vào tài khoản" +pluginTokenRequestedDescription: "Plugin này sẽ có thể sử dụng các quyền được đặt ở đây." +notificationType: "Loại thông báo" +edit: "Sửa" +emailServer: "Email máy chủ" +enableEmail: "Bật phân phối email" +emailConfigInfo: "Được dùng để xác minh email của bạn lúc đăng ký hoặc nếu bạn quên mật khẩu của mình" +email: "Email" +emailAddress: "Địa chỉ email" +smtpConfig: "Cấu hình máy chủ SMTP" +smtpHost: "Host" +smtpPort: "Cổng" +smtpUser: "Tên người dùng" +smtpPass: "Mật khẩu" +emptyToDisableSmtpAuth: "Để trống tên người dùng và mật khẩu để tắt xác thực SMTP" +smtpSecure: "Dùng SSL/TLS ngầm định cho các kết nối SMTP" +smtpSecureInfo: "Tắt cái này nếu dùng STARTTLS" +testEmail: "Kiểm tra vận chuyển email" +wordMute: "Ẩn chữ" +regexpError: "Lỗi biểu thức" +regexpErrorDescription: "Xảy ra lỗi biểu thức ở dòng {line} của {tab} chữ ẩn:" +instanceMute: "Những máy chủ ẩn" +userSaysSomething: "{name} nói gì đó" +makeActive: "Kích hoạt" +display: "Hiển thị" +copy: "Sao chép" +metrics: "Số liệu" +overview: "Tổng quan" +logs: "Nhật ký" +delayed: "Độ trễ" +database: "Cơ sở dữ liệu" +channel: "Kênh" +create: "Tạo" +notificationSetting: "Cài đặt thông báo" +notificationSettingDesc: "Chọn loại thông báo bạn muốn hiển thị." +useGlobalSetting: "Dùng thiết lập chung" +useGlobalSettingDesc: "Nếu được bật, cài đặt thông báo của bạn sẽ được áp dụng. Nếu bị tắt, có thể thực hiện các thiết lập riêng lẻ." +other: "Khác" +regenerateLoginToken: "Tạo lại mã đăng nhập" +regenerateLoginTokenDescription: "Tạo lại mã nội bộ có thể dùng để đăng nhập. Thông thường hành động này là không cần thiết. Nếu được tạo lại, tất cả các thiết bị sẽ bị đăng xuất." +setMultipleBySeparatingWithSpace: "Tách nhiều mục nhập bằng dấu cách." +fileIdOrUrl: "ID tập tin hoặc URL" +behavior: "Thao tác" +sample: "Ví dụ" +abuseReports: "Lượt báo cáo" +reportAbuse: "Báo cáo" +reportAbuseOf: "Báo cáo {name}" +fillAbuseReportDescription: "Vui lòng điền thông tin chi tiết về báo cáo này. Nếu đó là về một tút cụ thể, hãy kèm theo URL của tút." +abuseReported: "Báo cáo đã được gửi. Cảm ơn bạn nhiều." +reporter: "Người báo cáo" +reporteeOrigin: "Bị báo cáo" +reporterOrigin: "Máy chủ người báo cáo" +forwardReport: "Chuyển tiếp báo cáo cho máy chủ từ xa" +forwardReportIsAnonymous: "Thay vì tài khoản của bạn, một tài khoản hệ thống ẩn danh sẽ được hiển thị dưới dạng người báo cáo ở máy chủ từ xa." +send: "Gửi" +abuseMarkAsResolved: "Đánh dấu đã xử lý" +openInNewTab: "Mở trong tab mới" +openInSideView: "Mở trong thanh bên" +defaultNavigationBehaviour: "Thao tác điều hướng mặc định" +editTheseSettingsMayBreakAccount: "Việc chỉnh sửa các cài đặt này có thể làm hỏng tài khoản của bạn." +instanceTicker: "Thông tin máy chủ của tút" +waitingFor: "Đang đợi {x}" +random: "Ngẫu nhiên" +system: "Hệ thống" +switchUi: "Chuyển đổi giao diện người dùng" +desktop: "Desktop" +clip: "Ghim" +createNew: "Tạo mới" +optional: "Không bắt buộc" +createNewClip: "Tạo một ghim mới" +unclip: "Bỏ ghim" +confirmToUnclipAlreadyClippedNote: "Bài đăng này là một phần của \"{name}\" ghim. Bạn có muốn bỏ khỏi ghim?" +public: "Công khai" +i18nInfo: "Iceshrimp đang được các tình nguyện viên dịch sang nhiều thứ tiếng khác nhau. Bạn có thể hỗ trợ tại {link}." +manageAccessTokens: "Tạo mã truy cập" +accountInfo: "Thông tin tài khoản" +notesCount: "Số lượng tút" +repliesCount: "Số lượt trả lời đã gửi" +renotesCount: "Số lượt đăng lại đã gửi" +repliedCount: "Số lượt trả lời đã nhận" +renotedCount: "Lượt chia sẻ" +followingCount: "Số lượng người tôi theo dõi" +followersCount: "Số lượng người theo dõi tôi" +sentReactionsCount: "Số lượng biểu cảm đã gửi" +receivedReactionsCount: "Số lượng biểu cảm đã nhận" +pollVotesCount: "Số lượng bình chọn đã gửi" +pollVotedCount: "Số lượng bình chọn đã nhận" +yes: "Đồng ý" +no: "Từ chối" +driveFilesCount: "Số tập tin trong Ổ đĩa" +driveUsage: "Dung lượng ổ đĩa" +noCrawle: "Từ chối lập chỉ mục" +noCrawleDescription: "Không cho công cụ tìm kiếm lập chỉ mục trang hồ sơ, tút, Trang, etc." +lockedAccountInfo: "Ghi chú của bạn sẽ hiển thị với bất kỳ ai, trừ khi bạn đặt chế độ hiển thị tút của mình thành \"Chỉ người theo dõi\"." +alwaysMarkSensitive: "Luôn đánh dấu NSFW" +loadRawImages: "Tải ảnh gốc thay vì ảnh thu nhỏ" +disableShowingAnimatedImages: "Không phát ảnh động" +verificationEmailSent: "Một email xác minh đã được gửi. Vui lòng nhấn vào liên kết đính kèm để hoàn tất xác minh." +notSet: "Chưa đặt" +emailVerified: "Email đã được xác minh" +noteFavoritesCount: "Số lượng tút yêu thích" +pageLikesCount: "Số lượng trang đã thích" +pageLikedCount: "Số lượng thích trang đã nhận" +contact: "Liên hệ" +useSystemFont: "Dùng phông chữ mặc định của hệ thống" +clips: "Ghim" +experimentalFeatures: "Tính năng thử nghiệm" +developer: "Nhà phát triển" +makeExplorable: "Không hiện tôi trong \"Khám phá\"" +makeExplorableDescription: "Nếu bạn tắt, tài khoản của bạn sẽ không hiện trong mục \"Khám phá\"." +showGapBetweenNotesInTimeline: "Hiện dải phân cách giữa các tút trên bảng tin" +duplicate: "Tạo bản sao" +left: "Bên trái" +center: "Giữa" +wide: "Rộng" +narrow: "Thu hẹp" +reloadToApplySetting: "Cài đặt này sẽ chỉ áp dụng sau khi tải lại trang. Tải lại ngay bây giờ?" +needReloadToApply: "Cần tải lại để điều này được áp dụng." +showTitlebar: "Hiện thanh tựa đề" +clearCache: "Xóa bộ nhớ đệm" +onlineUsersCount: "{n} người đang online" +nUsers: "{n} Người" +nNotes: "{n} Tút" +sendErrorReports: "Báo lỗi" +sendErrorReportsDescription: "Khi được bật, thông tin chi tiết về lỗi sẽ được chia sẻ với Iceshrimp khi xảy ra sự cố, giúp nâng cao chất lượng của Iceshrimp.\nBao gồm thông tin như phiên bản hệ điều hành của bạn, trình duyệt bạn đang sử dụng, hoạt động của bạn trong Iceshrimp, v.v." +myTheme: "Theme của tôi" +backgroundColor: "Màu nền" +accentColor: "Màu phụ" +textColor: "Màu chữ" +saveAs: "Lưu thành" +advanced: "Nâng cao" +value: "Giá trị" +createdAt: "Ngày tạo" +updatedAt: "Cập nhật lúc" +saveConfirm: "Lưu thay đổi?" +deleteConfirm: "Bạn có muốn xóa không?" +invalidValue: "Giá trị không hợp lệ." +registry: "Registry" +closeAccount: "Đóng tài khoản" +currentVersion: "Phiên bản hiện tại" +latestVersion: "Phiên bản mới nhất" +youAreRunningUpToDateClient: "Bạn đang sử dụng phiên bản mới nhất." +newVersionOfClientAvailable: "Có phiên bản mới cho bạn cập nhật." +usageAmount: "Sử dụng" +capacity: "Sức chứa" +inUse: "Đã dùng" +editCode: "Chỉnh sửa mã" +apply: "Áp dụng" +receiveAnnouncementFromInstance: "Nhận thông báo từ máy chủ này" +emailNotification: "Thông báo email" +publish: "Đăng" +inChannelSearch: "Tìm trong kênh" +useReactionPickerForContextMenu: "Nhấn chuột phải để mở bộ chọn biểu cảm" +typingUsers: "{users} đang nhập" +jumpToSpecifiedDate: "Đến một ngày cụ thể" +showingPastTimeline: "Hiện đang hiển thị dòng thời gian cũ" +clear: "Hoàn lại" +markAllAsRead: "Đánh dấu tất cả đã đọc" +goBack: "Quay lại" +unlikeConfirm: "Bạn có chắc muốn bỏ thích ?" +fullView: "Kích thước đầy đủ" +quitFullView: "Thoát toàn màn hình" +addDescription: "Thêm mô tả" +userPagePinTip: "Bạn có thể hiển thị các tút ở đây bằng cách chọn \"Ghim vào hồ sơ\" từ menu của mỗi tút." +notSpecifiedMentionWarning: "Tút này có đề cập đến những người không mong muốn" +info: "Giới thiệu" +userInfo: "Thông tin người dùng" +unknown: "Chưa biết" +onlineStatus: "Trạng thái" +hideOnlineStatus: "Ẩn trạng thái online" +hideOnlineStatusDescription: "Ẩn trạng thái online của bạn làm giảm sự tiện lợi của một số tính năng như tìm kiếm." +online: "Online" +active: "Hoạt động" +offline: "Offline" +notRecommended: "Không đề xuất" +botProtection: "Bảo vệ Bot" +instanceBlocking: "Máy chủ đã chặn" +selectAccount: "Chọn một tài khoản" +switchAccount: "Chuyển tài khoản" +enabled: "Đã bật" +disabled: "Đã tắt" +quickAction: "Thao tác nhanh" +user: "Người dùng" +administration: "Quản lý" +accounts: "Tài khoản của bạn" +switch: "Chuyển đổi" +noMaintainerInformationWarning: "Chưa thiết lập thông tin vận hành." +noBotProtectionWarning: "Bảo vệ Bot chưa thiết lập." +configure: "Thiết lập" +postToGallery: "Tạo tút có ảnh" +gallery: "Thư viện ảnh" +recentPosts: "Tút gần đây" +popularPosts: "Tút được xem nhiều nhất" +shareWithNote: "Chia sẻ kèm với tút" +ads: "Quảng cáo" +expiration: "Thời hạn" +memo: "Lưu ý" +priority: "Ưu tiên" +high: "Cao" +middle: "Vừa" +low: "Thấp" +emailNotConfiguredWarning: "Chưa đặt địa chỉ email." +ratio: "Tỷ lệ" +previewNoteText: "Hiện xem trước" +customCss: "Tùy chỉnh CSS" +customCssWarn: "Chỉ sử dụng những cài đặt này nếu bạn biết rõ về nó. Việc nhập các giá trị không đúng có thể khiến máy chủ hoạt động không bình thường." +global: "Toàn cầu" +squareAvatars: "Ảnh đại diện vuông" +sent: "Gửi" +received: "Đã nhận" +searchResult: "Kết quả tìm kiếm" +hashtags: "Hashtag" +troubleshooting: "Khắc phục sự cố" +useBlurEffect: "Dùng hiệu ứng làm mờ trong giao diện" +learnMore: "Tìm hiểu thêm" +iceshrimpUpdated: "Iceshrimp vừa được cập nhật!" +whatIsNew: "Hiện những thay đổi" +translate: "Dịch" +translatedFrom: "Dịch từ {x}" +accountDeletionInProgress: "Đang xử lý việc xóa tài khoản" +usernameInfo: "Bạn có thể sử dụng chữ cái (a ~ z, A ~ Z), chữ số (0 ~ 9) hoặc dấu gạch dưới (_). Tên người dùng không thể thay đổi sau này." +aiChanMode: "Chế độ Ai" +keepCw: "Giữ cảnh báo nội dung" +pubSub: "Tài khoản Chính/Phụ" +lastCommunication: "Lần giao tiếp cuối" +resolved: "Đã xử lý" +unresolved: "Chờ xử lý" +breakFollow: "Xóa người theo dõi" +itsOn: "Đã bật" +itsOff: "Đã tắt" +emailRequiredForSignup: "Yêu cầu địa chỉ email khi đăng ký" +unread: "Chưa đọc" +filter: "Bộ lọc" +controlPanel: "Bảng điều khiển" +manageAccounts: "Quản lý tài khoản" +makeReactionsPublic: "Đặt lịch sử biểu cảm công khai" +makeReactionsPublicDescription: "Điều này sẽ hiển thị công khai danh sách tất cả các biểu cảm trước đây của bạn." +classic: "Cổ điển" +muteThread: "Không quan tâm nữa" +unmuteThread: "Quan tâm tút này" +ffVisibility: "Hiển thị Theo dõi/Người theo dõi" +ffVisibilityDescription: "Quyết định ai có thể xem những người bạn theo dõi và những người theo dõi bạn." +continueThread: "Tiếp tục xem chuỗi tút" +deleteAccountConfirm: "Điều này sẽ khiến tài khoản bị xóa vĩnh viễn. Vẫn tiếp tục?" +incorrectPassword: "Sai mật khẩu." +voteConfirm: "Xác nhận bình chọn \"{choice}\"?" +hide: "Ẩn" +leaveGroup: "Rời khỏi nhóm" +leaveGroupConfirm: "Bạn có chắc muốn rời khỏi nhóm \"{name}\"?" +useDrawerReactionPickerForMobile: "Hiện bộ chọn biểu cảm dạng xổ ra trên điện thoại" +clickToFinishEmailVerification: "Vui lòng nhấn [{ok}] để hoàn tất việc đăng ký." +overridedDeviceKind: "Loại thiết bị" +smartphone: "Điện thoại" +tablet: "Máy tính bảng" +auto: "Tự động" +themeColor: "Màu theme" +size: "Kích thước" +numberOfColumn: "Số lượng cột" +searchByGoogle: "Google" +instanceDefaultLightTheme: "Theme máy chủ Sáng-Rộng" +instanceDefaultDarkTheme: "Theme máy chủ Tối-Rộng" +instanceDefaultThemeDescription: "Nhập mã theme trong định dạng đối tượng." +mutePeriod: "Thời hạn ẩn" +indefinitely: "Vĩnh viễn" +tenMinutes: "10 phút" +oneHour: "1 giờ" +oneDay: "1 ngày" +oneWeek: "1 tuần" +reflectMayTakeTime: "Có thể mất một thời gian để điều này được áp dụng." +failedToFetchAccountInformation: "Không thể lấy thông tin tài khoản" +rateLimitExceeded: "Giới hạn quá mức" +cropImage: "Cắt hình ảnh" +cropImageAsk: "Bạn có muốn cắt ảnh này?" +file: "Tập tin" +recentNHours: "{n}h trước" +recentNDays: "{n} ngày trước" +noEmailServerWarning: "Chưa đặt máy chủ email." +thereIsUnresolvedAbuseReportWarning: "Có báo cáo chưa xử lí." +recommended: "Được đề xuất" +check: "Kiểm tra" +driveCapOverrideLabel: "Thay đổi dung lượng drive cho người này" +driveCapOverrideCaption: "Đặt dung lượng drive về mặc định bằng cách nhập 0 hoặc số âm." +requireAdminForView: "Bạn phải đăng nhập như là quản trị viên mới xem được." +isSystemAccount: "Đã tạo một tài khoản và tự động vận hành bởi hệ thống." +typeToConfirm: "Nhấn {x} để xác nhận" +deleteAccount: "Xóa tài khoản" +document: "Tài liệu" +numberOfPageCache: "Số lượng trang bộ nhớ đệm" +numberOfPageCacheDescription: "Việc tăng con số này sẽ cải thiện sự thuận tiện cho người dùng nhưng gây ra nhiều áp lực hơn cho máy chủ cũng như sử dụng nhiều bộ nhớ hơn." +logoutConfirm: "Bạn có chắc muốn đăng xuất?" +lastActiveDate: "Lần cuối vào" +statusbar: "Thanh trạng thái" +pleaseSelect: "Chọn một lựa chọn" +reverse: "Lật" +colored: "Với màu" +refreshInterval: "Cập nhật nội bộ" +label: "Nhãn" +type: "Loại" +speed: "Tốc độ" +slow: "Chậm" +fast: "Nhanh" +sensitiveMediaDetection: "Tự động phát hiện NSFW" +localOnly: "Chỉ trên máy chủ" +remoteOnly: "Chỉ máy chủ từ xa" +failedToUpload: "Tải lên thất bại" +cannotUploadBecauseInappropriate: "Không thể tải lên tập tin này vì các phần của tập tin đã được phát hiện có khả năng là NSFW." +cannotUploadBecauseNoFreeSpace: "Tải lên không thành công do thiếu dung lượng Drive." +beta: "Beta" +enableAutoSensitive: "Tự động đánh dấu NSFW" +enableAutoSensitiveDescription: "Cho phép tự động phát hiện và đánh dấu media NSFW thông qua học máy, nếu có thể. Ngay cả khi tùy chọn này bị tắt, nó vẫn có thể được bật trên toàn máy chủ." +activeEmailValidationDescription: "Cho phép xác minh địa chỉ email chặt chẽ hơn, bao gồm việc kiểm tra các địa chỉ dùng một lần và xem nó có thực sự được giao tiếp hay không. Khi bỏ chọn, chỉ định dạng của email được xác minh." +navbar: "Thanh điều hướng" +shuffle: "Xáo trộn" +account: "Tài khoản của bạn" +move: "Di chuyển" +_sensitiveMediaDetection: + description: "Giảm nỗ lực kiểm duyệt máy chủ thông qua việc tự động nhận dạng media NSFW thông qua học máy. Điều này sẽ làm tăng một chút áp lực trên máy chủ." + sensitivity: "Phát hiện nhạy cảm" + sensitivityDescription: "Giảm độ nhạy sẽ dẫn đến ít phát hiện sai hơn (dương tính giả), tăng nó sẽ dẫn đến ít phát hiện sai hơn (âm tính giả)." + setSensitiveFlagAutomatically: "Đánh dấu là NSFW" + setSensitiveFlagAutomaticallyDescription: "Kết quả của phát hiện nội bộ sẽ được giữ lại ngay cả khi tùy chọn này bị tắt." + analyzeVideos: "Bật chuẩn đoán video" + analyzeVideosDescription: "Phân tích video bên cạnh hình ảnh. Điều này sẽ làm tăng một chút áp lực trên máy chủ." +_emailUnavailable: + used: "Địa chỉ email đã được sử dụng" + format: "Địa chỉ email không hợp lệ" + disposable: "Cấm sử dụng địa chỉ email dùng một lần" + mx: "Máy chủ email không hợp lệ" + smtp: "Máy chủ email không phản hồi" +_ffVisibility: + public: "Đăng" + followers: "Chỉ người theo dõi mới xem được" + private: "Riêng tư" +_signup: + almostThere: "Gần xong rồi" + emailAddressInfo: "Hãy điền địa chỉ email của bạn. Nó sẽ không được công khai." + emailSent: "Một email xác minh đã được gửi đến địa chỉ email ({email}) của bạn. Vui lòng nhấn vào liên kết trong đó để hoàn tất việc tạo tài khoản." +_accountDelete: + accountDelete: "Xóa tài khoản" + mayTakeTime: "Vì xóa tài khoản là một quá trình tốn nhiều tài nguyên nên có thể mất một khoảng thời gian để hoàn thành, tùy thuộc vào lượng nội dung bạn đã tạo và số lượng tập tin bạn đã tải lên." + sendEmail: "Sau khi hoàn tất việc xóa tài khoản, một email sẽ được gửi đến địa chỉ email đã đăng ký tài khoản này." + requestAccountDelete: "Yêu cầu xóa tài khoản" + started: "Đang bắt đầu xóa tài khoản." + inProgress: "Đang xóa dần tài khoản." +_ad: + back: "Quay lại" + reduceFrequencyOfThisAd: "Hiện ít lại" +_forgotPassword: + enterEmail: "Nhập địa chỉ email bạn đã sử dụng để đăng ký. Một liên kết mà bạn có thể đặt lại mật khẩu của mình sau đó sẽ được gửi đến nó." + ifNoEmail: "Nếu bạn không sử dụng email lúc đăng ký, vui lòng liên hệ với quản trị viên." + contactAdmin: "Máy chủ này không hỗ trợ sử dụng địa chỉ email, vui lòng liên hệ với quản trị viên để đặt lại mật khẩu của bạn." +_gallery: + my: "Kho Ảnh" + liked: "Tút Đã Thích" + like: "Thích" + unlike: "Bỏ thích" +_email: + _follow: + title: "đã theo dõi bạn" + _receiveFollowRequest: + title: "Chấp nhận yêu cầu theo dõi" +_plugin: + install: "Cài đặt tiện ích" + installWarn: "Vui lòng không cài đặt những tiện ích đáng ngờ." + manage: "Quản lý plugin" +_preferencesBackups: + list: "Tạo sao lưu" + saveNew: "Lưu bản sao lưu" + loadFile: "Nhập tập tin" + apply: "Áp dụng lên thiết bị này" + save: "Lưu thay đổi" + inputName: "Nhập tên bản sao lưu" + cannotSave: "Không thể lưu" + nameAlreadyExists: "Bản sao lưu \"{name}\" đã tồn tại. Xin nhập tên khác." + applyConfirm: "Bạn có chắc muốn áp dụng bản sao lưu \"{name}\" cho thiết bị này? Thiết lập hiện tại sẽ bị ghi đè." + saveConfirm: "Lưu bản sao lưu {name}?" + deleteConfirm: "Xóa bản sao lưu {name}?" + renameConfirm: "Đổi tên bản sao lưu \"{old}\" thành \"{new}\"?" + noBackups: "Chưa có bản sao lưu. Bạn có thể sao lưu thiết lập trên máy chủ này bằng cách sử dụng \"Tạo sao lưu\"." + createdAt: "Tạo vào: {time} {date}" + updatedAt: "Cập nhật: {time} {date}" + cannotLoad: "Tải thất bại" + invalidFile: "Sai định dạng tập tin" +_registry: + scope: "Phạm vi" + key: "Mã" + keys: "Các mã" + domain: "Tên miền" + createKey: "Tạo mã" +_aboutIceshrimp: + about: "Iceshrimp là phần mềm mã nguồn mở được phát triển bởi syuilo từ năm 2014." + contributors: "Những người đóng góp nổi bật" + allContributors: "Toàn bộ người đóng góp" + source: "Mã nguồn" + translation: "Dịch Iceshrimp" + donate: "Ủng hộ Iceshrimp" + morePatrons: "Chúng tôi cũng trân trọng sự hỗ trợ của nhiều người đóng góp khác không được liệt kê ở đây. Cảm ơn! 🥰" + patrons: "Người ủng hộ" +_nsfw: + respect: "Ẩn nội dung NSFW" + ignore: "Hiện nội dung NSFW" + force: "Ẩn mọi media" +_mfm: + cheatSheet: "MFM Cheatsheet" + intro: "MFM là ngôn ngữ phát triển độc quyền của Iceshrimp có thể được sử dụng ở nhiều nơi. Tại đây bạn có thể xem danh sách tất cả các cú pháp MFM có sẵn." + dummy: "Iceshrimp mở rộng thế giới Fediverse" + mention: "Nhắc đến" + mentionDescription: "Bạn có thể nhắc đến ai đó bằng cách sử dụng @tên người dùng." + hashtag: "Hashtag" + hashtagDescription: "Bạn có thể tạo một hashtag bằng #chữ hoặc #số." + url: "URL" + urlDescription: "Những URL có thể hiển thị." + link: "Đường dẫn" + linkDescription: "Các phần cụ thể của văn bản có thể được hiển thị dưới dạng URL." + bold: "In đậm" + boldDescription: "Nổi bật các chữ cái bằng cách làm chúng dày hơn." + small: "Nhỏ" + smallDescription: "Hiển thị nội dung nhỏ và mỏng." + center: "Giữa" + centerDescription: "Hiển thị nội dung căn giữa." + inlineCode: "Mã (Trong dòng)" + inlineCodeDescription: "Hiển thị tô sáng cú pháp trong dòng cho mã (chương trình)." + blockCode: "Mã (Khối)" + blockCodeDescription: "Hiển thị tô sáng cú pháp cho mã nhiều dòng (chương trình) trong một khối." + inlineMath: "Toán học (Trong dòng)" + inlineMathDescription: "Hiển thị công thức toán (KaTeX) trong dòng" + blockMath: "Toán học (Khối)" + blockMathDescription: "Hiển thị công thức toán học nhiều dòng (KaTeX) trong một khối" + quote: "Trích dẫn" + quoteDescription: "Hiển thị nội dung dạng lời trích dạng." + emoji: "Tùy chỉnh emoji" + emojiDescription: "Hiển thị emoji với cú pháp :tên emoji:" + search: "Tìm kiếm" + searchDescription: "Hiển thị hộp tìm kiếm với văn bản được nhập trước." + flip: "Lật" + flipDescription: "Lật nội dung theo chiều ngang hoặc chiều dọc." + jelly: "Chuyển động (Thạch rau câu)" + jellyDescription: "Cho phép nội dung chuyển động giống như thạch rau câu." + tada: "Chuyển động (Tada)" + tadaDescription: "Cho phép nội dung chuyển động kiểu \"Tada!\"." + jump: "Chuyển động (Nhảy múa)" + jumpDescription: "Cho phép nội dung chuyển động nhảy nhót." + bounce: "Chuyển động (Cà tưng)" + bounceDescription: "Cho phép nội dung chuyển động cà tưng." + shake: "Chuyển động (Rung)" + shakeDescription: "Cho phép nội dung chuyển động rung lắc." + twitch: "Chuyển động (Co rút)" + twitchDescription: "Cho phép nội dung chuyển động co rút." + spin: "Chuyển động (Xoay tít)" + spinDescription: "Cho phép nội dung chuyển động xoay tít." + x2: "Lớn" + x2Description: "Hiển thị nội dung cỡ lớn hơn." + x3: "Rất lớn" + x3Description: "Hiển thị nội dung cỡ lớn hơn nữa." + x4: "Khổng lồ" + x4Description: "Hiển thị nội dung cỡ khổng lồ." + blur: "Làm mờ" + blurDescription: "Làm mờ nội dung. Nó sẽ được hiển thị rõ ràng khi di chuột qua." + font: "Phông chữ" + fontDescription: "Chọn phông chữ để hiển thị nội dung." + rainbow: "Cầu vồng" + rainbowDescription: "Làm cho nội dung hiển thị với màu sắc cầu vồng." + sparkle: "Lấp lánh" + sparkleDescription: "Làm cho nội dung hiệu ứng hạt lấp lánh." + rotate: "Xoay" + rotateDescription: "Xoay nội dung theo một góc cụ thể." + plain: "Đơn giản" + plainDescription: "Vô hiệu hóa mọi hiệu ứng MFM chứa trong hiệu ứng MFM này." +_instanceTicker: + none: "Không hiển thị" + remote: "Hiện cho người dùng từ máy chủ khác" + always: "Luôn hiện" +_serverDisconnectedBehavior: + reload: "Tự động tải lại" + dialog: "Hiện hộp thoại cảnh báo" + quiet: "Hiển thị cảnh báo không phô trương" +_channel: + create: "Tạo kênh" + edit: "Chỉnh sửa kênh" + setBanner: "Đặt ảnh bìa" + removeBanner: "Xóa ảnh bìa" + featured: "Xu hướng" + owned: "Do tôi quản lý" + following: "Đang theo dõi" + usersCount: "{n} Thành viên" + notesCount: "{n} Tút" +_menuDisplay: + sideFull: "Thanh bên" + sideIcon: "Thanh bên (Biểu tượng)" + top: "Trên cùng" + hide: "Ẩn" +_wordMute: + muteWords: "Ẩn từ ngữ" + muteWordsDescription: "Separate with spaces for an AND condition or with line breaks for an OR condition." + muteWordsDescription2: "Bao quanh các từ khóa bằng dấu gạch chéo để sử dụng cụm từ thông dụng." + softDescription: "Ẩn các tút phù hợp điều kiện đã đặt khỏi bảng tin." + hardDescription: "Ngăn các tút đáp ứng các điều kiện đã đặt xuất hiện trên bảng tin. Lưu ý, những tút này sẽ không được thêm vào bảng tin ngay cả khi các điều kiện được thay đổi." + soft: "Yếu" + hard: "Mạnh" + mutedNotes: "Những tút đã ẩn" +_instanceMute: + instanceMuteDescription: "Thao tác này sẽ ẩn mọi tút/lượt đăng lại từ các máy chủ được liệt kê, bao gồm cả những tút dạng trả lời từ máy chủ bị ẩn." + instanceMuteDescription2: "Tách bằng cách xuống dòng" + title: "Ẩn tút từ những máy chủ đã liệt kê." + heading: "Danh sách những máy chủ bị ẩn" +_theme: + explore: "Khám phá theme" + install: "Cài đặt theme" + manage: "Quản lý theme" + code: "Mã theme" + description: "Mô tả" + installed: "{name} đã được cài đặt" + installedThemes: "Theme đã cài đặt" + builtinThemes: "Theme tích hợp sẵn" + alreadyInstalled: "Theme này đã được cài đặt" + invalid: "Định dạng của theme này không hợp lệ" + make: "Tạo theme" + base: "Dựa trên có sẵn" + addConstant: "Thêm hằng số" + constant: "Hằng số" + defaultValue: "Giá trị mặc định" + color: "Màu sắc" + refProp: "Tham chiếu một thuộc tính" + refConst: "Tham chiếu một hằng số" + key: "Khóa" + func: "Hàm" + funcKind: "Loại hàm" + argument: "Tham số" + basedProp: "Thuộc tính tham chiếu" + alpha: "Độ trong suốt" + darken: "Độ tối" + lighten: "Độ sáng" + inputConstantName: "Nhập tên cho hằng số này" + importInfo: "Nếu bạn nhập mã theme ở đây, bạn có thể nhập mã đó vào trình chỉnh sửa theme" + deleteConstantConfirm: "Bạn có chắc muốn xóa hằng số {const} không?" + keys: + accent: "Màu phụ" + bg: "Màu nền" + fg: "Màu chữ" + focus: "Trọng tâm" + indicator: "Chỉ báo" + panel: "Thanh bên" + shadow: "Bóng mờ" + header: "Ảnh bìa" + navBg: "Nền thanh bên" + navFg: "Chữ thanh bên" + navHoverFg: "Chữ thanh bên (Khi chạm)" + navActive: "Chữ thanh bên (Khi chọn)" + navIndicator: "Chỉ báo thanh bên" + link: "Đường dẫn" + hashtag: "Hashtag" + mention: "Nhắc đến" + mentionMe: "Lượt nhắc (Tôi)" + renote: "Đăng lại" + modalBg: "Nền phương thức" + divider: "Phân chia" + scrollbarHandle: "Thanh cuộn khi giữ" + scrollbarHandleHover: "Thanh cuộn khi chạm" + dateLabelFg: "Màu ngày tháng năm" + infoBg: "Nền thông tin" + infoFg: "Chữ thông tin" + infoWarnBg: "Nền cảnh báo" + infoWarnFg: "Chữ cảnh báo" + cwBg: "Nền nút nội dung ẩn" + cwFg: "Chữ nút nội dung ẩn" + cwHoverBg: "Nền nút nội dung ẩn (Chạm)" + toastBg: "Nền thông báo" + toastFg: "Chữ thông báo" + buttonBg: "Nền nút" + buttonHoverBg: "Nền nút (Chạm)" + inputBorder: "Đường viền khung soạn thảo" + listItemHoverBg: "Nền mục liệt kê (Chạm)" + driveFolderBg: "Nền thư mục Ổ đĩa" + wallpaperOverlay: "Lớp phủ hình nền" + badge: "Huy hiệu" + messageBg: "Nền chat" + accentDarken: "Màu phụ (Tối)" + accentLighten: "Màu phụ (Sáng)" + fgHighlighted: "Chữ nổi bật" +_sfx: + note: "Tút" + noteMy: "Tút của tôi" + notification: "Thông báo" + chat: "Trò chuyện" + chatBg: "Chat (Nền)" + antenna: "Trạm phát sóng" + channel: "Kênh" +_ago: + future: "Tương lai" + justNow: "Vừa xong" + secondsAgo: "{n}s trước" + minutesAgo: "{n} phút {n2}s trước" + hoursAgo: "{n} giờ {n2} phút trước" + daysAgo: "{n} ngày {n2} giờ trước" + weeksAgo: "{n} tuần {n2} ngày trước" + monthsAgo: "{n} tháng {n2} tuần trước" + yearsAgo: "{n} năm {n2} tháng trước" +_time: + second: "s" + minute: "phút" + hour: "giờ" + day: "ngày" +_tutorial: + title: "How to use Iceshrimp" + step1_1: "Welcome!" + step1_2: "Let's get you set up. You'll be up and running in no time!" + step2_1: "First, please fill out your profile." + step2_2: "Providing some information about who you are will make it easier for others to tell if they want to see your notes or follow you." + step3_1: "Now time to follow some people!" + step3_2: "Your home and social timelines are based off of who you follow, so try following a couple accounts to get started.\nClick the plus circle on the top right of a profile to follow them." + step4_1: "Let's get you out there." + step4_2: "For your first post, some people like to made a {introduction} post or a simple \"Hello world!\"" + step5_1: "Timelines, timelines everywhere!" + step5_2: "Your instance has {timelines} different timelines enabled." + step5_3: "The Home {icon} timeline is where you can see posts from your followers." + step5_4: "The Local {icon} timeline is where you can see posts from everyone else on this instance." + step5_5: "The Recommended {icon} timeline is where you can see posts from instances the admins recommend." + step5_6: "The Social {icon} timeline is where you can see posts from friends of your followers." + step5_7: "The Global {icon} timeline is where you can see posts from every other connected instance." + step6_1: "So, what is this place?" + step6_2: "Well, you didn't just join Iceshrimp. You joined a portal to the Fediverse, an interconnected network of thousands of servers, called \"instances\"." + step6_3: "Each server works in different ways, and not all servers run Iceshrimp. This one does though! It's a bit complicated, but you'll get the hang of it in no time." + step6_4: "Now go, explore, and have fun!" +_2fa: + alreadyRegistered: "Bạn đã đăng ký thiết bị xác minh 2 bước." + registerTOTP: "Đăng ký một thiết bị" + registerSecurityKey: "Đăng ký một mã bảo vệ" + step1: "Trước tiên, hãy cài đặt một ứng dụng xác minh (chẳng hạn như {a} hoặc {b}) trên thiết bị của bạn." + step2: "Sau đó, quét mã QR hiển thị trên màn hình này." + step2Url: "Bạn cũng có thể nhập URL này nếu sử dụng một chương trình máy tính:" + step3: "Nhập mã token do ứng dụng của bạn cung cấp để hoàn tất thiết lập." + step4: "Kể từ bây giờ, những lần đăng nhập trong tương lai sẽ yêu cầu mã token đăng nhập đó." + securityKeyInfo: "Bên cạnh xác minh bằng vân tay hoặc mã PIN, bạn cũng có thể thiết lập xác minh thông qua khóa bảo mật phần cứng hỗ trợ FIDO2 để bảo mật hơn nữa cho tài khoản của mình." +_permissions: + "read:account": "Xem thông tin tài khoản của bạn" + "write:account": "Sửa thông tin tài khoản của bạn" + "read:blocks": "Xem danh sách người bạn chặn" + "write:blocks": "Sửa danh sách người bạn chặn" + "read:drive": "Truy cập tập tin, thư mục trong Ổ đĩa" + "write:drive": "Sửa và xóa tập tin, thư mục trong Ổ đĩa" + "read:favorites": "Xem lượt thích của tôi" + "write:favorites": "Sửa lượt thích của tôi" + "read:following": "Xem những người bạn theo dõi" + "write:following": "Theo dõi hoặc ngưng theo dõi ai đó" + "read:messaging": "Xem lịch sử chat" + "write:messaging": "Soạn hoặc xóa tin nhắn" + "read:mutes": "Xem những người bạn ẩn" + "write:mutes": "Sửa những người bạn ẩn" + "write:notes": "Soạn hoặc xóa tút" + "read:notifications": "Xem thông báo của tôi" + "write:notifications": "Quản lý thông báo của tôi" + "read:reactions": "Xem lượt biểu cảm của tôi" + "write:reactions": "Sửa lượt biểu cảm của tôi" + "write:votes": "Bình chọn" + "read:pages": "Xem trang của tôi" + "write:pages": "Sửa hoặc xóa trang của tôi" + "read:page-likes": "Xem lượt thích trên trang của tôi" + "write:page-likes": "Sửa lượt thích của tôi trên trang" + "read:user-groups": "Xem nhóm của tôi" + "write:user-groups": "Sửa hoặc xóa nhóm của tôi" + "read:channels": "Xem kênh của tôi" + "write:channels": "Sửa kênh của tôi" + "read:gallery": "Xem kho ảnh của tôi" + "write:gallery": "Sửa kho ảnh của tôi" + "read:gallery-likes": "Xem danh sách các tút đã thích trong thư viện của tôi" + "write:gallery-likes": "Sửa danh sách các tút đã thích trong thư viện của tôi" +_auth: + shareAccess: "Bạn có muốn cho phép \"{name}\" truy cập vào tài khoản này không?" + shareAccessAsk: "Bạn có chắc muốn cho phép ứng dụng này truy cập vào tài khoản của mình không?" + permissionAsk: "Ứng dụng này yêu cầu các quyền sau" + pleaseGoBack: "Vui lòng quay lại ứng dụng" + callback: "Quay lại ứng dụng" + denied: "Truy cập bị từ chối" +_antennaSources: + all: "Toàn bộ tút" + homeTimeline: "Tút từ những người đã theo dõi" + users: "Tút từ những người cụ thể" + userList: "Tút từ danh sách người dùng cụ thể" + userGroup: "Tút từ người dùng trong một nhóm cụ thể" +_weekday: + sunday: "Chủ Nhật" + monday: "Thứ Hai" + tuesday: "Thứ Ba" + wednesday: "Thứ Tư" + thursday: "Thứ Năm" + friday: "Thứ Sáu" + saturday: "Thứ Bảy" +_widgets: + memo: "Tút đã ghim" + notifications: "Thông báo" + timeline: "Bảng tin" + calendar: "Lịch" + trends: "Xu hướng" + clock: "Đồng hồ" + rss: "Trình đọc RSS" + rssTicker: "RSS-Ticker" + activity: "Hoạt động" + photos: "Kho ảnh" + digitalClock: "Đồng hồ số" + unixClock: "Đồng hồ UNIX" + federation: "Liên hợp" + postForm: "Mẫu đăng" + slideshow: "Trình chiếu" + button: "Nút" + onlineUsers: "Ai đang online" + jobQueue: "Công việc chờ xử lý" + serverMetric: "Thống kê máy chủ" + aiscript: "AiScript console" + aichan: "Ai" +_cw: + hide: "Ẩn" + show: "Tải thêm" + chars: "{count} ký tự" + files: "{count} tập tin" +_poll: + noOnlyOneChoice: "Cần ít nhất hai lựa chọn." + choiceN: "Lựa chọn {n}" + noMore: "Bạn không thể thêm lựa chọn" + canMultipleVote: "Cho phép chọn nhiều lựa chọn" + expiration: "Thời hạn" + infinite: "Vĩnh viễn" + at: "Kết thúc vào..." + after: "Kết thúc sau..." + deadlineDate: "Ngày kết thúc" + deadlineTime: "giờ" + duration: "Thời hạn" + votesCount: "{n} bình chọn" + totalVotes: "{n} tổng bình chọn" + vote: "Bình chọn" + showResult: "Xem kết quả" + voted: "Đã bình chọn" + closed: "Đã kết thúc" + remainingDays: "{d} ngày {h} giờ còn lại" + remainingHours: "{h} giờ {m} phút còn lại" + remainingMinutes: "{m} phút {s}s còn lại" + remainingSeconds: "{s}s còn lại" +_visibility: + public: "Công khai" + publicDescription: "Mọi người đều có thể đọc tút của bạn" + home: "Trang chính" + homeDescription: "Chỉ đăng lên bảng tin nhà" + followers: "Người theo dõi" + followersDescription: "Dành riêng cho người theo dõi" + specified: "Nhắn riêng" + specifiedDescription: "Chỉ người được nhắc đến mới thấy" + localOnly: "Chỉ trên máy chủ" + localOnlyDescription: "Không hiển thị với người ở máy chủ khác" +_postForm: + replyPlaceholder: "Trả lời tút này" + quotePlaceholder: "Trích dẫn tút này" + channelPlaceholder: "Đăng lên một kênh" + _placeholders: + a: "Bạn đang định làm gì?" + b: "Hôm nay bạn có gì vui?" + c: "Bạn đang nghĩ gì?" + d: "Bạn muốn nói gì?" + e: "Bắt đầu viết..." + f: "Đang chờ bạn viết..." +_profile: + name: "Tên" + username: "Tên người dùng" + description: "Tiểu sử" + youCanIncludeHashtags: "Bạn có thể dùng hashtag trong tiểu sử." + metadata: "Thông tin bổ sung" + metadataEdit: "Sửa thông tin bổ sung" + metadataDescription: "Sử dụng phần này, bạn có thể hiển thị các mục thông tin bổ sung trong hồ sơ của mình. Bạn có thể thêm thẻ {a} hoặc thẻ {l} với {rel} để xác minh liên kết trên tiểu sử của mình!" + metadataLabel: "Nhãn" + metadataContent: "Nội dung" + changeAvatar: "Đổi ảnh đại diện" + changeBanner: "Đổi ảnh bìa" +_exportOrImport: + allNotes: "Toàn bộ tút" + followingList: "Đang theo dõi" + muteList: "Ẩn" + blockingList: "Chặn" + userLists: "Danh sách" + excludeMutingUsers: "Loại trừ những người dùng bị ẩn" + excludeInactiveUsers: "Loại trừ những người dùng không hoạt động" +_charts: + federation: "Liên hợp" + apRequest: "Yêu cầu" + usersIncDec: "Sự khác biệt về số lượng người dùng" + usersTotal: "Tổng số người dùng" + activeUsers: "Số người đang hoạt động" + notesIncDec: "Sự khác biệt về số lượng tút" + localNotesIncDec: "Sự khác biệt về số lượng tút máy chủ này" + remoteNotesIncDec: "Sự khác biệt về số lượng tút từ máy chủ khác" + notesTotal: "Tổng số sút" + filesIncDec: "Sự khác biệt về số lượng tập tin" + filesTotal: "Tổng số tập tin" + storageUsageIncDec: "Sự khác biệt về dung lượng lưu trữ" + storageUsageTotal: "Tổng dung lượng lưu trữ" +_instanceCharts: + requests: "Lượt yêu cầu" + users: "Sự khác biệt về số lượng người dùng" + usersTotal: "Số lượng người dùng tích lũy" + notes: "Sự khác biệt về số lượng tút" + notesTotal: "Số lượng tút tích lũy" + ff: "Sự khác biệt về số lượng người dùng được theo dõi/người theo dõi" + ffTotal: "Số lượng người dùng được theo dõi/người theo dõi tích lũy" + cacheSize: "Sự khác biệt về dung lượng bộ nhớ đệm" + cacheSizeTotal: "Dung lượng bộ nhớ đệm tích lũy" + files: "Sự khác biệt về số lượng tập tin" + filesTotal: "Số lượng tập tin tích lũy" +_timelines: + home: "Trang chính" + local: "Máy chủ này" + social: "Xã hội" + global: "Liên hợp" +_pages: + newPage: "Tạo Trang mới" + editPage: "Sửa Trang này" + readPage: "Xem mã nguồn Trang này" + created: "Trang đã được tạo thành công" + updated: "Trang đã được cập nhật thành công" + deleted: "Trang đã được xóa thành công" + pageSetting: "Cài đặt trang" + nameAlreadyExists: "URL Trang đã tồn tại" + invalidNameTitle: "URL Trang không hợp lệ" + invalidNameText: "Không được để trống tựa đề Trang" + editThisPage: "Sửa Trang này" + viewSource: "Xem mã nguồn" + viewPage: "Xem trang của tôi" + like: "Thích" + unlike: "Bỏ thích" + my: "Trang của tôi" + liked: "Trang đã thích" + featured: "Nổi tiếng" + inspector: "Thanh tra" + contents: "Nội dung" + content: "Chặn Trang" + variables: "Biến thể" + title: "Tựa đề" + url: "URL Trang" + summary: "Mô tả Trang" + alignCenter: "Căn giữa" + hideTitleWhenPinned: "Ẩn tựa đề Trang khi ghim lên hồ sơ" + font: "Phông chữ" + fontSerif: "Serif" + fontSansSerif: "Sans Serif" + eyeCatchingImageSet: "Đặt ảnh thu nhỏ" + eyeCatchingImageRemove: "Xóa ảnh thu nhỏ" + chooseBlock: "Thêm khối" + selectType: "Chọn kiểu" + enterVariableName: "Nhập tên một biến thể" + variableNameIsAlreadyUsed: "Tên biến thể này đã được sử dụng" + contentBlocks: "Nội dung" + inputBlocks: "Nhập" + specialBlocks: "Đặc biệt" + blocks: + text: "Văn bản" + textarea: "Khu vực văn bản" + section: "Mục " + image: "Hình ảnh" + button: "Nút" + if: "Nếu" + _if: + variable: "Biến thể" + post: "Mẫu đăng" + _post: + text: "Nội dung" + attachCanvasImage: "Đính kèm hình canva" + canvasId: "ID Canva" + textInput: "Văn bản đầu vào" + _textInput: + name: "Tên biến thể" + text: "Tựa đề" + default: "Giá trị mặc định" + textareaInput: "Văn bản nhiều dòng đầu vào" + _textareaInput: + name: "Tên biến thể" + text: "Tựa đề" + default: "Giá trị mặc định" + numberInput: "Đầu vào số" + _numberInput: + name: "Tên biến thể" + text: "Tựa đề" + default: "Giá trị mặc định" + canvas: "Canva" + _canvas: + id: "ID Canva" + width: "Chiều rộng" + height: "Chiều cao" + note: "Tút đã nhúng" + _note: + id: "ID tút" + idDescription: "Ngoài ra, bạn có thể dán URL tút vào đây." + detailed: "Xem chi tiết" + switch: "Chuyển đổi" + _switch: + name: "Tên biến thể" + text: "Tựa đề" + default: "Giá trị mặc định" + counter: "Bộ đếm" + _counter: + name: "Tên biến thể" + text: "Tựa đề" + inc: "Bước" + _button: + text: "Tựa đề" + colored: "Với màu" + action: "Thao tác khi nhấn nút" + _action: + dialog: "Hiện hộp thoại" + _dialog: + content: "Nội dung" + resetRandom: "Đặt lại seed ngẫu nhiên" + pushEvent: "Gửi một sự kiện" + _pushEvent: + event: "Tên sự kiện" + message: "Tin nhắn hiển thị khi kích hoạt" + variable: "Biển thể để gửi" + no-variable: "Không" + callAiScript: "Gọi AiScript" + _callAiScript: + functionName: "Tên tính năng" + radioButton: "Lựa chọn" + _radioButton: + name: "Tên biến thể" + title: "Tựa đề" + values: "Phân tách các mục bằng cách xuống dòng" + default: "Giá trị mặc định" + script: + categories: + flow: "Điều khiển" + logical: "Hoạt động logic" + operation: "Tính toán" + comparison: "So sánh" + random: "Ngẫu nhiên" + value: "Giá trị" + fn: "Tính năng" + text: "Tác vụ văn bản" + convert: "Chuyển đổi" + list: "Danh sách" + blocks: + text: "Văn bản" + multiLineText: "Văn bản (nhiều dòng)" + textList: "Văn bản liệt kê" + _textList: + info: "Phân tách mục bằng cách xuống dòng" + strLen: "Độ dài văn bản" + _strLen: + arg1: "Văn bản" + strPick: "Trích xuất chuỗi" + _strPick: + arg1: "Văn bản" + arg2: "Vị trí chuỗi" + strReplace: "Thay thế chuỗi" + _strReplace: + arg1: "Nội dung" + arg2: "Văn bản thay thế" + arg3: "Thay thế bằng" + strReverse: "Lật văn bản" + _strReverse: + arg1: "Văn bản" + join: "Nối văn bản" + _join: + arg1: "Danh sách" + arg2: "Phân cách" + add: "Cộng" + _add: + arg1: "A" + arg2: "B" + subtract: "Trừ" + _subtract: + arg1: "A" + arg2: "B" + multiply: "Nhân" + _multiply: + arg1: "A" + arg2: "B" + divide: "Chia" + _divide: + arg1: "A" + arg2: "B" + mod: "Phần còn lại" + _mod: + arg1: "A" + arg2: "B" + round: "Làm tròn thập phân" + _round: + arg1: "Số" + eq: "A và B bằng nhau" + _eq: + arg1: "A" + arg2: "B" + notEq: "A và B khác nhau" + _notEq: + arg1: "A" + arg2: "B" + and: "A VÀ B" + _and: + arg1: "A" + arg2: "B" + or: "A HOẶC B" + _or: + arg1: "A" + arg2: "B" + lt: "< A nhỏ hơn B" + _lt: + arg1: "A" + arg2: "B" + gt: "> A lớn hơn B" + _gt: + arg1: "A" + arg2: "B" + ltEq: "<= A nhỏ hơn hoặc bằng B" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: ">= A lớn hơn hoặc bằng B" + _gtEq: + arg1: "A" + arg2: "B" + if: "Nhánh" + _if: + arg1: "Nếu" + arg2: "Sau đó" + arg3: "Khác" + not: "KHÔNG" + _not: + arg1: "KHÔNG" + random: "Ngẫu nhiên" + _random: + arg1: "Xác suất" + rannum: "Số ngẫu nhiên" + _rannum: + arg1: "Giá trị tối thiểu" + arg2: "Giá trị tối đa" + randomPick: "Chọn ngẫu nhiên từ danh sách" + _randomPick: + arg1: "Danh sách" + dailyRandom: "Ngẫu nhiên (Đổi mỗi người một lần mỗi ngày)" + _dailyRandom: + arg1: "Xác suất" + dailyRannum: "Số ngẫu nhiên (Đổi mỗi người một lần mỗi ngày)" + _dailyRannum: + arg1: "Giá trị tối thiểu" + arg2: "Giá trị tối đa" + dailyRandomPick: "Chọn ngẫu nhiên từ một danh sách (Đổi mỗi người một lần mỗi ngày)" + _dailyRandomPick: + arg1: "Danh sách" + seedRandom: "Ngẫu nhiên (với seed)" + _seedRandom: + arg1: "Seed" + arg2: "Xác suất" + seedRannum: "Số ngẫu nhiên (với seed)" + _seedRannum: + arg1: "Seed" + arg2: "Giá trị tối thiểu" + arg3: "Giá trị tối đa" + seedRandomPick: "Chọn ngẫu nhiên từ danh sách (với seed)" + _seedRandomPick: + arg1: "Seed" + arg2: "Danh sách" + DRPWPM: "Chọn ngẫu nhiên từ danh sách nặng (Đổi mỗi người một lần mỗi ngày)" + _DRPWPM: + arg1: "Văn bản liệt kê" + pick: "Chọn từ danh sách" + _pick: + arg1: "Danh sách" + arg2: "Vị trí" + listLen: "Lấy độ dài danh sách" + _listLen: + arg1: "Danh sách" + number: "Số" + stringToNumber: "Chữ thành số" + _stringToNumber: + arg1: "Văn bản" + numberToString: "Số thành chữ" + _numberToString: + arg1: "Số" + splitStrByLine: "Phân cách văn bản bằng cách xuống dòng" + _splitStrByLine: + arg1: "Văn bản" + ref: "Biến thể" + aiScriptVar: "Biển thể AiScript" + fn: "Tính năng" + _fn: + slots: "Chỗ" + slots-info: "Phân cách chỗ bằng cách xuống dòng" + arg1: "Đầu ra" + for: "để-Lặp lại" + _for: + arg1: "Số lần lặp lại" + arg2: "Hành động" + typeError: "Chỗ {slot} chấp nhận các giá trị thuộc loại \"{expect}\", nhưng giá trị được cung cấp thuộc loại \"{actual}\"!" + thereIsEmptySlot: "Chỗ {slot} đang trống!" + types: + string: "Văn bản" + number: "Số" + boolean: "Cờ" + array: "Danh sách" + stringArray: "Văn bản liệt kê" + emptySlot: "Chỗ trống" + enviromentVariables: "Biến môi trường" + pageVariables: "Biến trang" + argVariables: "Đầu vào chỗ" +_relayStatus: + requesting: "Đang chờ" + accepted: "Đã duyệt" + rejected: "Đã từ chối" +_notification: + fileUploaded: "Đã tải lên tập tin" + youGotMention: "{name} nhắc đến bạn" + youGotReply: "{name} trả lời bạn" + youGotQuote: "{name} trích dẫn tút của bạn" + youRenoted: "{name} đăng lại tút của bạn" + youGotPoll: "{name} bình chọn tút của bạn" + youGotMessagingMessageFromUser: "{name} nhắn tin cho bạn" + youGotMessagingMessageFromGroup: "Một tin nhắn trong nhóm {name}" + youWereFollowed: "đã theo dõi bạn" + youReceivedFollowRequest: "Bạn vừa có một yêu cầu theo dõi" + yourFollowRequestAccepted: "Yêu cầu theo dõi của bạn đã được chấp nhận" + youWereInvitedToGroup: "Bạn đã được mời tham gia nhóm" + pollEnded: "Cuộc bình chọn đã kết thúc" + emptyPushNotificationMessage: "Đã cập nhật thông báo đẩy" + _types: + all: "Toàn bộ" + follow: "Đang theo dõi" + mention: "Nhắc đến" + reply: "Lượt trả lời" + renote: "Đăng lại" + quote: "Trích dẫn" + reaction: "Biểu cảm" + pollVote: "Lượt bình chọn" + pollEnded: "Bình chọn kết thúc" + receiveFollowRequest: "Yêu cầu theo dõi" + followRequestAccepted: "Yêu cầu theo dõi được chấp nhận" + groupInvited: "Mời vào nhóm" + app: "Từ app liên kết" + _actions: + followBack: "đã theo dõi lại bạn" + reply: "Trả lời" + renote: "Đăng lại" +_deck: + alwaysShowMainColumn: "Luôn hiện cột chính" + columnAlign: "Căn cột" + addColumn: "Thêm cột" + configureColumn: "Cài đặt cột" + swapLeft: "Hoán đổi với cột bên trái" + swapRight: "Hoán đổi với cột bên phải" + swapUp: "Hoán đổi với cột trên" + swapDown: "Hoán đổi với cột dưới" + stackLeft: "Xếp chồng với cột bên trái" + popRight: "Xếp chồng với cột bên trái" + profile: "Hồ sơ" + newProfile: "Hồ sơ mới" + deleteProfile: "Xóa hồ sơ" + introduction: "Kết hợp các cột để tạo giao diện của riêng bạn!" + introduction2: "Bạn có thể thêm cột bất kỳ lúc nào bằng cách nhấn + ở bên phải màn hình." + widgetsIntroduction: "Chọn \"Sửa widget\" trong menu cột và thêm một widget." + _columns: + main: "Chính" + widgets: "Tiện ích" + notifications: "Thông báo" + tl: "Bảng tin" + antenna: "Trạm phát sóng" + list: "Danh sách" + mentions: "Lượt nhắc" + direct: "Nhắn riêng" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml new file mode 100644 index 0000000..b6cf9b0 --- /dev/null +++ b/locales/zh-CN.yml @@ -0,0 +1,1961 @@ +_lang_: "简体中文" +headlineIceshrimp: "一个开源、去中心化的社交媒体平台,永远免费!🚀" +introIceshrimp: "欢迎! Iceshrimp 是一个开源、去中心化的社交媒体平台,永久免费!🚀" +monthAndDay: "{month} 月 {day} 日" +search: "搜索" +notifications: "通知" +username: "用户名" +password: "密码" +forgotPassword: "忘记密码" +fetchingAsApObject: "正在从联邦宇宙查询" +ok: "好" +gotIt: "知道了!" +cancel: "取消" +enterUsername: "输入用户名" +renotedBy: "转发自 {user}" +noNotes: "没有帖子" +noNotifications: "没有通知" +instance: "服务器" +settings: "设置" +basicSettings: "基本设置" +otherSettings: "其它设置" +openInWindow: "在新窗口中打开" +profile: "个人资料" +timeline: "时间线" +noAccountDescription: "这个人很懒,没有写自我介绍。" +login: "登录" +loggingIn: "正在登录" +logout: "登出" +signup: "新用户注册" +uploading: "正在上传..." +save: "保存" +users: "用户" +addUser: "添加用户" +favorite: "添加到书签" +favorites: "书签" +unfavorite: "取消收藏" +favorited: "已添加到书签。" +alreadyFavorited: "书签中已存在。" +cantFavorite: "无法添加到书签。" +pin: "置顶" +unpin: "取消置顶" +copyContent: "复制内容" +copyLink: "复制链接" +delete: "删除" +deleteAndEdit: "删除并编辑" +deleteAndEditConfirm: "要删除此帖子并再次编辑吗?对此帖子的所有回应、转发和回复也将被删除。" +addToList: "添加至列表" +sendMessage: "发送" +copyUsername: "复制用户名" +searchUser: "搜索用户" +reply: "回复" +loadMore: "加载更多" +showMore: "查看更多" +showLess: "关闭" +youGotNewFollower: "关注了您" +receiveFollowRequest: "收到了关注请求" +followRequestAccepted: "关注请求已通过" +mention: "提及" +mentions: "提及" +directNotes: "私信" +importAndExport: "导入/导出数据" +import: "导入" +export: "导出" +files: "文件" +download: "下载" +driveFileDeleteConfirm: "要删除文件「{name}」吗?它将从所有作为附件包含它的帖子中删除。" +unfollowConfirm: "要取消关注 {name} 吗?" +exportRequested: "导出请求已提交,这可能需要花一些时间,导出的文件将保存到网盘中。" +importRequested: "导入请求已提交,这可能需要花一点时间。" +lists: "列表" +noLists: "列表为空" +note: "帖子" +notes: "帖子" +following: "关注中" +followers: "关注者" +followsYou: "关注了您" +createList: "创建列表" +manageLists: "管理列表" +error: "错误" +somethingHappened: "发生了一个错误" +retry: "重试" +pageLoadError: "页面加载时发生错误。" +pageLoadErrorDescription: "这通常是由于网络错误或浏览器缓存的原因。请清除缓存或等待片刻后重试。" +serverIsDead: "服务器没有响应。 请稍等片刻,然后重试。" +youShouldUpgradeClient: "请重新加载并使用新版本的客户端查看此页面。" +enterListName: "输入列表名称" +privacy: "隐私" +makeFollowManuallyApprove: "关注请求需要批准" +defaultNoteVisibility: "默认可见性" +follow: "关注" +followRequest: "关注请求" +followRequests: "关注请求" +unfollow: "取消关注" +followRequestPending: "关注请求待批准" +enterEmoji: "输入表情符号" +renote: "转发" +unrenote: "取消转发" +renoted: "已转发。" +cantRenote: "此帖子无法被转发。" +cantReRenote: "转发无法被再次转发。" +quote: "引用" +pinnedNote: "已置顶的帖子" +pinned: "置顶" +you: "您" +clickToShow: "点击以显示" +sensitive: "敏感内容" +add: "添加" +reaction: "回应" +enableEmojiReaction: "启用表情符号回应" +showEmojisInReactionNotifications: "在回应通知中显示表情符号" +reactionSetting: "在回应选择器中显示的回应" +reactionSettingDescription2: "拖动重新排序,单击删除,点击 + 添加。" +rememberNoteVisibility: "保存帖子可见性设置" +attachCancel: "删除附件" +markAsSensitive: "标记为敏感内容" +unmarkAsSensitive: "取消标记为敏感内容" +enterFileName: "请输入文件名" +mute: "静音" +unmute: "取消静音" +renoteMute: "静音转发" +renoteUnmute: "取消静音转发" +block: "屏蔽" +unblock: "取消屏蔽" +suspend: "冻结" +unsuspend: "解除冻结" +blockConfirm: "确定要屏蔽吗?" +unblockConfirm: "确定要取消屏蔽吗?" +suspendConfirm: "确定要冻结吗?" +unsuspendConfirm: "确定要解除冻结吗?" +selectList: "选择列表" +selectAntenna: "选择天线" +selectWidget: "选择小部件" +editWidgets: "编辑小部件" +editWidgetsExit: "完成编辑" +customEmojis: "自定义表情符号" +emoji: "表情符号" +emojis: "表情符号" +emojiName: "表情符号名称" +emojiUrl: "表情符号 URL" +addEmoji: "添加表情符号" +settingGuide: "推荐配置" +cacheRemoteFiles: "缓存远程文件" +cacheRemoteFilesDescription: "当禁用此设定时远程文件将直接从远程服务器载入。禁用后会减小储存空间需求,但是会增加流量,因为缩略图不会被生成。" +flagAsBot: "这是一个机器人账号" +flagAsBotDescription: "如果此账号由程序控制,请启用此项。启用后,此标志可以帮助其它开发人员防止机器人之间产生无限互动的行为,并让 Iceshrimp + 的内部系统将此账号识别为机器人。" +flagAsCat: "将这个账号设定为一只猫😺" +flagAsCatDescription: "您会长出猫耳朵并像猫一样说话!" +flagShowTimelineReplies: "在时间线上显示帖子的回复" +flagShowTimelineRepliesDescription: "启用后,时间线除了显示用户的帖子外,还会显示其它用户对帖子的回复。" +autoAcceptFollowed: "自动批准来自关注者的关注请求" +addAccount: "添加账号" +loginFailed: "登录失败" +showOnRemote: "打开原网页" +general: "常规设置" +wallpaper: "壁纸" +setWallpaper: "设置壁纸" +removeWallpaper: "移除壁纸" +searchWith: "搜索:{q}" +youHaveNoLists: "列表为空" +followConfirm: "您确定要关注 {name} 吗?" +proxyAccount: "代理账号" +proxyAccountDescription: "代理账号是在某些情况下充当用户的远程关注者的账号。 例如,当一个用户添加一个远程用户为代理账号时,如果没有本地用户关注该用户,远程用户的活动将不会被传递到服务器,因此代理账号将被关注。" +host: "主机名" +selectUser: "选择用户" +recipient: "接收者" +annotation: "注解" +federation: "联合" +instances: "服务器" +registeredAt: "初次观测于" +latestRequestSentAt: "上次发送的请求" +latestRequestReceivedAt: "上次收到的请求" +latestStatus: "最后状态" +storageUsage: "已用存储" +charts: "图表" +perHour: "每小时" +perDay: "每天" +stopActivityDelivery: "停止发送活动" +blockThisInstance: "屏蔽此服务器" +operations: "操作" +software: "软件" +version: "版本" +metadata: "元数据" +monitor: "监测" +jobQueue: "作业队列" +cpuAndMemory: "CPU 和内存" +network: "网络" +disk: "磁盘" +instanceInfo: "服务器信息" +statistics: "统计" +clearQueue: "清除队列" +clearQueueConfirmTitle: "确定清除队列?" +clearQueueConfirmText: "队列中任何未送达的帖子将不会发送。 通常,您不需要这样做。" +clearCachedFiles: "清除缓存" +clearCachedFilesConfirm: "确定要删除所有缓存的远程文件?" +blockedInstances: "已屏蔽的服务器" +blockedInstancesDescription: "设定要屏蔽的服务器,一行一个。被屏蔽的服务器将无法与本服务器进行交换通讯。" +muteAndBlock: "静音与屏蔽" +mutedUsers: "已静音的用户" +blockedUsers: "已屏蔽的用户" +noUsers: "无用户" +editProfile: "编辑个人资料" +noteDeleteConfirm: "确定要删除此帖子吗?" +pinLimitExceeded: "无法置顶更多帖子了" +intro: "Iceshrimp 安装完成!请创建一个管理员用户。" +done: "完成" +processing: "正在处理" +preview: "预览" +default: "默认" +defaultValueIs: "默认值:{value}" +noCustomEmojis: "没有自定义表情符号" +noJobs: "没有任务" +federating: "联合中" +blocked: "已屏蔽" +suspended: "停止推流" +all: "全部" +subscribing: "订阅中" +publishing: "直播中" +notResponding: "没有响应" +instanceFollowing: "关注服务器" +instanceFollowers: "服务器的关注者" +instanceUsers: "此服务器的用户" +changePassword: "修改密码" +security: "安全" +retypedNotMatch: "两次输入不匹配。" +currentPassword: "现在的密码" +newPassword: "新密码" +newPasswordRetype: "重新输入新密码" +attachFile: "插入附件" +more: "更多!" +featured: "热门" +usernameOrUserId: "用户名或用户 ID" +noSuchUser: "用户不存在" +lookup: "查询" +announcements: "公告" +imageUrl: "图片 URL" +remove: "删除" +removed: "已删除" +removeAreYouSure: "确定要删除「{x}」吗?" +deleteAreYouSure: "确定要删除「{x}」吗?" +resetAreYouSure: "确定重置为默认设置?" +saved: "已保存" +messaging: "聊天" +upload: "本地上传" +keepOriginalUploading: "保留原图" +keepOriginalUploadingDescription: "上传图片时保留原始图片。如果关闭,会在上传时生成一张用于 web 发布的图片。" +fromDrive: "从网盘中" +fromUrl: "从 URL" +uploadFromUrl: "从 URL 上传" +uploadFromUrlDescription: "输入文件的 URL" +uploadFromUrlRequested: "已请求上传" +uploadFromUrlMayTakeTime: "上传可能需要一些时间完成。" +explore: "发现" +messageRead: "已读" +noMoreHistory: "没有更多的历史记录" +startMessaging: "开始聊天" +nUsersRead: "{n} 人已读" +agreeTo: "我同意 {0}" +tos: "服务条款" +start: "开始" +home: "首页" +remoteUserCaution: "由于此用户来自其它服务器,显示的信息可能不完整。" +activity: "活动" +images: "图片" +birthday: "生日" +yearsOld: "{age} 岁" +registeredDate: "注册于" +location: "位置" +theme: "主题" +themeForLightMode: "在浅色模式下使用的主题" +themeForDarkMode: "在深色模式下使用的主题" +light: "浅色" +dark: "深色" +lightThemes: "浅色主题" +darkThemes: "深色主题" +syncDeviceDarkMode: "将深色模式设置与设备同步" +drive: "网盘" +fileName: "文件名" +selectFile: "选择文件" +selectFiles: "选择文件" +selectFolder: "选择文件夹" +selectFolders: "选择多个文件夹" +renameFile: "重命名文件" +folderName: "文件夹名称" +createFolder: "创建文件夹" +renameFolder: "重命名文件夹" +deleteFolder: "删除文件夹" +addFile: "添加文件" +emptyDrive: "网盘中无文件" +emptyFolder: "此文件夹中无文件" +unableToDelete: "无法删除" +inputNewFileName: "请输入新文件名" +inputNewDescription: "请输入新标题" +inputNewFolderName: "请输入新文件夹名" +circularReferenceFolder: "目标文件夹是您要移动的文件夹的子文件夹。" +hasChildFilesOrFolders: "此文件夹中有文件,无法删除。" +copyUrl: "复制链接" +rename: "重命名" +avatar: "头像" +banner: "横幅" +nsfw: "敏感内容" +whenServerDisconnected: "与服务器连接中断时" +disconnectedFromServer: "已和服务器断开连接" +reload: "重新加载" +doNothing: "忽略" +reloadConfirm: "确定要重新加载吗?" +watch: "关注" +unwatch: "取消关注" +accept: "接受" +reject: "拒绝" +normal: "正常" +instanceName: "服务器名称" +instanceDescription: "服务器简介" +maintainerName: "管理员名称" +maintainerEmail: "管理员电子邮箱" +tosUrl: "服务条款 URL" +thisYear: "今年" +thisMonth: "本月" +today: "今天" +dayX: "{day} 日" +monthX: "{month} 月" +yearX: "{year} 年" +pages: "页面" +integration: "整合" +connectService: "连接" +disconnectService: "断开连接" +enableLocalTimeline: "启用本地时间线功能" +enableGlobalTimeline: "启用全局时间线" +disablingTimelinesInfo: "管理员和监察员将始终拥有对所有时间线的访问权,即使它们没有被启用。" +registration: "注册" +enableRegistration: "允许新用户注册" +invite: "邀请" +driveCapacityPerLocalAccount: "每个本地用户的网盘容量" +driveCapacityPerRemoteAccount: "每个远程用户的网盘容量" +inMb: "以兆字节 (MegaByte) 为单位" +iconUrl: "图标 URL" +bannerUrl: "横幅图 URL" +backgroundImageUrl: "背景图 URL" +basicInfo: "基本信息" +pinnedUsers: "置顶用户" +pinnedUsersDescription: "列出要在「发现」页面中置顶的用户,一行一个。" +pinnedPages: "固定页面" +pinnedPagesDescription: "输入您要固定到服务器首页的页面路径,一行一个。" +pinnedClipId: "置顶的便签 ID" +pinnedNotes: "已置顶的帖子" +hcaptcha: "hCaptcha" +enableHcaptcha: "启用 hCaptcha" +hcaptchaSiteKey: "网站密钥 (Site key)" +hcaptchaSecretKey: "密钥 (Secret key)" +recaptcha: "reCAPTCHA" +enableRecaptcha: "启用 reCAPTCHA\n(请注意,reCAPTCHA 在中国大陆无法访问,如果启用,可能导致无法正常使用登录或注册等功能)" +recaptchaSiteKey: "网站密钥 (Site key)" +recaptchaSecretKey: "密钥 (Secret key)" +avoidMultiCaptchaConfirm: "使用多种验证方式可能会造成干扰,您要禁用其它现已激活的验证方式吗?如果您希望它们继续被启用,请点击 「取消」。" +antennas: "天线" +manageAntennas: "管理天线" +name: "名称" +antennaSource: "接收来源" +antennaKeywords: "包含关键字" +antennaExcludeKeywords: "排除关键字" +antennaKeywordsDescription: "AND 条件用空格分隔,OR 条件用换行符分隔。" +notifyAntenna: "新帖子通知" +withFileAntenna: "仅显示带有附件的帖子" +enableServiceworker: "为浏览器启用推送通知 (ServiceWorker)" +antennaUsersDescription: "指定用户名,一行一个" +caseSensitive: "区分大小写" +withReplies: "包括回复" +connectedTo: "您的账号已连到接以下第三方账号" +notesAndReplies: "帖子与回复" +withFiles: "包含文件" +silence: "禁言" +silenceConfirm: "确认要禁言吗?" +unsilence: "解除禁言" +unsilenceConfirm: "要解除禁言吗?" +popularUsers: "热门用户" +recentlyUpdatedUsers: "最近投稿的用户" +recentlyRegisteredUsers: "最近登录的用户" +recentlyDiscoveredUsers: "最近发现的用户" +exploreUsersCount: "有 {count} 个用户" +exploreFediverse: "探索联邦宇宙" +popularTags: "热门标签" +userList: "列表" +about: "关于" +aboutIceshrimp: "关于 Iceshrimp" +administrator: "管理员" +token: "令牌" +twoStepAuthentication: "两步验证" +moderator: "监察员" +moderation: "管理" +nUsersMentioned: "被 {n} 人提到" +securityKey: "安全密钥" +securityKeyName: "密钥名称" +registerSecurityKey: "注册安全密钥" +lastUsed: "最近使用" +unregister: "删除账号" +passwordLessLogin: "无密码登录" +resetPassword: "重置密码" +newPasswordIs: "新的密码是 {password}" +reduceUiAnimation: "减少 UI 动画" +share: "分享" +notFound: "未找到" +notFoundDescription: "没有与指定 URL 对应的页面。" +uploadFolder: "默认上传文件夹" +cacheClear: "清空缓存" +markAsReadAllNotifications: "将所有通知标为已读" +markAsReadAllUnreadNotes: "将所有帖子标记为已读" +markAsReadAllTalkMessages: "将所有聊天标记为已读" +help: "帮助" +inputMessageHere: "在此输入信息" +close: "关闭" +group: "群组" +groups: "群组" +createGroup: "创建群组" +ownedGroups: "拥有的群组" +joinedGroups: "已加入的群组" +invites: "邀请" +groupName: "群组名" +members: "成员" +transfer: "转让" +messagingWithUser: "私聊" +messagingWithGroup: "群聊" +title: "标题" +text: "文本" +enable: "启用" +next: "下一个" +retype: "重新输入" +noteOf: "{user} 的帖子" +inviteToGroup: "群组邀请" +quoteAttached: "已引用" +quoteQuestion: "是否引用?" +noMessagesYet: "暂无消息" +newMessageExists: "新信息" +onlyOneFileCanBeAttached: "只能添加一个附件" +signinRequired: "请先登录" +invitations: "邀请" +invitationCode: "邀请码" +checking: "正在确认..." +available: "可用" +unavailable: "不可用" +usernameInvalidFormat: "可使用大小写英文字母、数字和下划线。" +tooShort: "太短" +tooLong: "太长" +weakPassword: "密码强度:弱" +normalPassword: "密码强度:中等" +strongPassword: "密码强度:强" +passwordMatched: "密码一致" +passwordNotMatched: "密码不一致" +signinWith: "以 {x} 登录" +signinFailed: "无法登录,请检查您的用户名和密码是否正确。" +tapSecurityKey: "轻触您的安全密钥" +or: "或者" +language: "语言" +uiLanguage: "显示语言" +groupInvited: "您有新的群组邀请" +aboutX: "关于 {x}" +useOsNativeEmojis: "使用系统的原生表情符号" +disableDrawer: "不显示抽屉菜单" +youHaveNoGroups: "没有群组" +joinOrCreateGroup: "请加入一个现有的群组,或者创建新群组。" +noHistory: "没有历史记录" +signinHistory: "登录历史" +disableAnimatedMfm: "禁用 MFM 动画" +doing: "正在处理…" +category: "类别" +tags: "标签" +docSource: "文件来源" +createAccount: "注册账号" +existingAccount: "现有的账号" +regenerate: "重新生成" +fontSize: "字体大小" +noFollowRequests: "没有待批准的关注申请" +openImageInNewTab: "在新标签页中打开图片" +dashboard: "管理面板" +local: "本地" +remote: "远程" +total: "总计" +weekOverWeekChanges: "与前一周相比" +dayOverDayChanges: "与昨日相比" +appearance: "外观" +clientSettings: "客户端设置" +accountSettings: "账号设置" +promotion: "推广" +promote: "推广" +numberOfDays: "天数" +hideThisNote: "隐藏这条帖子" +showFeaturedNotesInTimeline: "在时间线上显示热门推荐" +objectStorage: "对象存储" +useObjectStorage: "使用对象存储" +objectStorageBaseUrl: "Base URL" +objectStorageBaseUrlDesc: "用于引用的 URL。如果您正在使用 CDN 或反向代理,请指定其 URL。\n例如S3:“https://.s3.amazonaws.com”,GCS:“https://storage.googleapis.com/”,其它同理。" +objectStorageBucket: "存储桶" +objectStorageBucketDesc: "请指定使用的对象存储服务的存储桶名称。" +objectStoragePrefix: "前缀" +objectStoragePrefixDesc: "文件将存储在此前缀的目录下。" +objectStorageEndpoint: "Endpoint" +objectStorageEndpointDesc: "如果您使用 AWS S3 请留空。否则请根据您使用的服务商的说明来进行设置,指定 Endpoint 形式为 + \"\" 或 \":\"。" +objectStorageRegion: "可用区" +objectStorageRegionDesc: "指定一个可用区,例如 \"xx-east-1\"。 如果您的对象存储服务没有可用区概念,请将其留空或填写 \"\ + us-east-1\"。\n对于 Cloudflare R2,可以填为 \"auto\"。" +objectStorageUseSSL: "使用 SSL" +objectStorageUseSSLDesc: "如果不使用 HTTPS 进行 API 连接,请关闭" +objectStorageUseProxy: "使用代理" +objectStorageUseProxyDesc: "如果您不使用代理进行 API 连接,请将其关闭" +objectStorageSetPublicRead: "上传时设置为 public-read" +serverLogs: "服务器日志" +deleteAll: "全部删除" +showFixedPostForm: "在时间线顶部显示发帖框" +newNoteRecived: "新帖子" +sounds: "提示音" +listen: "试听" +none: "无" +showInPage: "在页面中显示" +popout: "弹窗" +volume: "音量" +masterVolume: "主音量" +details: "详情" +chooseEmoji: "选择表情符号" +unableToProcess: "操作无法完成" +recentUsed: "最近使用" +install: "安装" +uninstall: "卸载" +installedApps: "已授权的应用" +nothing: "没有" +installedDate: "授权日期" +lastUsedDate: "最近使用时间" +state: "状态" +sort: "排序" +ascendingOrder: "升序" +descendingOrder: "降序" +scratchpad: "AiScript 控制台" +scratchpadDescription: "AiScript 控制台为 AiScript 提供了实验环境。您可以编写代码以与 Iceshrimp 交互,运行它并查看结果。" +output: "输出" +script: "脚本" +disablePagesScript: "在页面中禁用 AiScript" +updateRemoteUser: "更新远程用户信息" +deleteAllFiles: "删除所有文件" +deleteAllFilesConfirm: "确定要删除所有文件吗?" +removeAllFollowing: "取消所有关注" +removeAllFollowingDescription: "取消 {host} 的所有关注者。如果服务器已不存在,请执行它。" +userSuspended: "该用户已被冻结。" +userSilenced: "该用户已被禁言。" +yourAccountSuspendedTitle: "账号已被冻结" +yourAccountSuspendedDescription: "由于违反了服务器的服务条款或其它原因,该账号已被冻结。 您可以与管理员联系以了解更多信息。 请不要创建一个新的账号。" +menu: "菜单" +divider: "分割线" +addItem: "添加项目" +relays: "中继" +addRelay: "添加中继" +inboxUrl: "Inbox URL" +addedRelays: "已添加的中继" +serviceworkerInfo: "需要启用推送通知。" +deletedNote: "已删除的帖子" +invisibleNote: "隐藏的帖子" +enableInfiniteScroll: "滚动页面以载入更多内容" +visibility: "可见性" +poll: "调查问卷" +useCw: "隐藏内容" +enablePlayer: "打开播放器" +disablePlayer: "关闭播放器" +expandTweet: "展开帖子" +themeEditor: "主题编辑器" +description: "描述" +describeFile: "添加标题" +enterFileDescription: "输入标题" +author: "作者" +leaveConfirm: "存在未保存的更改。要放弃更改吗?" +manage: "管理" +plugins: "插件" +preferencesBackups: "备份设置" +deck: "Deck" +undeck: "取消 Deck" +useBlurEffectForModal: "对话框使用模糊效果" +useFullReactionPicker: "使用全尺寸的回应选择栏" +width: "宽度" +height: "高度" +large: "大" +medium: "中" +small: "小" +generateAccessToken: "生成访问令牌" +permission: "权限" +enableAll: "启用全部" +disableAll: "禁用全部" +tokenRequested: "允许访问账号" +pluginTokenRequestedDescription: "此插件将能够拥有这里设置的权限。" +notificationType: "通知类型" +edit: "编辑" +emailServer: "邮件服务器" +enableEmail: "启用发送邮件功能" +emailConfigInfo: "用于确认电子邮件和密码重置" +email: "邮箱" +emailAddress: "电子邮件地址" +smtpConfig: "SMTP 服务器设置" +smtpHost: "主机名" +smtpPort: "端口" +smtpUser: "用户名" +smtpPass: "密码" +emptyToDisableSmtpAuth: "留空用户名和密码以禁用 SMTP 验证" +smtpSecure: "在 SMTP 连接中使用隐式 SSL / TLS" +smtpSecureInfo: "使用 STARTTLS 时关闭" +testEmail: "邮件发送测试" +wordMute: "文字过滤" +regexpError: "正则表达式错误" +regexpErrorDescription: "{tab} 文字过滤的第 {line} 行的正则表达式有错误:" +instanceMute: "服务器静音" +userSaysSomething: "{name} 说了什么" +makeActive: "启用" +display: "显示" +copy: "复制" +metrics: "指标" +overview: "服务器概况" +logs: "日志" +delayed: "滞后" +database: "数据库" +channel: "频道" +create: "创建" +notificationSetting: "通知设置" +notificationSettingDesc: "选择要显示的通知类型。" +useGlobalSetting: "使用全局设置" +useGlobalSettingDesc: "启用时,将使用账号通知设置。关闭时,则可以单独设置。" +other: "其它" +regenerateLoginToken: "重新生成登录令牌" +regenerateLoginTokenDescription: "重新生成用于登录的内部令牌。通常您不需要这样做。重新生成后,您将在所有设备上登出。" +setMultipleBySeparatingWithSpace: "您可以使用空格分隔多个项目。" +fileIdOrUrl: "文件 ID 或者 URL" +behavior: "行为" +sample: "示例" +abuseReports: "举报" +reportAbuse: "举报" +reportAbuseOf: "举报 {name}" +fillAbuseReportDescription: "请填写举报的详细原因。如果有对方发的帖子,请同时填写 URL 地址。" +abuseReported: "您的举报已发送。非常感谢您。" +reporter: "举报者" +reporteeOrigin: "举报来源" +reporterOrigin: "举报者来源" +forwardReport: "将该举报信息转发给远程服务器" +forwardReportIsAnonymous: "勾选则在远程服务器上显示的举报者是匿名的系统账号,而不是您的账号。" +send: "发送" +abuseMarkAsResolved: "标记举报为已解决" +openInNewTab: "在新标签页中打开" +openInSideView: "在侧边栏中打开" +defaultNavigationBehaviour: "默认导航" +editTheseSettingsMayBreakAccount: "编辑这些设置可能会损坏您的账号。" +instanceTicker: "帖子所在的服务器信息" +waitingFor: "等待 {x}" +random: "随机" +system: "系统" +switchUi: "界面" +desktop: "桌面" +clip: "便签" +createNew: "新建" +optional: "可选" +createNewClip: "新建便签" +unclip: "移除便签" +confirmToUnclipAlreadyClippedNote: "本帖已包含在便签 \"{name}\" 里。您想要将本帖从该便签中移除吗?" +public: "公开" +i18nInfo: "Iceshrimp 已经被志愿者们翻译成了各种语言。如果您也有兴趣,可以通过 {link} 帮助翻译。" +manageAccessTokens: "管理访问令牌" +accountInfo: "账号信息" +notesCount: "帖子数量" +repliesCount: "回复数量" +renotesCount: "转发数量" +repliedCount: "回复数" +renotedCount: "转发数量" +followingCount: "关注中数量" +followersCount: "关注者数量" +sentReactionsCount: "发送回应数" +receivedReactionsCount: "收到回应数" +pollVotesCount: "问卷调查的投票数" +pollVotedCount: "问卷调查的被投票数" +yes: "是" +no: "否" +driveFilesCount: "网盘的文件数" +driveUsage: "网盘的空间用量" +noCrawle: "要求搜索引擎不索引该用户" +noCrawleDescription: "要求搜索引擎不要收录(索引)您的用户页面,帖子,页面等。" +lockedAccountInfo: "即使通过了关注请求,只要您不将帖子可见范围设置成“关注者”,任何人都可以看到您的帖子。" +alwaysMarkSensitive: "默认将媒体文件标记为敏感内容" +loadRawImages: "加载原始图像而不是显示缩略图" +disableShowingAnimatedImages: "不播放动画" +verificationEmailSent: "已发送确认电子邮件。请访问电子邮件中的链接以完成验证。" +notSet: "未设置" +emailVerified: "电子邮件地址已验证" +noteFavoritesCount: "加入书签的帖子数" +pageLikesCount: "页面点赞次数" +pageLikedCount: "页面被点赞次数" +contact: "联系人" +useSystemFont: "使用系统默认字体" +clips: "便签" +experimentalFeatures: "实验性功能" +developer: "开发者" +makeExplorable: "使账号在“发现”中可见" +makeExplorableDescription: "关闭时,账号不会显示在\"发现\"中。" +showGapBetweenNotesInTimeline: "时间线上的帖子分开显示" +duplicate: "复制" +left: "左" +center: "中央" +wide: "宽" +narrow: "窄" +reloadToApplySetting: "页面刷新后设置才会生效。是否现在刷新页面?" +needReloadToApply: "需要重新加载才能生效。" +showTitlebar: "显示标题栏" +clearCache: "清除缓存" +onlineUsersCount: "{n} 人在线" +nUsers: "{n} 用户" +nNotes: "{n} 帖子" +sendErrorReports: "发送错误报告" +sendErrorReportsDescription: "启用后,如果出现问题,可以与 Iceshrimp 共享详细的错误信息,从而帮助提高软件的质量。\n这将包括您的操作系统版本、您使用的浏览器、您在 + Iceshrimp 中的活动等信息。" +myTheme: "我的主题" +backgroundColor: "背景色" +accentColor: "强调色" +textColor: "文本颜色" +saveAs: "另存为..." +advanced: "高级" +value: "值" +createdAt: "创建日期" +updatedAt: "更新时间" +saveConfirm: "确定保存?" +deleteConfirm: "确定删除?" +invalidValue: "无效值。" +registry: "注册表" +closeAccount: "永久注销账号" +currentVersion: "当前版本" +latestVersion: "最新版本" +youAreRunningUpToDateClient: "您所使用的客户端已经是最新的。" +newVersionOfClientAvailable: "新版本的客户端可用。" +usageAmount: "使用量" +capacity: "容量" +inUse: "已使用" +editCode: "编辑代码" +apply: "应用" +receiveAnnouncementFromInstance: "从服务器接收通知" +emailNotification: "邮件通知" +publish: "发布" +inChannelSearch: "频道内搜索" +useReactionPickerForContextMenu: "单击右键打开回应工具栏" +typingUsers: "{users} 正在输入" +jumpToSpecifiedDate: "跳转到特定日期" +showingPastTimeline: "显示过去的时间线" +clear: "清除" +markAllAsRead: "将全部标记为已读" +goBack: "返回" +unlikeConfirm: "取消赞?" +fullView: "全屏" +quitFullView: "退出全屏" +addDescription: "添加描述" +userPagePinTip: "在帖子的菜单中选择“置顶”,即可显示该条帖子。" +notSpecifiedMentionWarning: "有未指定的提及" +info: "关于" +userInfo: "用户信息" +unknown: "未知" +onlineStatus: "在线状态" +hideOnlineStatus: "隐藏在线状态" +hideOnlineStatusDescription: "隐藏在线状态后,可能会降低搜索等功能的便利性。" +online: "在线" +active: "活跃" +offline: "离线" +notRecommended: "不推荐" +botProtection: "Bot 防护" +instanceBlocking: "联合管理" +selectAccount: "选择账号" +switchAccount: "切换账号" +enabled: "已启用" +disabled: "已禁用" +quickAction: "快捷操作" +user: "用户" +administration: "管理" +accounts: "账号" +switch: "切换" +noMaintainerInformationWarning: "管理员信息未设置。" +noBotProtectionWarning: "Bot 防御未设置。" +configure: "设置" +postToGallery: "发送到图库" +gallery: "图库" +recentPosts: "最新发布" +popularPosts: "热门投稿" +shareWithNote: "在帖子中分享" +ads: "广告" +expiration: "截止时间" +memo: "便笺" +priority: "优先级" +high: "高" +middle: "中" +low: "低" +emailNotConfiguredWarning: "电子邮件地址未设置。" +ratio: "比率" +previewNoteText: "预览文本" +customCss: "自定义 CSS" +customCssWarn: "这些设置必须有相关的基础知识,不当的配置可能导致客户端无法正常使用。" +global: "全局" +squareAvatars: "显示方形头像图标" +sent: "发送" +received: "收取" +searchResult: "搜索结果" +hashtags: "话题标签" +troubleshooting: "故障排除" +useBlurEffect: "在 UI 上使用模糊效果" +learnMore: "更多信息" +iceshrimpUpdated: "Iceshrimp 更新完成!" +whatIsNew: "显示更新信息" +translate: "翻译" +translatedFrom: "从 {x} 翻译" +accountDeletionInProgress: "正在删除账号" +usernameInfo: "在服务器上唯一标识您的账号的名称。您可以使用字母 (a ~ z, A ~ Z)、数字 (0 ~ 9) 和下划线 (_)。用户名以后不能更改。" +aiChanMode: "小蓝模式" +keepCw: "保留内容警告" +pubSub: "推送 (Pub)/订阅 (Sub) 账号" +lastCommunication: "最近通信" +resolved: "已解决" +unresolved: "未解决" +breakFollow: "移除关注者" +itsOn: "已开启" +itsOff: "已关闭" +emailRequiredForSignup: "注册账号需要电子邮件地址" +unread: "未读" +filter: "筛选" +controlPanel: "控制面板" +manageAccounts: "管理账号" +makeReactionsPublic: "将回应设置为公开" +makeReactionsPublicDescription: "将您发表过的回应设置成公开可见。" +classic: "居中" +muteThread: "静音帖子串" +unmuteThread: "取消静音帖子串" +ffVisibility: "关注/关注者 可见性" +ffVisibilityDescription: "您可以设置谁可以看到您的关注/关注者信息。" +continueThread: "查看更多帖子" +deleteAccountConfirm: "将要删除账号。是否继续?" +incorrectPassword: "密码错误。" +voteConfirm: "确定投给 “{choice}” ?" +hide: "隐藏" +leaveGroup: "离开群组" +leaveGroupConfirm: "确定离开「{name}」?" +useDrawerReactionPickerForMobile: "在移动设备上使用抽屉显示" +clickToFinishEmailVerification: "点击 [{ok}] 完成电子邮件地址认证。" +overridedDeviceKind: "设备类型" +smartphone: "智能手机" +tablet: "平板" +auto: "自动" +themeColor: "服务器滚动条颜色" +size: "大小" +numberOfColumn: "列数" +searchByGoogle: "Google" +instanceDefaultLightTheme: "服务器默认浅色主题" +instanceDefaultDarkTheme: "服务器默认深色主题" +instanceDefaultThemeDescription: "以对象格式键入主题代码。" +mutePeriod: "静音时间" +indefinitely: "永久" +tenMinutes: "10分钟" +oneHour: "1 小时" +oneDay: "1 天" +oneWeek: "1 周" +reflectMayTakeTime: "可能需要一些时间才能体现出效果。" +failedToFetchAccountInformation: "获取账号信息失败" +rateLimitExceeded: "已超过速率限制" +cropImage: "剪裁图像" +cropImageAsk: "您想要裁剪图像吗?" +file: "文件" +recentNHours: "最近 {n} 小时" +recentNDays: "最近 {n} 天" +noEmailServerWarning: "电子邮件服务器未设置。" +thereIsUnresolvedAbuseReportWarning: "有未处理的举报。" +recommended: "推荐" +check: "检查" +driveCapOverrideLabel: "修改此用户的网盘容量" +driveCapOverrideCaption: "输入 0 或以下的值将容量重置为默认值。" +requireAdminForView: "需要使用管理员账号登录才能查看。" +isSystemAccount: "该账号由系统自动创建和管理。请不要修改、编辑、删除或以其它方式篡改这个账号,否则可能会破坏您的服务器。" +typeToConfirm: "输入 {x} 以确认操作" +deleteAccount: "删除账号" +document: "文档" +numberOfPageCache: "缓存页数" +numberOfPageCacheDescription: "设置较高的值会更方便用户,但设备的负载和内存使用量会增加。" +logoutConfirm: "是否确认登出?" +lastActiveDate: "最近使用时间" +statusbar: "状态栏" +pleaseSelect: "请选择" +reverse: "翻转" +colored: "彩色" +refreshInterval: "更新间隔 " +label: "标签" +type: "类型" +speed: "速度" +slow: "慢" +fast: "快" +sensitiveMediaDetection: "检测到敏感媒体内容" +localOnly: "仅限本地" +remoteOnly: "仅远程" +failedToUpload: "上传失败" +cannotUploadBecauseInappropriate: "无法上传此文件,因为它可能包含不适宜的内容。" +cannotUploadBecauseNoFreeSpace: "由于已无可用网盘空间,无法上传。" +beta: "测试" +enableAutoSensitive: "自动 NSFW 标记" +enableAutoSensitiveDescription: "允许通过机器学习对媒体文件自动设置 NSFW 标志。即使关闭此功能,也可能会根据服务器自动设置。" +activeEmailValidationDescription: "启用更严格的电子邮件地址验证,包括判断它是一次性的电子邮件地址还是可以实际通信的地址。关闭时,则只检查字符串是否正确。" +navbar: "导航栏" +shuffle: "随机" +account: "账号" +move: "迁移" +customKaTeXMacro: "自定义 KaTeX 宏" +customKaTeXMacroDescription: "使用宏来轻松的输入数学表达式吧!宏的用法与 LaTeX 中的命令定义相同。您可以使用 \\newcommand{\\ + name}{content} 或 \\newcommand{\\name}[number of arguments]{content} 来输入数学表达式。举个例子,\\ + newcommand{\\add}[2]{#1 + #2} 会将 \\add{3}{foo} 展开为 3 + foo。此外,宏名称外的花括号 {} 可以被替换为圆括号 + () 和方括号 [],这会影响用于参数的括号。每行只能够定义一个宏,无法在中间换行,且无效的行将被忽略。只支持简单字符串替换功能,不支持高级语法,如条件分支等。" +enableCustomKaTeXMacro: "启用自定义 KaTeX 宏" +_sensitiveMediaDetection: + description: "可以使用机器学习技术自动检测敏感媒体,以便进行审核。服务器负载将略微增加。" + sensitivity: "检测敏感度" + sensitivityDescription: "敏感度较低,则误检(假阳性)会减少;敏感度较高,则漏检(假阴性)会减少。" + setSensitiveFlagAutomatically: "自动设置 NSFW 标签" + setSensitiveFlagAutomaticallyDescription: "即使关闭此配置,识别结果也会在内部保存。" + analyzeVideos: "启用对视频的检测" + analyzeVideosDescription: "除了静止图像之外,还对视频进行分析。服务器负载会略微增加。" +_emailUnavailable: + used: "这个电子邮件地址已经被使用过" + format: "无效的格式" + disposable: "不得使用一次性电子邮件地址" + mx: "邮件服务器不正确" + smtp: "邮件服务器没有响应" +_ffVisibility: + public: "公开" + followers: "仅对关注者可见" + private: "私信" +_signup: + almostThere: "即将完成" + emailAddressInfo: "请输入您所使用的电子邮件地址,它不会公开显示。" + emailSent: "已将确认邮件发送至您输入的电子邮件地址 ({email})。请访问电子邮件中的链接以完成账号创建。" +_accountDelete: + accountDelete: "删除账号" + mayTakeTime: "删除账号是一个性能损耗较大的过程,如果账号持有的内容数量和上传的文件数量较多的话,完成需要花费一段时间。" + sendEmail: "账号删除完成后,将向注册的电子邮件地址发送通知。" + requestAccountDelete: "请求删除账号" + started: "账号删除过程已开始。" + inProgress: "正在删除" +_ad: + back: "返回" + reduceFrequencyOfThisAd: "减少此广告的频率" +_forgotPassword: + enterEmail: "请输入您注册账号时用的电子邮箱地址,密码重置链接将发送至该邮箱上。" + ifNoEmail: "如果您在注册时没有输入电子邮件地址,请联系服务器管理员。" + contactAdmin: "该服务器不支持发送电子邮件。如果您想重设密码,请联系管理员。" +_gallery: + my: "我的图库" + liked: "喜欢的图片" + like: "喜欢" + unlike: "取消喜欢" +_email: + _follow: + title: "您有新的关注者" + _receiveFollowRequest: + title: "您收到了关注请求" +_plugin: + install: "安装插件" + installWarn: "请不要安装不可信的插件。" + manage: "管理插件" +_preferencesBackups: + list: "已创建的备份" + saveNew: "另存为" + loadFile: "从文件导入" + apply: "应用于本设备" + save: "覆盖存档" + inputName: "请输入备份的名称" + cannotSave: "保存失败" + nameAlreadyExists: "备份名称 \"{name}\" 已经存在,请指定其它名称。" + applyConfirm: "您是否要将备份 \"{name}\" 应用到当前设备上?当前设备现有配置将被丢弃。" + saveConfirm: "您确定要覆盖保存 {name} 吗?" + deleteConfirm: "您确定要删除 {name} 吗?" + renameConfirm: "您确定要把 \"{old}\" 改为 \"{new}\" 吗?" + noBackups: "没有备份。您可以使用“创建新的备份”来备份您在该服务器上的客户设置。" + createdAt: "创建日期:{date} {time}" + updatedAt: "更新日期:{date} {time}" + cannotLoad: "无法加载" + invalidFile: "无效的的文件格式" +_registry: + scope: "范围" + key: "键" + keys: "键" + domain: "域" + createKey: "创建键" +_aboutIceshrimp: + about: "Iceshrimp 是由 ThatOneCalculator 创建的 Iceshrimp 的一个分支,自 2022 年开始开发。" + contributors: "主要贡献者" + allContributors: "全体贡献者" + source: "源代码" + translation: "翻译 Iceshrimp" + donate: "赞助 Iceshrimp" + morePatrons: "还有很多其它的人也在支持我们,非常感谢🥰" + patrons: "Iceshrimp 赞助者" + patronsList: 按时间顺序而不是捐赠金额排列。通过上面的链接捐款,让您的名字出现在这里! + sponsors: Calckey 赞助者们 + donateTitle: 喜欢 Calckey 吗? + pleaseDonateToCalckey: 请考虑赞助 Calckey 以支持其开发。 + pleaseDonateToHost: 也请考虑赞助您的主服务器 {host},以帮助支持其运营成本。 + donateHost: 赞助 {host} +_nsfw: + respect: "隐藏敏感内容" + ignore: "不隐藏敏感内容" + force: "总是隐藏内容" +_mfm: + cheatSheet: "MFM 代码速查表" + intro: "MFM 是一种在 Iceshrimp、Iceshrimp、Akkoma 中使用的标记语言,可以在很多地方使用。您可以在此处查看所有可用的 MFM 语法的列表。" + dummy: "通过 Iceshrimp 扩展联邦宇宙的世界" + mention: "提及" + mentionDescription: "可以使用 @+用户名 来指示特定用户。" + hashtag: "话题标签" + hashtagDescription: "可以使用井号+文字来表示话题标签。" + url: "URL" + urlDescription: "可以表示 URL 地址。" + link: "链接" + linkDescription: "可以将部分文字和 URL 关联起来。" + bold: "粗体" + boldDescription: "可以将文字显示为粗体来表示强调。" + small: "缩小" + smallDescription: "可以使内容文字变小、变淡。" + center: "居中" + centerDescription: "可以将内容居中显示。" + inlineCode: "代码(内嵌)" + inlineCodeDescription: "将文字中的程序代码语法高亮显示。" + blockCode: "代码(块)" + blockCodeDescription: "语法高亮显示整块程序代码。" + inlineMath: "数学公式(内嵌)" + inlineMathDescription: "显示内嵌的 KaTeX 公式" + blockMath: "数学公式(块)" + blockMathDescription: "显示整块的 KaTeX 数学公式" + quote: "引用" + quoteDescription: "将内容显示为引用。" + emoji: "自定义表情符号" + emojiDescription: "可以将自定义表情符号使用冒号括起来,就可以显示自定义表情符号了。" + search: "搜索" + searchDescription: "显示含有搜索内容示例的搜索框。" + flip: "翻转" + flipDescription: "将内容上下或左右翻转。" + jelly: "动画(果冻)" + jellyDescription: "显示果冻一样的动画效果。" + tada: "动画(锵锵)" + tadaDescription: "显示\"锵锵!\"的动画效果。" + jump: "动画(跳动)" + jumpDescription: "显示跳动的动画效果。" + bounce: "动画(弹性)" + bounceDescription: "显示弹性一样的动画效果。" + shake: "动画(摇晃)" + shakeDescription: "显示摇晃的动画效果。" + twitch: "动画(颤抖)" + twitchDescription: "显示强烈颤抖的动画效果。" + spin: "动画(回转)" + spinDescription: "显示回转的动画效果。" + x2: "大" + x2Description: "以大尺寸显示内容。" + x3: "非常大" + x3Description: "以更大尺寸显示内容。" + x4: "最大" + x4Description: "以最大尺寸显示内容。" + blur: "模糊" + blurDescription: "产生模糊效果。将鼠标指针放在上面即可将内容显示出来。" + font: "字体" + fontDescription: "可以设置内容所使用的字体。" + rainbow: "彩虹" + rainbowDescription: "用彩虹色来显示内容。" + sparkle: "闪光" + sparkleDescription: "添加发光粒子效果。" + rotate: "旋转" + rotateDescription: "旋转指定的角度。" + plain: "简洁" + plainDescription: "禁用所有内部语法。" + crop: 裁剪 + scale: 缩放 + position: 位置 + fade: 渐淡 + advanced: 高级 MFM + background: 背景色 + fadeDescription: 内容淡入和淡出。 + warn: MFM 可能包含快速移动或华丽的动画 + advancedDescription: 如果禁用,则仅允许基本标记,除非正在播放动态 MFM + foreground: 前景色 + backgroundDescription: 更改文本的背景色。 + play: 播放 MFM + alwaysPlay: 始终自动播放所有动态的 MFM + stop: 停止播放 MFM + positionDescription: 将内容移动指定的量。 + cropDescription: 裁剪内容。 + scaleDescription: 按指定量缩放内容。 + foregroundDescription: 更改文本的前景色。 +_instanceTicker: + none: "不显示" + remote: "仅远程用户" + always: "始终显示" +_serverDisconnectedBehavior: + reload: "自动重载" + dialog: "对话框警告" + quiet: "安静警告" + nothing: 什么也不做 +_channel: + create: "创建频道" + edit: "编辑频道" + setBanner: "设置横幅" + removeBanner: "删除横幅" + featured: "热点" + owned: "管理中" + following: "关注中" + usersCount: "有 {n} 人参与" + notesCount: "{n} 帖子" + nameAndDescription: "名称与描述" + nameOnly: "仅名称" +_menuDisplay: + sideFull: "横向" + sideIcon: "横向(图标)" + top: "顶部" + hide: "隐藏" +_wordMute: + muteWords: "过滤词" + muteWordsDescription: "AND 条件用空格分隔,OR 条件用换行符分隔。" + muteWordsDescription2: "将关键字用斜线括起来表示正则表达式。" + softDescription: "隐藏时间线中指定条件的帖子。" + hardDescription: "防止将具有指定条件的帖子添加到时间线。 即使您更改条件,原先未添加的帖文也会被排除在外。" + soft: "软过滤" + hard: "硬过滤" + mutedNotes: "已过滤的帖子" +_instanceMute: + instanceMuteDescription: "静音列出服务器中的所有帖子和转帖,包括服务器的用户回复。" + instanceMuteDescription2: "设置时用换行符来分隔" + title: "隐藏列出的服务器中的帖子。" + heading: "要静音的服务器列表" +_theme: + explore: "寻找主题" + install: "安装主题" + manage: "主题管理" + code: "主题代码" + description: "描述" + installed: "{name} 已安装" + installedThemes: "已安装的主题" + builtinThemes: "标准主题" + alreadyInstalled: "此主题已经安装" + invalid: "主题格式错误" + make: "制作主题" + base: "基于" + addConstant: "添加常量" + constant: "常量" + defaultValue: "默认值" + color: "颜色" + refProp: "查看属性" + refConst: "查看常量" + key: "主要" + func: "函数" + funcKind: "功能类型" + argument: "参数" + basedProp: "基于的属性名称" + alpha: "不透明度" + darken: "深色" + lighten: "浅色" + inputConstantName: "请输入常量名称" + importInfo: "您可以在此处粘贴主题代码,将其导入到编辑器中" + deleteConstantConfirm: "确定要删除常量 {const} 吗?" + keys: + accent: "强调色" + bg: "背景" + fg: "文本" + focus: "聚焦" + indicator: "标记" + panel: "面板" + shadow: "阴影" + header: "顶栏" + navBg: "侧边栏背景" + navFg: "侧栏文本" + navHoverFg: "侧栏文本(悬停)" + navActive: "侧栏文本(活动)" + navIndicator: "侧栏标记" + link: "链接" + hashtag: "话题标签" + mention: "提及" + mentionMe: "提及(自己)" + renote: "转发" + modalBg: "对话框背景" + divider: "分割线" + scrollbarHandle: "滚动条" + scrollbarHandleHover: "滚动条(悬停)" + dateLabelFg: "日期标签文字" + infoBg: "信息背景" + infoFg: "信息文本" + infoWarnBg: "警告背景" + infoWarnFg: "警告文本" + cwBg: "CW 按钮背景" + cwFg: "CW 按钮文本" + cwHoverBg: "CW 按钮背景(悬停)" + toastBg: "Toast 通知背景" + toastFg: "Toast 通知文本" + buttonBg: "按钮背景" + buttonHoverBg: "按钮背景(悬停)" + inputBorder: "输入框边框" + listItemHoverBg: "下拉列表项目背景(悬停)" + driveFolderBg: "网盘的文件夹背景" + wallpaperOverlay: "壁纸叠加层" + badge: "徽章" + messageBg: "聊天背景" + accentDarken: "强调色(深)" + accentLighten: "强调色(浅)" + fgHighlighted: "高亮显示文本" +_sfx: + note: "新的帖子" + noteMy: "我的帖子" + notification: "通知" + chat: "聊天" + chatBg: "聊天背景" + antenna: "天线接收" + channel: "频道通知" +_ago: + future: "将来" + justNow: "刚刚" + secondsAgo: "{n} 秒前" + minutesAgo: "{n} 分{n2} 秒前" + hoursAgo: "{n} 时{n2} 分前" + daysAgo: "{n} 天{n2} 时前" + weeksAgo: "{n} 周{n2} 天前" + monthsAgo: "{n} 月{n2} 周前" + yearsAgo: "{n} 年{n2} 月前" +_time: + second: "秒" + minute: "分" + hour: "小时" + day: "日" +_tutorial: + title: "如何使用 Iceshrimp" + step1_1: "欢迎!" + step1_2: "让我们帮您设置一下。您很快就能开始畅游联邦宇宙!" + step2_1: "首先,请完成您的个人资料。" + step2_2: "提供一些关于您的信息,让其它人更容易知道他们是否想看您的帖子或关注您。" + step3_1: "现在是时候关注一些人了!" + step3_2: "您的主页和社交馈送是基于您所关注的人,所以试着先关注几个账号。\n点击个人资料右上角的加号圈就可以关注它。" + step4_1: "让我们出发把。" + step4_2: "对于第一条帖子,可以做一个 {introduction} 或一个简单的 \"hello world!\"" + step5_1: "时间线,无处不在的时间线!" + step5_2: "您的服务器已启用 {timelines} 种不同的时间线。" + step5_3: "主页 {icon} 时间线是您可以看到您关注账号的帖子的时间线。" + step5_4: "本地 {icon} 时间线是您可以看到此服务器上其它用户的帖子的时间线。" + step5_5: "社交 {icon} 时间线是主页和本地时间线的结合。" + step5_6: "推荐 {icon} 时间线是您可以看到管理员推荐服务器的帖子的时间线。" + step5_7: "全球 {icon} 时间线是您可以看到来自其它所有互联服务器的帖子的时间线。" + step6_1: "那么,这里是什么地方?" + step6_2: "好吧,您不只是加入 Iceshrimp。您已经加入了 Fediverse 的一个门户,这是一个由成千上万台服务器组成的互联网络。" + step6_3: "每个服务器的工作方式不同,并不是所有的服务器都运行 Iceshrimp。但这个服务器是的! 这有点复杂,但您很快就会明白的。" + step6_4: "现在,去吧,去探索,去享受乐趣吧!" +_2fa: + alreadyRegistered: "您已经注册了两步验证设备。" + registerTOTP: "注册身份验证器应用" + registerSecurityKey: "注册安全或通行密钥" + step1: "首先,在您的设备上安装身份验证器应用,例如 {a} 或 {b}。" + step2: "然后,扫描屏幕上显示的二维码。" + step2Url: "如果您使用的是桌面程序,您也可以输入这个URL:" + step3: "输入您的应用提供的令牌以完成设置。" + step4: "从现在开始,任何登录操作都将要求您提供这样一个登录令牌。" + securityKeyInfo: "除了指纹或 PIN 身份验证外,您还可以通过支持 FIDO2 的硬件安全密钥设置身份验证,以进一步保护您的账号。" + renewTOTPOk: 重新配置 + renewTOTPCancel: 取消 + token: 2FA 令牌 + renewTOTP: 重新配置身份验证器应用 + registerTOTPBeforeKey: 请先设置认证器应用以注册安全或通行密钥。 + renewTOTPConfirm: 这将导致您之前应用中的验证码失效 + step3Title: 输入验证码 + step2Click: 点击此二维码将允许您在安全密钥或手机身份验证器应用中注册 2FA。 + securityKeyNotSupported: 您的浏览器不支持安全密钥。 + securityKeyName: 输入密钥名称 + chromePasskeyNotSupported: 暂不支持 Chrome 通行密钥。 + tapSecurityKey: 请按照您的浏览器的指示注册安全或通行密钥 + removeKey: 移除安全密钥 + removeKeyConfirm: 真的要删除 {name} 密钥吗? + whyTOTPOnlyRenew: 只要注册了安全密钥,就无法删除身份验证器应用。 +_permissions: + "read:account": "查看账号信息" + "write:account": "更改账号信息" + "read:blocks": "查看屏蔽名单" + "write:blocks": "编辑屏蔽名单" + "read:drive": "查看网盘" + "write:drive": "管理网盘文件" + "read:favorites": "查看收藏夹" + "write:favorites": "编辑收藏夹" + "read:following": "查看关注信息" + "write:following": "关注/取消关注其它账号" + "read:messaging": "查看聊天消息" + "write:messaging": "撰写或删除聊天消息" + "read:mutes": "查看静音用户列表" + "write:mutes": "编辑静音用户列表" + "write:notes": "撰写或删除帖子" + "read:notifications": "查看通知" + "write:notifications": "管理通知" + "read:reactions": "查看回应" + "write:reactions": "编辑回应" + "write:votes": "投票" + "read:pages": "查看页面" + "write:pages": "编辑或删除页面" + "read:page-likes": "查看页面上的喜欢" + "write:page-likes": "编辑页面上的喜欢" + "read:user-groups": "查看用户组" + "write:user-groups": "操作用户组" + "read:channels": "查看频道" + "write:channels": "管理频道" + "read:gallery": "浏览图库" + "write:gallery": "编辑图库" + "read:gallery-likes": "读取喜欢的图片" + "write:gallery-likes": "编辑喜欢的图片" +_auth: + shareAccess: "您要授权允许 \"{name}\" 访问您的账号吗?" + shareAccessAsk: "您确定要授权此应用访问您的账号吗?" + permissionAsk: "此应用请求以下权限:" + pleaseGoBack: "请返回至应用" + callback: "正在返回至应用" + denied: "拒绝访问" + allPermissions: 完全的账号访问权限 + copyAsk: 请将以下授权码粘贴到应用中: +_antennaSources: + all: "所有帖子" + homeTimeline: "已关注用户的帖子" + users: "来自指定用户的帖子" + userList: "来自指定列表中的帖子" + userGroup: "来自指定群组中用户的帖子" + instances: 服务器上所有用户的帖子 +_weekday: + sunday: "星期日" + monday: "星期一" + tuesday: "星期二" + wednesday: "星期三" + thursday: "星期四" + friday: "星期五" + saturday: "星期六" +_widgets: + memo: "便签" + notifications: "通知" + timeline: "时间线" + calendar: "日历" + trends: "趋势" + clock: "时钟" + rss: "RSS 阅读器" + rssTicker: "RSS 滚动条" + activity: "活动" + photos: "照片" + digitalClock: "数字时钟" + unixClock: "UNIX 时钟" + federation: "联邦宇宙" + postForm: "发布窗口" + slideshow: "幻灯片展示" + button: "按钮" + onlineUsers: "在线用户" + jobQueue: "作业队列" + serverMetric: "服务器指标" + aiscript: "AiScript 控制台" + aichan: "小蓝" + userList: 用户列表 + meiliStatus: 服务器状态 + meiliIndexCount: 已索引的帖子 + meiliSize: 索引大小 + serverInfo: 服务器信息 + _userList: + chooseList: 选择列表 +_cw: + hide: "隐藏" + show: "查看更多" + chars: "{count} 个字符" + files: "{count} 个文件" +_poll: + noOnlyOneChoice: "需要至少两个选项" + choiceN: "选择 {n}" + noMore: "无法再添加更多了" + canMultipleVote: "允许多个投票" + expiration: "截止时间" + infinite: "永久" + at: "指定日期" + after: "指定时间" + deadlineDate: "截止日期" + deadlineTime: "小时" + duration: "时长" + votesCount: "{n} 票" + totalVotes: "总票数 {n}" + vote: "投票" + showResult: "显示结果" + voted: "已投票" + closed: "已截止" + remainingDays: "{d} 天 {h} 小时后截止" + remainingHours: "{h} 小时 {m} 分后截止" + remainingMinutes: "{m} 分 {s} 秒后截止" + remainingSeconds: "{s} 秒后截止" +_visibility: + public: "公开" + publicDescription: "您的帖子将出现在公共时间线上" + home: "不公开" + homeDescription: "仅发送至首页时间线" + followers: "仅关注者" + followersDescription: "仅对您的关注者和提及的用户可见" + specified: "指定用户" + specifiedDescription: "仅发送至指定用户" + localOnly: "仅限本地" + localOnlyDescription: "对远程用户不可见" +_postForm: + replyPlaceholder: "回复这个帖子..." + quotePlaceholder: "引用这个帖子..." + channelPlaceholder: "发布到频道…" + _placeholders: + a: "现在如何?" + b: "发生了什么?" + c: "您有什么想法?" + d: "您想要发布些什么吗?" + e: "请写下来吧" + f: "等待您的发布..." +_profile: + name: "昵称" + username: "用户名" + description: "个人简介" + youCanIncludeHashtags: "您可以包含一个话题标签。" + metadata: "附加信息" + metadataEdit: "附加信息编辑" + metadataDescription: "使用这些,您可以在您的个人资料中显示其它信息字段。您可以添加带有 {rel} 的 {a} 标签或 {l} 标签来验证您个人资料上的链接!" + metadataLabel: "标签" + metadataContent: "内容" + changeAvatar: "修改头像" + changeBanner: "修改横幅" + locationDescription: 如果您先输入您的城市,它将向其它用户显示您的当地时间。 +_exportOrImport: + allNotes: "所有帖子" + followingList: "已关注用户" + muteList: "已静音用户" + blockingList: "已屏蔽用户" + userLists: "列表" + excludeMutingUsers: "排除已静音用户" + excludeInactiveUsers: "排除不活跃用户" +_charts: + federation: "联合" + apRequest: "请求" + usersIncDec: "用户数量:增加/减少" + usersTotal: "用户总数" + activeUsers: "活跃用户数" + notesIncDec: "帖子:增加/减少" + localNotesIncDec: "本地帖子量增减" + remoteNotesIncDec: "远程帖子量增减" + notesTotal: "帖子总数" + filesIncDec: "文件总数增减" + filesTotal: "合计文件总数" + storageUsageIncDec: "存储空间用量增减" + storageUsageTotal: "合计存储空间用量" +_instanceCharts: + requests: "请求" + users: "用户数量:增加/减少" + usersTotal: "用户总计" + notes: "帖子:增加/减少" + notesTotal: "帖子总计" + ff: "被关注用户/关注者的数量差异 " + ffTotal: "关注/被关注者总计" + cacheSize: "缓存大小:增加/减少" + cacheSizeTotal: "缓存大小总计" + files: "文件总数增减" + filesTotal: "文件数总计" +_timelines: + home: "首页" + local: "本地" + social: "社交" + global: "全局" + recommended: 推荐 +_pages: + newPage: "创建页面" + editPage: "编辑页面" + readPage: "查看页面" + created: "页面已创建" + updated: "页面已更新" + deleted: "该页面已被删除" + pageSetting: "页面设置" + nameAlreadyExists: "该页面 URL 已存在" + invalidNameTitle: "无效的页面 URL" + invalidNameText: "请确认该项不为空" + editThisPage: "编辑此页面" + viewSource: "查看源代码" + viewPage: "查看页面" + like: "赞" + unlike: "取消喜欢" + my: "我的页面" + liked: "喜欢的页面" + featured: "热门" + inspector: "检查器" + contents: "内容" + content: "页面内容" + variables: "变量" + title: "标题" + url: "页面 URL" + summary: "页面摘要" + alignCenter: "居中" + hideTitleWhenPinned: "置顶时隐藏标题" + font: "字体" + fontSerif: "衬线字体" + fontSansSerif: "无衬线字体" + eyeCatchingImageSet: "设置封面图片" + eyeCatchingImageRemove: "删除封面图片" + chooseBlock: "添加块" + selectType: "选择类型" + enterVariableName: "请输入变量名" + variableNameIsAlreadyUsed: "变量名已使用" + contentBlocks: "内容" + inputBlocks: "输入" + specialBlocks: "特殊" + blocks: + text: "文本" + textarea: "文本区域" + section: "章节" + image: "图片" + button: "按钮" + if: "如果" + _if: + variable: "变量" + post: "投稿窗口" + _post: + text: "内容" + attachCanvasImage: "附加画布图像" + canvasId: "画布 ID" + textInput: "文本输入" + _textInput: + name: "变量名" + text: "标题" + default: "默认值" + textareaInput: "多行文本输入" + _textareaInput: + name: "变量名" + text: "标题" + default: "默认值" + numberInput: "输入数值" + _numberInput: + name: "变量名" + text: "标题" + default: "默认值" + canvas: "画布" + _canvas: + id: "画布 ID" + width: "宽度" + height: "高度" + note: "嵌入的帖子" + _note: + id: "帖子 ID" + idDescription: "您也可以将帖子 URL 粘贴到此处。" + detailed: "显示详细信息" + switch: "开关" + _switch: + name: "变量名" + text: "标题" + default: "默认值" + counter: "计数器" + _counter: + name: "变量名" + text: "标题" + inc: "增加值" + _button: + text: "标题" + colored: "彩色" + action: "按下按钮时的行为" + _action: + dialog: "显示对话框" + _dialog: + content: "内容" + resetRandom: "重置随机值" + pushEvent: "发送事件" + _pushEvent: + event: "事件名称" + message: "按下时显示的消息" + variable: "发送的变量" + no-variable: "空" + callAiScript: "调用 AiScript" + _callAiScript: + functionName: "函数名" + radioButton: "选择项" + _radioButton: + name: "变量名" + title: "标题" + values: "使用换行区分的选择项" + default: "默认值" + script: + categories: + flow: "控制" + logical: "逻辑运算" + operation: "计算" + comparison: "比较" + random: "随机" + value: "值" + fn: "函数" + text: "文本操作" + convert: "转换" + list: "列表" + blocks: + text: "文本" + multiLineText: "文本(多行)" + textList: "文本列表" + _textList: + info: "请使用换行符分隔每行" + strLen: "文本长度" + _strLen: + arg1: "文本" + strPick: "提取字符" + _strPick: + arg1: "文本" + arg2: "字符位置" + strReplace: "替换文本" + _strReplace: + arg1: "文本" + arg2: "替换之前" + arg3: "替换之后" + strReverse: "文本反向" + _strReverse: + arg1: "文本" + join: "合并文本" + _join: + arg1: "列表" + arg2: "分隔符" + add: "加" + _add: + arg1: "A" + arg2: "B" + subtract: "减" + _subtract: + arg1: "A" + arg2: "B" + multiply: "乘" + _multiply: + arg1: "A" + arg2: "B" + divide: "除" + _divide: + arg1: "A" + arg2: "B" + mod: "取模 (MOD)" + _mod: + arg1: "A" + arg2: "B" + round: "四舍五入" + _round: + arg1: "数值" + eq: "A 和 B 相等" + _eq: + arg1: "A" + arg2: "B" + notEq: "A 和 B 不等" + _notEq: + arg1: "A" + arg2: "B" + and: "A 和 B" + _and: + arg1: "A" + arg2: "B" + or: "A 或 B" + _or: + arg1: "A" + arg2: "B" + lt: "< A 小于 B" + _lt: + arg1: "A" + arg2: "B" + gt: "> A 大于 B" + _gt: + arg1: "A" + arg2: "B" + ltEq: "<= A 小于等于 B" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: ">= A 大于等于 B" + _gtEq: + arg1: "A" + arg2: "B" + if: "分支" + _if: + arg1: "如果" + arg2: "如果" + arg3: "否则" + not: "否" + _not: + arg1: "否" + random: "随机" + _random: + arg1: "概率" + rannum: "随机数" + _rannum: + arg1: "最小值" + arg2: "最大值" + randomPick: "从列表中随机选择" + _randomPick: + arg1: "列表" + dailyRandom: "随机(每个用户每日)" + _dailyRandom: + arg1: "概率" + dailyRannum: "随机数(每个用户每日)" + _dailyRannum: + arg1: "最小值" + arg2: "最大值" + dailyRandomPick: "从列表中随机选择(每个用户每日)" + _dailyRandomPick: + arg1: "列表" + seedRandom: "随机(种子)" + _seedRandom: + arg1: "种子" + arg2: "概率" + seedRannum: "随机数(种子)" + _seedRannum: + arg1: "种子" + arg2: "最小值" + arg3: "最大值" + seedRandomPick: "从列表中随机选择(种子)" + _seedRandomPick: + arg1: "种子" + arg2: "列表" + DRPWPM: "从概率列表中随机选择(每个用户每日)" + _DRPWPM: + arg1: "文本列表" + pick: "从列表中选择" + _pick: + arg1: "列表" + arg2: "位置" + listLen: "获取列表长度" + _listLen: + arg1: "列表" + number: "数值" + stringToNumber: "文本到数字" + _stringToNumber: + arg1: "文本" + numberToString: "数字到文本" + _numberToString: + arg1: "数值" + splitStrByLine: "将文本按行拆分" + _splitStrByLine: + arg1: "文本" + ref: "变量" + aiScriptVar: "AiScript 变量" + fn: "函数" + _fn: + slots: "槽函数" + slots-info: "请使用换行符分隔每个槽函数" + arg1: "输出" + for: "重复" + _for: + arg1: "次数" + arg2: "处理" + typeError: "槽函数 {slot} 需要传入 \"{expect}\",但是实际传入为 \"{actual}\"!" + thereIsEmptySlot: "槽函数 {slot} 为空!" + types: + string: "文字" + number: "数值" + boolean: "Flag" + array: "列表" + stringArray: "文本列表" + emptySlot: "空白槽函数" + enviromentVariables: "环境变量" + pageVariables: "页面元素" + argVariables: "输入变量" +_relayStatus: + requesting: "待批准" + accepted: "已批准" + rejected: "已拒绝" +_notification: + fileUploaded: "文件已上传" + youGotMention: "来自 {name} 的提及" + youGotReply: "来自 {name} 的回复" + youGotQuote: "来自 {name} 的引用" + youRenoted: "来自 {name} 的转发" + youGotPoll: "来自 {name} 的投票" + youGotMessagingMessageFromUser: "来自 {name} 的聊天" + youGotMessagingMessageFromGroup: "来自 {name} 的群聊" + youWereFollowed: "关注了您" + youReceivedFollowRequest: "您有新的关注请求" + yourFollowRequestAccepted: "您的关注请求已通过" + youWereInvitedToGroup: "{userName} 邀请您加入一个群组" + pollEnded: "问卷调查结果已生成" + emptyPushNotificationMessage: "推送通知已更新" + _types: + all: "全部" + follow: "新关注者" + mention: "提及" + reply: "回复" + renote: "转发" + quote: "引用" + reaction: "回应" + pollVote: "问卷调查投票" + pollEnded: "问卷调查结束" + receiveFollowRequest: "收到的关注请求" + followRequestAccepted: "已通过的关注请求" + groupInvited: "群组加入邀请" + app: "关联应用的通知" + _actions: + followBack: "回关" + reply: "回复" + renote: "转发" + reacted: 回应了您的帖子 + voted: 在您的问卷调查中投了票 + renoted: 转发了您的帖子 +_deck: + alwaysShowMainColumn: "总是显示主列" + columnAlign: "列对齐" + addColumn: "添加列" + configureColumn: "列设置" + swapLeft: "向左移动" + swapRight: "向右移动" + swapUp: "向上移动" + swapDown: "向下移动" + stackLeft: "向左折叠" + popRight: "向右弹出" + profile: "工作区" + newProfile: "新建工作区" + renameProfile: "重命名工作区" + deleteProfile: "删除工作区" + nameAlreadyExists: "该工作区名已存在。" + introduction: "将各列进行组合以创建您自己的界面!" + introduction2: "您可以随时通过屏幕右侧的 + 来添加列。" + widgetsIntroduction: "从列菜单中,选择“编辑小部件”以添加小部件。" + _columns: + main: "主列" + widgets: "小部件" + notifications: "通知" + tl: "时间线" + antenna: "天线" + list: "列表" + mentions: "提及" + direct: "私信" + channel: 频道 +apps: 应用 +_messaging: + dms: 私信 + groups: 群组 +migration: 迁移 +license: 许可证 +flagSpeakAsCatDescription: 在猫模式下您的帖子会喵化 +allowedInstances: 白名单服务器 +listsDesc: 列表可以让您创建含有指定用户的时间线,它们可以从时间线页面访问。 +flagSpeakAsCat: 像猫一样说话 +removeReaction: 移除您的回应 +expandOnNoteClick: 点击打开帖子 +expandOnNoteClickDesc: 如果禁用,您仍然可以在右键菜单中或通过点击时间戳打开帖子。 +sendPushNotificationReadMessage: 已读后删除推送通知 +customMOTD: 自定义 MOTD(启动屏幕消息) +sendPushNotificationReadMessageCaption: 会短暂显示 "{emptyPushNotificationMessage}" 的通知,如果启用,可能会增加您的设备的耗电量。 +adminCustomCssWarn: 仅当您知道此设置的作用时才应使用它。输入不正确的值可能会导致每个人的客户端停止正常运行。请在用户设置中进行测试来确保您的 CSS + 正常工作。 +customMOTDDescription: 自定义 MOTD(启动屏幕)消息,一行一个,每次用户加载/刷新页面时都会随机显示。 +customSplashIconsDescription: 用换行符隔开的自定义启动屏幕图标的 URL,在用户每次加载/重新载入页面时随机显示。请确保图片是在一个静态的 + URL 上,最好全部调整为 192x192 的大小。 +recommendedInstancesDescription: 推荐的服务器一行一个,它们将出现在推荐时间线中。 +splash: 启动画面 +showUpdates: Iceshrimp 更新后显示弹出窗口 +selectInstance: 选择服务器 +silencedInstances: 禁言的服务器 +antennaInstancesDescription: 列出服务器主机名,一行一个 +pushNotification: 推送通知 +subscribePushNotification: 启用推送通知 +showAdminUpdates: 提示新的 Iceshrimp 版本可用(仅对于管理员) +searchPlaceholder: 搜索 Iceshrimp +addInstance: 添加服务器 +jumpToPrevious: 跳转到上一个 +silenceThisInstance: 禁言此服务器 +manageGroups: 管理群组 +antennasDesc: "天线会显示符合您设置条件的新帖子!\n可以从时间线页面访问它们。" +channelFederationWarn: 频道还没有与其它服务器联合 +seperateRenoteQuote: 单独的转发和引用按钮 +customSplashIcons: 自定义启动屏幕图标(urls) +alt: 替代文字 +pushNotificationNotSupported: 您的浏览器或者服务器不支持推送通知 +showAds: 显示广告 +enterSendsMessage: 按回车键发送信息(关闭则是 Ctrl + Retun 发送) +recommendedInstances: 推荐服务器 +updateAvailable: 可能有可用更新! +swipeOnMobile: 允许在页面之间滑动 +swipeOnDesktop: 允许在桌面端以移动设备方式滑动 +logoImageUrl: Logo 图像 URL +deleted: 已删除 +editNote: 编辑帖子 +edited: 于 {date} {time} 编辑 +selectChannel: 选择一个频道 +accountMoved: 用户已迁移至新账号: +silencedInstancesDescription: 列出您想禁言的服务器的主机名。列出的服务器中的账号被视为 "禁言",只能发出关注请求,如果不被关注,就不能提及本地账号。这不会影响被屏蔽的服务器。 +hiddenTags: 隐藏的话题标签 +userSaysSomethingReason: '{name} 说了 {reason}' +clipsDesc: 便签就像可共享的分类书签。您可以从各个帖子的菜单中创建便签。 +privateModeInfo: 当启用时,只有白名单上的服务器可以与您的服务器联合,所有的帖子都会对公共时间线隐藏。 +allowedInstancesDescription: 要列入联合白名单的服务器的主机名,一行一个(仅适用于私密模式)。 +breakFollowConfirm: 确定要移除关注者吗? +caption: 自动显示说明文字 +newer: 更新的 +older: 更旧的 +noInstances: 没有服务器 +silenced: 禁言的 +accessibility: 无障碍 +secureMode: 安全模式(仅允许授权的拉取) +replayTutorial: 重播教程 +userSaysSomethingReasonReply: '{name} 回复了包含 {reason} 的帖子' +userSaysSomethingReasonQuote: '{name} 引用了一篇包含 {reason} 的帖子' +userSaysSomethingReasonRenote: '{name} 转发了一个包含 {reason} 的帖子' +noThankYou: 不,谢谢 +secureModeInfo: 当向其它服务器请求时,不要在没有验证的情况下发回。 +privateMode: 私密模式 +instanceSecurity: 服务器安全 +image: 图像 +video: 视频 +audio: 音频 +cannotUploadBecauseExceedsFileSizeLimit: 无法上传此文件,因为它超出了允许的最大大小。 +unsubscribePushNotification: 禁用推送通知 +pushNotificationAlreadySubscribed: 推送通知已启用 +enableEmojiReactions: 启用表情符号回应 +cw: 内容警告 +hiddenTagsDescription: 列出您想隐藏的话题标签(不带#)以避免在趋势和探索中显示。隐藏的标签仍然可以通过其它方式被发现。 +enableRecommendedTimeline: 启用推荐时间线 +_skinTones: + medium: 中等 + light: 浅色 + yellow: 黄色 + dark: 深色 + mediumLight: 中等偏淡 + mediumDark: 中等偏深 +isModerator: 监察员 +isAdmin: 管理员 +findOtherInstance: 寻找其它服务器 +moveFromDescription: 这将为您的旧账号设置一个别名,以便您可以从该旧账号迁移到当前账号。在从旧账号迁移之前执行此操作。请输入格式如@person@server.com + 的账号标签 +indexPosts: 索引帖子 +signupsDisabled: 该服务器目前关闭注册,但您随时可以在另一台服务器上注册!如果您有该服务器的邀请码,请在下面输入。 +silencedWarning: 显示这个页面是因为这些用户来自您的管理员设置的禁言服务器,所以他们有可能是垃圾信息。 +isBot: 这个账号是一个机器人 +moveAccountDescription: 这个过程是不可逆的。在迁移之前,请确保您已在新账号上为当前账号设置了别名。请输入格式如 @person@server.com + 账号标签 +moveFromLabel: 您要迁移出的旧账号: +preventAiLearning: 阻止 AI 机器人抓取 +preventAiLearningDescription: 请求第三方人工智能语言模型不要研究您上传的内容,例如帖子和图像。 +noGraze: 请禁用 "Graze for Mastodon" 浏览器扩展,因为它会干扰 Iceshrimp。 +moveTo: 将当前账号迁移至新账号 +moveToLabel: 您要迁移到的目标账号: +moveAccount: 迁移账号! +migrationConfirm: "您确实确定要将账号迁移到 {account} 吗?此操作无法撤消,并且您将无法再次正常使用旧账号。\n另外,请确保您已将此当前账号设置为要移出的账号。" +indexFromDescription: 留空以索引每个帖子 +noteId: 帖子 ID +moveFrom: 从旧账号迁移至此账号 +defaultReaction: 发出和收到帖子的默认表情符号反应 +indexNotice: 现在开始索引。这可能需要一段时间,请至少一个小时内不要重新启动服务器。 +indexFrom: 从帖子 ID 开始的索引 +sendModMail: 发送审核通知 +isLocked: 该账号设置了关注请求 +_filters: + notesBefore: 帖子早于 + followingOnly: 仅关注中 + notesAfter: 帖子晚于 + fromDomain: 来自域名 + withFile: 带有文件 + fromUser: 来自用户 + followersOnly: 仅关注者 +reactionPickerSkinTone: 首选的表情符号肤色 +isPatron: Iceshrimp 赞助 +_dialog: + charactersExceeded: 超出了最大字符数!当前:{current} / 限制:{max} + charactersBelow: 没有足够的字符!当前:{current} / 限制:{min} +enableIdenticonGeneration: 启用 Identicon 生成 +enableServerMachineStats: 启用服务器硬件统计 +_feeds: + atom: Atom + rss: RSS + jsonFeed: JSON 订阅源 + copyFeed: 复制订阅源 +verifiedLink: 已验证链接 +xl: 特大 +showPopup: 以弹出窗口通知用户 +showWithSparkles: 闪闪发光地展示 +youHaveUnreadAnnouncements: 您有未读的公告 +donationLink: 赞助页面链接 +neverShow: 不再显示 +remindMeLater: 稍后再说 +removeQuote: 移除引用 +removeRecipient: 移除接收者 +removeMember: 移除成员 diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml new file mode 100644 index 0000000..22e10f8 --- /dev/null +++ b/locales/zh-TW.yml @@ -0,0 +1,1897 @@ +_lang_: "繁體中文" +headlineIceshrimp: "貼文連繫網路" +introIceshrimp: "歡迎! Iceshrimp是一個開源、去中心化且永遠免費的社群網路平台!🚀" +monthAndDay: "{month}月 {day}日" +search: "搜尋" +notifications: "通知" +username: "使用者名稱" +password: "密碼" +forgotPassword: "忘記密碼" +fetchingAsApObject: "從聯邦宇宙取得中" +ok: "OK" +gotIt: "知道了!" +cancel: "取消" +enterUsername: "輸入使用者名稱" +renotedBy: "{user} 轉傳了" +noNotes: "無貼文" +noNotifications: "沒有通知" +instance: "伺服器" +settings: "設定" +basicSettings: "基本設定" +otherSettings: "其他設定" +openInWindow: "在新視窗開啟" +profile: "個人檔案" +timeline: "時間線" +noAccountDescription: "此用戶還沒有自我介紹。" +login: "登入" +loggingIn: "登入中" +logout: "登出" +signup: "註冊" +uploading: "上傳中..." +save: "儲存" +users: "使用者" +addUser: "新增使用者" +favorite: "添加至我的最愛" +favorites: "我的最愛" +unfavorite: "從我的最愛中移除" +favorited: "已添加至我的最愛。" +alreadyFavorited: "我的最愛中已存在。" +cantFavorite: "無法加入至我的最愛。" +pin: "置頂" +unpin: "取消置頂" +copyContent: "複製內容" +copyLink: "複製連結" +delete: "刪除" +deleteAndEdit: "刪除並編輯" +deleteAndEditConfirm: "要刪除並再次編輯嗎?此貼文的所有反應、轉發和回覆也會消失。" +addToList: "加入至清單" +sendMessage: "發送訊息" +copyUsername: "複製使用者名稱" +searchUser: "搜尋使用者" +reply: "回覆" +loadMore: "載入更多" +showMore: "載入更多" +showLess: "關閉" +youGotNewFollower: "您有新的追隨者" +receiveFollowRequest: "您有新的追隨請求" +followRequestAccepted: "追隨請求已接受" +mention: "提及" +mentions: "提及" +directNotes: "私訊" +importAndExport: "匯入與匯出" +import: "匯入" +export: "匯出" +files: "檔案" +download: "下載" +driveFileDeleteConfirm: "確定要刪除檔案「{name}」嗎?使用此附件的貼文也會跟著消失。" +unfollowConfirm: "確定要取消追隨 「{name}」 嗎?" +exportRequested: "已請求匯出。這可能會花一點時間。結束後檔案將會被放到雲端裡。" +importRequested: "已請求匯入。這可能會花一點時間。" +lists: "清單" +noLists: "你沒有任何清單" +note: "貼文" +notes: "貼文" +following: "追隨中" +followers: "追隨者" +followsYou: "追隨你的人" +createList: "建立清單" +manageLists: "管理清單" +error: "錯誤" +somethingHappened: "發生錯誤" +retry: "重試" +pageLoadError: "載入頁面失敗。" +pageLoadErrorDescription: "這通常是因為網路錯誤或是瀏覽器快取殘留的原因。請先清除瀏覽器快取,稍後再重試。" +serverIsDead: "伺服器沒有回應。請稍等片刻,然後重試。" +youShouldUpgradeClient: "請重新載入以使用新版本的客戶端顯示此頁面。" +enterListName: "輸入清單名稱" +privacy: "隱私" +makeFollowManuallyApprove: "手動審核追隨請求" +defaultNoteVisibility: "預設可見性" +follow: "追隨" +followRequest: "追隨請求" +followRequests: "追隨請求" +unfollow: "取消追隨" +followRequestPending: "追隨許可批准中" +enterEmoji: "輸入表情符號" +renote: "轉發" +unrenote: "取消轉發" +renoted: "已轉發。" +cantRenote: "無法轉發此貼文。" +cantReRenote: "無法轉發之前已經轉發過的內容。" +quote: "引用" +pinnedNote: "已置頂的貼文" +pinned: "置頂" +you: "您" +clickToShow: "按一下以顯示" +sensitive: "敏感內容" +add: "新增" +reaction: "反應" +enableEmojiReaction: "啟用表情符號反應" +showEmojisInReactionNotifications: "在反應通知中顯示表情符號" +reactionSetting: "在選擇器中顯示反應" +reactionSettingDescription2: "拖動以重新列序,點擊以刪除,按下 + 添加。" +rememberNoteVisibility: "記住貼文可見性" +attachCancel: "移除附件" +markAsSensitive: "標記為敏感內容" +unmarkAsSensitive: "取消標記為敏感內容" +enterFileName: "請輸入檔案名稱" +mute: "靜音" +unmute: "解除靜音" +renoteMute: "靜音轉發貼文" +renoteUnmute: "解除靜音轉發貼文" +block: "封鎖" +unblock: "解除封鎖" +suspend: "凍結" +unsuspend: "解除凍結" +blockConfirm: "確定要封鎖此用戶?" +unblockConfirm: "確定解除封鎖此用戶?" +suspendConfirm: "確定凍結此帳號?" +unsuspendConfirm: "確定解凍此帳號?" +selectList: "選擇清單" +selectAntenna: "選擇天線" +selectWidget: "選擇小工具" +editWidgets: "編輯小工具" +editWidgetsExit: "完成" +customEmojis: "自訂表情符號" +emoji: "表情符號" +emojis: "表情符號" +emojiName: "表情符號名稱" +emojiUrl: "表情符號URL" +addEmoji: "加入表情符號" +settingGuide: "推薦設定" +cacheRemoteFiles: "快取遠端檔案" +cacheRemoteFilesDescription: "禁用此設定會停止遠端檔案的緩存,從而節省儲存空間,但資料會因直接連線從而產生額外數據花費。" +flagAsBot: "標記此帳號是機器人" +flagAsBotDescription: "如果本帳戶是由程式控制,請啟用此選項。啟用後,會作為標示幫助其他開發者防止機器人之間產生無限互動的行為,並會調整Iceshrimp內部系統將本帳戶識別為機器人。" +flagAsCat: "你是喵咪嗎?w😺" +flagAsCatDescription: "如果想將本帳戶標示為一隻貓,請開啟此標示!" +flagShowTimelineReplies: "在時間線上顯示貼文的回覆" +flagShowTimelineRepliesDescription: "啟用時,時間線除了顯示用戶的貼文以外,還會顯示用戶對其他貼文的回覆。" +autoAcceptFollowed: "自動准予追隨中使用者的追隨請求" +addAccount: "添加帳戶" +loginFailed: "登入失敗" +showOnRemote: "轉到所在伺服器顯示" +general: "一般" +wallpaper: "桌布" +setWallpaper: "設定桌布" +removeWallpaper: "移除桌布" +searchWith: "搜尋: {q}" +youHaveNoLists: "你沒有任何清單" +followConfirm: "你真的要追隨 「{name}」 嗎?" +proxyAccount: "代理帳戶" +proxyAccountDescription: "代理帳戶是在某些情況下充當其他伺服器用戶的帳戶。例如,當使用者將一個來自其他伺服器的帳戶放在列表中時,由於沒有其他使用者追蹤該帳戶,該指令不會傳送到該伺服器上,因此會由代理帳戶追蹤。" +host: "主機" +selectUser: "選取使用者" +recipient: "收件人" +annotation: "註解" +federation: "站台聯邦" +instances: "伺服器" +registeredAt: "初次註冊" +latestRequestSentAt: "上次發送的請求" +latestRequestReceivedAt: "上次收到的請求" +latestStatus: "最後狀態" +storageUsage: "已使用容量" +charts: "圖表" +perHour: "每小時" +perDay: "每日" +stopActivityDelivery: "停止發送活動" +blockThisInstance: "封鎖此伺服器" +operations: "操作" +software: "軟體" +version: "版本" +metadata: "元資料" +monitor: "監視器" +jobQueue: "佇列" +cpuAndMemory: "CPU及記憶體用量" +network: "網路" +disk: "硬碟" +instanceInfo: "伺服器資訊" +statistics: "統計" +clearQueue: "清除佇列" +clearQueueConfirmTitle: "確定要清除佇列嗎?" +clearQueueConfirmText: "未發佈的貼文將不會發佈。您通常不需要確認。" +clearCachedFiles: "清除快取資料" +clearCachedFilesConfirm: "確定要清除所有遠端暫存資料嗎?" +blockedInstances: "已封鎖的伺服器" +blockedInstancesDescription: "請逐行輸入需要封鎖的伺服器。已封鎖的伺服器將無法與本伺服器進行通訊。" +muteAndBlock: "靜音和封鎖" +mutedUsers: "已靜音用戶" +blockedUsers: "已封鎖用戶" +noUsers: "沒有任何使用者" +editProfile: "編輯個人檔案" +noteDeleteConfirm: "確定刪除此貼文嗎?" +pinLimitExceeded: "不能置頂更多貼文了" +intro: "Iceshrimp 部署完成!請建立管理員帳戶。" +done: "完成" +processing: "處理中" +preview: "預覽" +default: "預設" +defaultValueIs: "預設值:{value}" +noCustomEmojis: "沒有自訂的表情符號" +noJobs: "沒有任務" +federating: "整合搜索中" +blocked: "已封鎖" +suspended: "已凍結" +all: "全部" +subscribing: "訂閱中" +publishing: "直播中" +notResponding: "沒有回應" +instanceFollowing: "追蹤伺服器" +instanceFollowers: "伺服器的追蹤者" +instanceUsers: "此伺服器的用戶" +changePassword: "修改密碼" +security: "安全性" +retypedNotMatch: "兩次輸入不一致。" +currentPassword: "目前密碼" +newPassword: "新密碼" +newPasswordRetype: "確認密碼" +attachFile: "上傳附件" +more: "更多!" +featured: "精選" +usernameOrUserId: "使用者名稱或使用者ID" +noSuchUser: "使用者不存在" +lookup: "查詢" +announcements: "公告" +imageUrl: "圖片URL" +remove: "刪除" +removed: "已成功刪除" +removeAreYouSure: "確定要刪掉「{x}」嗎?" +deleteAreYouSure: "確定要刪掉「{x}」嗎?" +resetAreYouSure: "確定要重設嗎?" +saved: "已儲存" +messaging: "訊息" +upload: "上傳" +keepOriginalUploading: "保留原圖" +keepOriginalUploadingDescription: "上傳圖片時保留原始圖片。關閉時,瀏覽器會在上傳時自動產生用於貼文發布的圖片。" +fromDrive: "從雲端空間" +fromUrl: "從網址" +uploadFromUrl: "從網址上傳" +uploadFromUrlDescription: "您要上傳的文件的網址" +uploadFromUrlRequested: "已請求上傳" +uploadFromUrlMayTakeTime: "還需要一些時間才能完成上傳。" +explore: "探索" +messageRead: "已讀" +noMoreHistory: "沒有更多歷史紀錄" +startMessaging: "開始傳送訊息" +nUsersRead: "{n}人已讀" +agreeTo: "我同意{0}" +tos: "使用條款" +start: "開始" +home: "首頁" +remoteUserCaution: "由於該使用者來自遠端實例,因此資料可能是非即時的。" +activity: "動態" +images: "圖片" +birthday: "生日" +yearsOld: "{age}歲" +registeredDate: "註冊日期" +location: "位置" +theme: "外觀主題" +themeForLightMode: "在淺色模式下使用的主題" +themeForDarkMode: "在闇黑模式下使用的主題" +light: "淺色" +dark: "闇黑" +lightThemes: "明亮主題" +darkThemes: "闇黑主題" +syncDeviceDarkMode: "闇黑模式使用裝置設定" +drive: "雲端硬碟" +fileName: "檔案名稱" +selectFile: "選擇檔案" +selectFiles: "選擇檔案" +selectFolder: "選擇資料夾" +selectFolders: "選擇資料夾" +renameFile: "重新命名檔案" +folderName: "資料夾名稱" +createFolder: "創建資料夾" +renameFolder: "重新命名資料夾" +deleteFolder: "刪除資料夾" +addFile: "加入附件" +emptyDrive: "你的雲端硬碟沒有任何東西( ̄▽ ̄)\"" +emptyFolder: "資料夾裡面沒有東西(⊙_⊙;)" +unableToDelete: "無法刪除" +inputNewFileName: "輸入檔案名稱" +inputNewDescription: "請輸入新標題" +inputNewFolderName: "輸入新資料夾的名稱" +circularReferenceFolder: "目標文件夾是您要移動的文件夾的子文件夾。" +hasChildFilesOrFolders: "此文件夾不是空的,無法刪除。" +copyUrl: "複製網址" +rename: "重新命名" +avatar: "大頭貼" +banner: "橫幅" +nsfw: "敏感內容" +whenServerDisconnected: "與伺服器的連接中斷時" +disconnectedFromServer: "與伺服器中斷連線" +reload: "重新整理" +doNothing: "無視" +reloadConfirm: "確定要重新整理嗎?" +watch: "關注" +unwatch: "取消關注" +accept: "接受" +reject: "拒絕" +normal: "正常" +instanceName: "伺服器名稱" +instanceDescription: "伺服器說明" +maintainerName: "管理員名稱" +maintainerEmail: "管理員郵箱" +tosUrl: "服務條款網址" +thisYear: "本年" +thisMonth: "本月" +today: "本日" +dayX: "{day}日" +monthX: "{month}月" +yearX: "{year}年" +pages: "頁面" +integration: "整合" +connectService: "己連結" +disconnectService: "己斷開" +enableLocalTimeline: "開啟本地時間線" +enableGlobalTimeline: "啟用公開時間線" +disablingTimelinesInfo: "即使您關閉了時間線功能,管理員和版主始終可以訪問所有的時間線。" +registration: "註冊" +enableRegistration: "開啟新使用者註冊" +invite: "邀請" +driveCapacityPerLocalAccount: "每個本地用戶的雲端空間大小" +driveCapacityPerRemoteAccount: "每個非本地用戶的雲端容量" +inMb: "以MB為單位" +iconUrl: "圖標網址" +bannerUrl: "橫幅圖像網址" +backgroundImageUrl: "背景圖片的來源網址" +basicInfo: "基本資訊" +pinnedUsers: "置頂用戶" +pinnedUsersDescription: "在「探索」頁面中使用換行標記想要置頂的使用者。" +pinnedPages: "已釘選的頁面" +pinnedPagesDescription: "輸入要固定至伺服器首頁的頁面路徑,一行一個。" +pinnedClipId: "置頂的摘錄ID" +pinnedNotes: "已置頂的貼文" +hcaptcha: "hCaptcha" +enableHcaptcha: "啟用 hCaptcha" +hcaptchaSiteKey: "網站金鑰" +hcaptchaSecretKey: "金鑰" +recaptcha: "reCAPTCHA" +enableRecaptcha: "啟用 reCAPTCHA" +recaptchaSiteKey: "網站金鑰" +recaptchaSecretKey: "金鑰" +avoidMultiCaptchaConfirm: "使用多種驗證方式可能會造成干擾,您要關閉其他驗證方式嗎?您可以按“取消”保留多種驗證方式。" +antennas: "天線" +manageAntennas: "管理天線" +name: "名稱" +antennaSource: "接收來源" +antennaKeywords: "包含關鍵字" +antennaExcludeKeywords: "排除關鍵字" +antennaKeywordsDescription: "用空格分隔指定AND、用換行符分隔指定OR。" +notifyAntenna: "通知有新貼文" +withFileAntenna: "僅帶有附件的貼文" +enableServiceworker: "開啟 ServiceWorker" +antennaUsersDescription: "指定用換行符分隔的用戶名" +caseSensitive: "區分大小寫" +withReplies: "包含回覆" +connectedTo: "您的帳戶已連接到以下社交帳戶" +notesAndReplies: "貼文與回覆" +withFiles: "附件" +silence: "禁言" +silenceConfirm: "確定要禁言此用戶嗎?" +unsilence: "解除禁言" +unsilenceConfirm: "確定要解除禁言嗎?" +popularUsers: "熱門使用者" +recentlyUpdatedUsers: "最近發文的使用者" +recentlyRegisteredUsers: "新加入使用者" +recentlyDiscoveredUsers: "最近發現的使用者" +exploreUsersCount: "有{count}個使用者" +exploreFediverse: "探索聯邦世界" +popularTags: "熱門標籤" +userList: "清單" +about: "資訊" +aboutIceshrimp: "關於 Iceshrimp" +administrator: "管理員" +token: "權杖" +twoStepAuthentication: "兩階段驗證" +moderator: "板主" +moderation: "言論調節" +nUsersMentioned: "提到了{n}" +securityKey: "安全金鑰" +securityKeyName: "金鑰名稱" +registerSecurityKey: "註冊安全金鑰" +lastUsed: "上次使用" +unregister: "註銷帳號" +passwordLessLogin: "設置無密碼登入" +resetPassword: "重置密碼" +newPasswordIs: "新密碼為「{password}」" +reduceUiAnimation: "減少介面的動態視覺" +share: "分享" +notFound: "找不到" +notFoundDescription: "找不到與指定URL回應的頁面。" +uploadFolder: "預設上傳資料夾" +cacheClear: "清除快取" +markAsReadAllNotifications: "標記所有通知為已讀" +markAsReadAllUnreadNotes: "標記所有貼文為已讀" +markAsReadAllTalkMessages: "標記所有訊息為已讀" +help: "幫助" +inputMessageHere: "在此輸入訊息" +close: "關閉" +group: "群組" +groups: "群組" +createGroup: "創建群組" +ownedGroups: "擁有的群組" +joinedGroups: "群組成員" +invites: "邀請" +groupName: "群組名稱" +members: "成員" +transfer: "轉讓" +messagingWithUser: "傳送訊息給其他使用者" +messagingWithGroup: "發送訊息至群組" +title: "標題" +text: "文字" +enable: "啟用" +next: "下一步" +retype: "重新輸入" +noteOf: "{user}的貼文" +inviteToGroup: "邀請至群組" +quoteAttached: "引用" +quoteQuestion: "是否要引用?" +noMessagesYet: "沒有訊息" +newMessageExists: "有新的訊息" +onlyOneFileCanBeAttached: "只能加入一個附件" +signinRequired: "請先登入" +invitations: "邀請" +invitationCode: "邀請碼" +checking: "確認中..." +available: "可用的" +unavailable: "不可用的" +usernameInvalidFormat: "可使用大小寫英文字母、數字和底線。" +tooShort: "過短" +tooLong: "過長" +weakPassword: "密碼強度過弱" +normalPassword: "密碼強度普通" +strongPassword: "密碼強度高" +passwordMatched: "密碼一致" +passwordNotMatched: "密碼不一致" +signinWith: "以{x}登錄" +signinFailed: "登入失敗。 請檢查使用者名稱和密碼。" +tapSecurityKey: "點擊安全密鑰" +or: "或者" +language: "語言" +uiLanguage: "介面語言" +groupInvited: "您有新的群組邀請" +aboutX: "關於{x}" +useOsNativeEmojis: "使用OS原生表情符號" +disableDrawer: "不顯示下拉式選單" +youHaveNoGroups: "找不到群組" +joinOrCreateGroup: "請加入現有群組,或創建新群組。" +noHistory: "沒有歷史紀錄" +signinHistory: "登入歷史" +disableAnimatedMfm: "禁用MFM動畫" +doing: "正在處理..." +category: "類別" +tags: "標籤" +docSource: "文件來源" +createAccount: "建立帳戶" +existingAccount: "現有帳戶" +regenerate: "再生" +fontSize: "字體大小" +noFollowRequests: "沒有要求跟隨您的申請" +openImageInNewTab: "於新分頁中開啟圖片" +dashboard: "儀表板" +local: "本地" +remote: "遠端" +total: "合計" +weekOverWeekChanges: "與上週相比" +dayOverDayChanges: "與前一日相比" +appearance: "外觀" +clientSettings: "用戶端設定" +accountSettings: "帳戶設定" +promotion: "推廣" +promote: "推廣" +numberOfDays: "有效天數" +hideThisNote: "隱藏此貼文" +showFeaturedNotesInTimeline: "在時間線上顯示熱門推薦" +objectStorage: "Object Storage (物件儲存)" +useObjectStorage: "使用Object Storage" +objectStorageBaseUrl: "根URL" +objectStorageBaseUrlDesc: "引用時的URL。如果你使用的是CDN或反向代理,請指定其網址URL。\n例如S3:“https://.s3.amazonaws.com”,GCS:“https://storage.googleapis.com/”。" +objectStorageBucket: "儲存空間(Bucket)" +objectStorageBucketDesc: "請指定您正在使用的服務的存儲桶名稱。" +objectStoragePrefix: "前綴" +objectStoragePrefixDesc: "它存儲在此前綴目錄下。" +objectStorageEndpoint: "端點(Endpoint)" +objectStorageEndpointDesc: "如要使用AWS S3,請留空。否則請依照你使用的服務商的說明書進行設定,以''或 ':'的形式設定端點(Endpoint)。" +objectStorageRegion: "地域(Region)" +objectStorageRegionDesc: "指定一個分區,例如“xx-east-1”。 如果您使用的服務沒有分區的概念,請留空或填寫“us-east-1”。" +objectStorageUseSSL: "使用SSL" +objectStorageUseSSLDesc: "如果不使用https進行API連接,請關閉" +objectStorageUseProxy: "使用網路代理" +objectStorageUseProxyDesc: "如果不使用代理進行API連接,請關閉" +objectStorageSetPublicRead: "上傳時設定為\"public-read\"" +serverLogs: "伺服器日誌" +deleteAll: "刪除所有記錄" +showFixedPostForm: "於時間線頁頂顯示「發送貼文」方框" +newNoteRecived: "發現新的貼文" +sounds: "音效" +listen: "聆聽" +none: "無" +showInPage: "在頁面中顯示" +popout: "彈出型窗口" +volume: "音量" +masterVolume: "主音量" +details: "詳細資訊" +chooseEmoji: "選擇您的表情符號" +unableToProcess: "操作無法完成" +recentUsed: "最近使用" +install: "安裝" +uninstall: "解除安裝" +installedApps: "已授權的應用程式" +nothing: "未發現" +installedDate: "安裝時間" +lastUsedDate: "最後上線日期" +state: "狀態" +sort: "排序" +ascendingOrder: "昇冪" +descendingOrder: "降冪" +scratchpad: "暫存記憶體" +scratchpadDescription: "AiScript控制台為AiScript提供了實驗環境。您可以在此編寫、執行和確認代碼與Iceshrimp互動的结果。" +output: "輸出" +script: "腳本" +disablePagesScript: "停用頁面的AiScript腳本" +updateRemoteUser: "更新遠端使用者資訊" +deleteAllFiles: "刪除所有檔案" +deleteAllFilesConfirm: "要删除所有檔案嗎?" +removeAllFollowing: "解除所有追蹤" +removeAllFollowingDescription: "解除{host}所有的追蹤。在伺服器不再存在時執行。" +userSuspended: "此使用者已被停用。" +userSilenced: "該用戶已被禁言。" +yourAccountSuspendedTitle: "帳戶已被凍結" +yourAccountSuspendedDescription: "由於違反了伺服器的服務條款或其他原因,該帳戶已被凍結。 您可以與管理員連繫以了解更多訊息。 請不要創建一個新的帳戶。" +menu: "選單" +divider: "分割線" +addItem: "新增項目" +relays: "中繼" +addRelay: "新增中繼" +inboxUrl: "收件夾URL" +addedRelays: "已加入的中繼" +serviceworkerInfo: "您需要啟用推送通知。" +deletedNote: "已删除的貼文" +invisibleNote: "隱藏的貼文" +enableInfiniteScroll: "啟用自動滾動頁面模式" +visibility: "可見性" +poll: "投票" +useCw: "隱藏內容" +enablePlayer: "打開播放器" +disablePlayer: "關閉播放器" +expandTweet: "展開推文" +themeEditor: "主題編輯器" +description: "描述" +describeFile: "添加標題" +enterFileDescription: "輸入標題" +author: "作者" +leaveConfirm: "有未保存的更改。要放棄嗎?" +manage: "管理" +plugins: "外掛" +preferencesBackups: "備份設定檔" +deck: "多欄模式" +undeck: "取消多欄模式" +useBlurEffectForModal: "在模態框使用模糊效果" +useFullReactionPicker: "使用全尺寸的反應選擇器" +width: "寬度" +height: "高度" +large: "大" +medium: "中" +small: "小" +generateAccessToken: "發行存取權杖" +permission: "權限" +enableAll: "啟用全部" +disableAll: "停用全部" +tokenRequested: "允許存取帳戶" +pluginTokenRequestedDescription: "此外掛將擁有在此設定的權限。" +notificationType: "通知形式" +edit: "編輯" +emailServer: "電郵伺服器" +enableEmail: "啟用發送電郵功能" +emailConfigInfo: "用於確認電郵地址及密碼重置" +email: "電子郵件" +emailAddress: "電郵地址" +smtpConfig: "SMTP伺服器設定" +smtpHost: "主機" +smtpPort: "埠" +smtpUser: "使用者名稱" +smtpPass: "密碼" +emptyToDisableSmtpAuth: "留空使用者名稱及密碼以關閉SMTP驗證" +smtpSecure: "在 SMTP 連接中使用隱式 SSL/TLS" +smtpSecureInfo: "如使用STARTTLS,請關閉" +testEmail: "測試郵件發送" +wordMute: "被靜音的文字" +regexpError: "正規表達式錯誤" +regexpErrorDescription: "{tab} 靜音文字的第 {line} 行的正規表達式有錯誤:" +instanceMute: "伺服器的靜音" +userSaysSomething: "{name}說了什麼" +makeActive: "啟用" +display: "檢視" +copy: "複製" +metrics: "指標" +overview: "概覽" +logs: "日誌" +delayed: "延遲" +database: "資料庫" +channel: "頻道" +create: "新增" +notificationSetting: "通知設定" +notificationSettingDesc: "選擇顯示通知的類型。" +useGlobalSetting: "使用全域設定" +useGlobalSettingDesc: "啟用時,將使用帳戶通知設定。停用時,則可以單獨設定。" +other: "其他" +regenerateLoginToken: "重新產生登入權杖" +regenerateLoginTokenDescription: "重新產生用於登入的內部權杖。一般情況下是不需要這樣做的。一旦重產,所有裝置將會被登出。" +setMultipleBySeparatingWithSpace: "您可以使用空格分隔多個項目。" +fileIdOrUrl: "檔案ID或URL" +behavior: "行為" +sample: "範例" +abuseReports: "檢舉" +reportAbuse: "檢舉" +reportAbuseOf: "檢舉{name}" +fillAbuseReportDescription: "請填寫檢舉的詳細理由。可以的話,請附上針對的URL網址。" +abuseReported: "回報已送出。感謝您的報告。" +reporter: "檢舉者" +reporteeOrigin: "檢舉來源" +reporterOrigin: "檢舉者來源" +forwardReport: "將報告轉送給遠端伺服器" +forwardReportIsAnonymous: "在遠端伺服器上看不到您的資訊,顯示的報告者是匿名的系統帳戶。" +send: "發送" +abuseMarkAsResolved: "處理完畢" +openInNewTab: "在新分頁中開啟" +openInSideView: "在側欄中開啟" +defaultNavigationBehaviour: "默認導航" +editTheseSettingsMayBreakAccount: "修改這些設定可能會毀損你的帳戶。" +instanceTicker: "貼文的伺服器資訊" +waitingFor: "等待{x}" +random: "隨機" +system: "系統" +switchUi: "界面" +desktop: "桌面" +clip: "摘錄" +createNew: "新建" +optional: "可選" +createNewClip: "建立新摘錄" +unclip: "解除摘錄" +confirmToUnclipAlreadyClippedNote: "此貼文已包含在摘錄「{name}」中。 你想將貼文從這個摘錄中排除嗎?" +public: "公開" +i18nInfo: "Iceshrimp已經被志願者們翻譯成各種語言版本,如果想要幫忙的話,可以進入{link}幫助翻譯。" +manageAccessTokens: "管理存取權杖" +accountInfo: "帳戶資訊" +notesCount: "貼文數量" +repliesCount: "回覆數量" +renotesCount: "轉發數量" +repliedCount: "回覆數量" +renotedCount: "轉發次數" +followingCount: "正在跟隨的用戶數量" +followersCount: "跟隨者數量" +sentReactionsCount: "反應發送次數" +receivedReactionsCount: "反應收到次數" +pollVotesCount: "已統計的投票數" +pollVotedCount: "已投票數" +yes: "確定" +no: "取消" +driveFilesCount: "雲端硬碟檔案數量" +driveUsage: "雲端硬碟使用量" +noCrawle: "拒絕搜尋引擎索引" +noCrawleDescription: "要求網路搜尋引擎不要索引你的個人資料頁、貼文及頁面等。" +lockedAccountInfo: "即使你通過了追隨者請求,除非你將貼文的可見性設定為 「追隨者」,否則任何人都能看見你的貼文。" +alwaysMarkSensitive: "默認將圖像/影像標記為敏感內容" +loadRawImages: "以原始圖檔顯示附件圖檔的縮圖" +disableShowingAnimatedImages: "不播放動態圖檔" +verificationEmailSent: "已發送驗證電子郵件。請點擊進入電子郵件中的鏈接完成驗證。" +notSet: "未設定" +emailVerified: "已成功驗證您的電郵" +noteFavoritesCount: "我的最愛貼文的數目" +pageLikesCount: "頁面被按讚次數" +pageLikedCount: "頁面被按讚次數" +contact: "聯絡人" +useSystemFont: "使用系統預設的字型" +clips: "摘錄" +experimentalFeatures: "實驗中的功能" +developer: "開發者" +makeExplorable: "使自己的帳戶能夠在“探索”頁面中顯示" +makeExplorableDescription: "如果關閉,帳戶將不會被顯示在\"探索\"頁面中。" +showGapBetweenNotesInTimeline: "分開顯示時間線上的貼文" +duplicate: "複製" +left: "左" +center: "置中" +wide: "寬" +narrow: "窄" +reloadToApplySetting: "設定將會在頁面重新載入之後生效。要現在就重載頁面嗎?" +needReloadToApply: "必須重新載入才會生效。" +showTitlebar: "顯示標題列" +clearCache: "清除快取資料" +onlineUsersCount: "{n}人正在線上" +nUsers: "{n}用戶" +nNotes: "{n}貼文" +sendErrorReports: "傳送錯誤報告" +sendErrorReportsDescription: "開啟後,錯誤出現時將會與 Iceshrimp 分享詳細紀錄,對於 Iceshrimp 的開發會有非常大的幫助。\n + 這將包括您的操作系統版本、使用的瀏覽器、您在 Iceshrimp 中的活動等資料。" +myTheme: "我的佈景主題" +backgroundColor: "背景" +accentColor: "重點色彩" +textColor: "文字" +saveAs: "另存為..." +advanced: "進階" +value: "數值" +createdAt: "建立於" +updatedAt: "最後更新" +saveConfirm: "您要儲存變更嗎?" +deleteConfirm: "你確定要刪除嗎?" +invalidValue: "輸入值無效。" +registry: "登錄表" +closeAccount: "停用帳戶" +currentVersion: "目前版本" +latestVersion: "最新版本" +youAreRunningUpToDateClient: "您所使用的用戶端已經是最新的。" +newVersionOfClientAvailable: "新版本的用戶端可用。" +usageAmount: "使用量" +capacity: "容量" +inUse: "已使用" +editCode: "編輯代碼" +apply: "套用" +receiveAnnouncementFromInstance: "接收由本伺服器發出的電郵通知" +emailNotification: "郵件通知" +publish: "發佈" +inChannelSearch: "頻道内搜尋" +useReactionPickerForContextMenu: "點擊右鍵開啟反應工具欄" +typingUsers: "{users}輸入中" +jumpToSpecifiedDate: "跳轉到特定日期" +showingPastTimeline: "顯示過往的時間線" +clear: "清除" +markAllAsRead: "全部標示為已讀" +goBack: "返回" +unlikeConfirm: "要取消按讚嗎?" +fullView: "全熒幕顯示" +quitFullView: "退出全熒幕顯示" +addDescription: "添加描述" +userPagePinTip: "在貼文的選單中選擇\"置頂\",即可置頂該貼文至您的個人檔案頁面。" +notSpecifiedMentionWarning: "此貼文有未指定的提及" +info: "資訊" +userInfo: "用戶資料" +unknown: "未知" +onlineStatus: "在線狀態" +hideOnlineStatus: "隱藏在線狀態" +hideOnlineStatusDescription: "隱藏在線狀態後,可能會降低檢索等功能的便利性。" +online: "線上" +active: "最近活躍" +offline: "離線" +notRecommended: "不推薦" +botProtection: "Bot防護" +instanceBlocking: "聯邦管理" +selectAccount: "選擇帳戶" +switchAccount: "切換帳戶" +enabled: "已啟用" +disabled: "已停用" +quickAction: "快捷操作" +user: "使用者" +administration: "管理" +accounts: "帳戶" +switch: "切換" +noMaintainerInformationWarning: "尚未設定管理員信息。" +noBotProtectionWarning: "尚未設定Bot防護。" +configure: "設定" +postToGallery: "發佈到相簿" +gallery: "相簿" +recentPosts: "最新貼文" +popularPosts: "熱門的貼文" +shareWithNote: "在貼文中分享" +ads: "廣告" +expiration: "期限" +memo: "備忘錄" +priority: "優先級" +high: "高" +middle: "中" +low: "低" +emailNotConfiguredWarning: "沒有設定電郵地址。" +ratio: "%" +previewNoteText: "預覽文本" +customCss: "自定義 CSS" +customCssWarn: "這個設定必須由具備相關知識的人員操作,不當的設定可能导致客戶端無法正常使用。" +global: "公開" +squareAvatars: "頭像以方形顯示" +sent: "發送" +received: "收取" +searchResult: "搜尋結果" +hashtags: "#tag" +troubleshooting: "故障排除" +useBlurEffect: "在 UI 上使用模糊效果" +learnMore: "更多資訊" +iceshrimpUpdated: "Iceshrimp 更新完成!" +whatIsNew: "顯示更新資訊" +translate: "翻譯" +translatedFrom: "從 {x} 翻譯" +accountDeletionInProgress: "正在刪除帳戶" +usernameInfo: "在伺服器上您的帳戶是唯一的識別名稱。您可以使用字母 (a ~ z, A ~ Z)、數字 (0 ~ 9) 和下底線 (_)。之後帳戶名是不能更改的。" +aiChanMode: "小藍模式" +keepCw: "保持CW" +pubSub: "Pub/Sub 帳戶" +lastCommunication: "最近的通信" +resolved: "已解決" +unresolved: "未解決" +breakFollow: "移除追蹤者" +itsOn: "已開啟" +itsOff: "已關閉" +emailRequiredForSignup: "註冊帳戶需要電子郵件地址" +unread: "未讀" +filter: "篩選" +controlPanel: "控制台" +manageAccounts: "管理帳戶" +makeReactionsPublic: "將反應設為公開" +makeReactionsPublicDescription: "將您做過的反應設為公開可見。" +classic: "置中" +muteThread: "將貼文串設為靜音" +unmuteThread: "將貼文串的靜音解除" +ffVisibility: "連接的公開範圍" +ffVisibilityDescription: "您可以設定您的關注/關注者資訊的公開範圍。" +continueThread: "查看更多貼文" +deleteAccountConfirm: "將要刪除帳戶。是否確定?" +incorrectPassword: "密碼錯誤。" +voteConfirm: "確定投給「{choice}」?" +hide: "隱藏" +leaveGroup: "離開群組" +leaveGroupConfirm: "確定離開「{name}」?" +useDrawerReactionPickerForMobile: "在移動設備上使用抽屜顯示" +clickToFinishEmailVerification: "點擊 [{ok}] 完成電子郵件地址認證。" +overridedDeviceKind: "裝置類型" +smartphone: "智慧型手機" +tablet: "平板" +auto: "自動" +themeColor: "主題顏色" +size: "大小" +numberOfColumn: "列數" +searchByGoogle: "搜尋" +instanceDefaultLightTheme: "伺服器預設的淺色主題" +instanceDefaultDarkTheme: "伺服器預設的深色主題" +instanceDefaultThemeDescription: "輸入物件形式的主題代碼。" +mutePeriod: "靜音的期限" +indefinitely: "無期限" +tenMinutes: "10分鐘" +oneHour: "1小時" +oneDay: "1天" +oneWeek: "1週" +reflectMayTakeTime: "可能需要一些時間才會出現效果。" +failedToFetchAccountInformation: "取得帳戶資訊失敗" +rateLimitExceeded: "已超過速率限制" +cropImage: "圖片裁剪" +cropImageAsk: "要剪裁圖片嗎?" +file: "檔案" +recentNHours: "過去{n}小時" +recentNDays: "過去{n}天" +noEmailServerWarning: "尚未設定電子郵件伺服器。" +thereIsUnresolvedAbuseReportWarning: "有尚未處理的檢舉。" +recommended: "推薦" +check: "檢查" +driveCapOverrideLabel: "更改這個使用者的雲端硬碟容量上限" +driveCapOverrideCaption: "如果指定0以下的值,就會被取消。" +requireAdminForView: "必須以管理者帳號登入才可以檢視。" +isSystemAccount: "該帳號由系統自動創建並運行。 千千萬萬不要審核、編輯、刪除或以其他方式修改此帳戶,否則可能會破壞您的伺服器。" +typeToConfirm: "要執行這項操作,請輸入 {x}" +deleteAccount: "刪除帳號" +document: "文件" +numberOfPageCache: "快取頁面數" +numberOfPageCacheDescription: "增加數量會提高便利性,但也會增加負荷與記憶體使用量。" +logoutConfirm: "確定要登出嗎?" +lastActiveDate: "上次使用日期及時間" +statusbar: "狀態列" +pleaseSelect: "請選擇" +reverse: "翻轉" +colored: "彩色" +refreshInterval: "更新間隔 " +label: "標籤" +type: "類型" +speed: "速度" +slow: "慢" +fast: "快" +sensitiveMediaDetection: "敏感性媒體的檢測" +localOnly: "僅限本地" +remoteOnly: "僅限遠端" +failedToUpload: "上傳失敗" +cannotUploadBecauseInappropriate: "由於判定可能包含不適當的內容,因此無法上傳。" +cannotUploadBecauseNoFreeSpace: "由於雲端硬碟沒有可用空間,因此無法上傳。" +beta: "Beta" +enableAutoSensitive: "自動NSFW判定" +enableAutoSensitiveDescription: "如可用,請利用機器學習在媒體上自動設置 NSFW 旗標。 即使關閉此功能,依伺服器而定也可能會自動設置。" +activeEmailValidationDescription: "積極地驗證用戶的電子郵件地址,判斷它是否為免洗地址,或者它是否可以通信。 若關閉,則只會檢查字元是否正確。" +navbar: "導覽列" +shuffle: "隨機" +account: "帳戶" +move: "移動" +customKaTeXMacro: "自定義 KaTeX 宏" +customKaTeXMacroDescription: "使用宏來輕鬆的輸入數學表達式吧!宏的用法與 LaTeX 中的命令定義相同。你可以使用 \\newcommand{\\ + name}{content} 或 \\newcommand{\\name}[number of arguments]{content} 來輸入數學表達式。舉個例子,\\ + newcommand{\\add}[2]{#1 + #2} 會將 \\add{3}{foo} 展開為 3 + foo。此外,宏名稱外的花括號 {} 可以被替換為圓括號 + () 和方括號 [],這會影響用於參數的括號。每行只能夠定義一個宏,無法在中間換行,且無效的行將被忽略。只支持簡單字符串替換功能,不支持高級語法,如條件分支等。" +enableCustomKaTeXMacro: "啟用自定義 KaTeX 宏" +_sensitiveMediaDetection: + description: "您可以使用機器學習自動檢測敏感媒體並將其用於審核。 伺服器的負荷會稍微增加。" + sensitivity: "檢測敏感度" + sensitivityDescription: "敏感度低時,誤檢測(偽陽性)會減少。敏感度高時,漏檢(偽陰性)會減少。" + setSensitiveFlagAutomatically: "設定 NSFW 旗標" + setSensitiveFlagAutomaticallyDescription: "即使將此設定關閉,判定結果也會保留在內部。" + analyzeVideos: "啟用影片分析" + analyzeVideosDescription: "除了靜止影像以外,也分析影片。伺服器的負荷會稍微增加。" +_emailUnavailable: + used: "已經在使用中" + format: "格式無效" + disposable: "不是永久可用的地址" + mx: "郵件伺服器不正確" + smtp: "郵件伺服器沒有應答" +_ffVisibility: + public: "發佈" + followers: "只有關注你的用戶能看到" + private: "私密" +_signup: + almostThere: "即將完成" + emailAddressInfo: "請輸入您所使用的電子郵件地址。電子郵件地址不會被公開。" + emailSent: "已將確認郵件發送至您輸入的電子郵件地址 ({email})。請開啟電子郵件中的連結以完成帳戶創建。" +_accountDelete: + accountDelete: "刪除帳戶" + mayTakeTime: "刪除帳戶的處理負荷較大,如果帳戶產生的內容數量上傳的檔案數量較多的話,就需要花费一段時間才能完成。" + sendEmail: "帳戶删除完成後,將向註冊地電子郵件地址發送通知。" + requestAccountDelete: "刪除帳戶請求" + started: "已開始刪除作業。" + inProgress: "正在刪除" +_ad: + back: "返回" + reduceFrequencyOfThisAd: "降低此廣告的頻率" +_forgotPassword: + enterEmail: "請輸入您的帳戶註冊的電子郵件地址。 密碼重置連結將被發送到該電子郵件地址。" + ifNoEmail: "如果您還沒有註冊您的電子郵件地址,請聯繫管理員。" + contactAdmin: "此伺服器不支援使用電郵,請聯繫您的管理員重置你的密碼。" +_gallery: + my: "我的貼文" + liked: "喜歡的貼文" + like: "讚" + unlike: "收回喜歡" +_email: + _follow: + title: "您有新的追隨者" + _receiveFollowRequest: + title: "收到追隨請求" +_plugin: + install: "安裝外掛組件" + installWarn: "請不要安裝來源不明的外掛組件。" + manage: "管理外掛" +_preferencesBackups: + list: "已備份的設定檔" + saveNew: "另存新檔" + loadFile: "讀取檔案" + apply: "套用在此裝置" + save: "覆蓋存檔" + inputName: "輸入備份檔名稱" + cannotSave: "無法儲存" + nameAlreadyExists: "備份檔名稱「{name}」已經存在。請指定不同的名稱。" + applyConfirm: "將備份檔「{name}」套用在現在的裝置嗎?現在的裝置設定將會消失。" + saveConfirm: "要覆蓋存檔{name}嗎?" + deleteConfirm: "要刪除{name}嗎?" + renameConfirm: "要將「{old}」變更為「{new}」嗎?" + noBackups: "沒有備份檔。您可以用「另存新檔」將現在的客戶端設定儲存在伺服器上。" + createdAt: "建立日期:{date} {time}" + updatedAt: "更新日期:{date} {time}" + cannotLoad: "無法讀取" + invalidFile: "無效的檔案格式" +_registry: + scope: "範圍" + key: "機碼" + keys: "機碼" + domain: "域" + createKey: "新增機碼" +_aboutIceshrimp: + about: "Iceshrimp是由ThatOneCalculator自2022年起開發的Iceshrimp分支。" + contributors: "主要貢獻者" + allContributors: "全體貢獻人員" + source: "原始碼" + translation: "翻譯Iceshrimp" + donate: "贊助Iceshrimp" + morePatrons: "還有許許多多幫助我們的其他人,非常感謝你們。 🥰" + patrons: "贊助者" + patronsList: 按時間順序列出,而不是按贊助規模列出。使用上面的連結贊助,在這裡獲得顯示您名字的機會! + sponsors: Iceshrimp 贊助者們 + donateTitle: 覺得 Iceshrimp 棒嗎? + pleaseDonateToIceshrimp: 請考慮向 Iceshrimp 贊助以支持其發展。 + pleaseDonateToHost: 還請考慮捐贈給您在使用的伺服器 {host},以支援龐大的運營成本。 + donateHost: 贊助給 {host} +_nsfw: + respect: "隱藏敏感內容" + ignore: "不隱藏敏感內容" + force: "隱藏所有內容" +_mfm: + cheatSheet: "MFM代碼小抄" + intro: "MFM是Iceshrimp專用的標記語言,可以在Iceshrimp中的各個位置使用。 您可以這裏看到MFM可用語法列表。" + dummy: "Iceshrimp拓展了Fediverse的世界" + mention: "提及" + mentionDescription: "透過 @+用戶名 來標示特定使用者。" + hashtag: "#tag" + hashtagDescription: "可以使用\"#\"符號後加文字表示話題標籤。" + url: "URL" + urlDescription: "可以展示URL位址。" + link: "鏈接" + linkDescription: "您可以將特定範圍的文章與 URL 相關聯。" + bold: "粗體" + boldDescription: "可以將文字顯示为粗體来強調。" + small: "縮小" + smallDescription: "可以使內容文字變小、變淡。" + center: "置中" + centerDescription: "可以將內容置中顯示。" + inlineCode: "程式碼(内嵌)" + inlineCodeDescription: "在行內用高亮度顯示,例如程式碼語法。" + blockCode: "程式碼(區塊)" + blockCodeDescription: "在區塊中用高亮度顯示,例如複數行的程式碼語法。" + inlineMath: "數學公式(內嵌)" + inlineMathDescription: "顯示內嵌的KaTeX數學公式" + blockMath: "數學公式(方塊)" + blockMathDescription: "以區塊顯示KaTeX數學式" + quote: "引用" + quoteDescription: "可以用來表示引用的内容。" + emoji: "自訂表情符號" + emojiDescription: "您可以通過將自定義表情符號名稱括在冒號中來顯示自定義表情符號。" + search: "搜尋" + searchDescription: "您可以顯示所輸入的搜索框。" + flip: "翻轉" + flipDescription: "將內容上下或左右翻轉。" + jelly: "動畫(果凍)" + jellyDescription: "顯示果凍一樣的動畫效果。" + tada: "動畫(鏘~)" + tadaDescription: "顯示「鏘~!」這種感覺的動畫效果。" + jump: "動畫(跳動)" + jumpDescription: "顯示跳動的動畫效果。" + bounce: "動畫(反彈)" + bounceDescription: "顯示有彈性的動畫效果。" + shake: "動畫(搖晃)" + shakeDescription: "顯示顫抖的動畫效果。" + twitch: "動畫(顫抖)" + twitchDescription: "顯示強烈顫抖的動畫效果。" + spin: "動畫(旋轉)" + spinDescription: "顯示旋轉的動畫效果。" + x2: "大" + x2Description: "放大顯示內容。" + x3: "較大" + x3Description: "放大顯示內容。" + x4: "最大" + x4Description: "將顯示內容放至最大。" + blur: "模糊" + blurDescription: "產生模糊效果。将游標放在上面即可將内容顯示出來。" + font: "字型" + fontDescription: "您可以設定顯示內容的字型。" + rainbow: "彩虹" + rainbowDescription: "用彩虹色來顯示內容。" + sparkle: "閃閃發光" + sparkleDescription: "添加閃閃發光的粒子效果。" + rotate: "旋轉" + rotateDescription: "以指定的角度旋轉。" + plain: "簡潔" + plainDescription: "停用全部的內部語法。" + play: 播放 MFM + stop: 暫停MFM + warn: MFM 可能包含快速移動或顯眼的動畫 + position: 位置 + alwaysPlay: 自動播放所有MFM動畫 + positionDescription: 按指定數量移動內容。 + advancedDescription: 如果禁用,則僅允許基本標記,除非正在播放 MFM 動畫 + advanced: 高級MFM +_instanceTicker: + none: "隱藏" + remote: "向遠端使用者顯示" + always: "總是顯示" +_serverDisconnectedBehavior: + reload: "自動重載" + dialog: "彈出式警告" + quiet: "非侵入式警告" + nothing: 無 +_channel: + create: "建立頻道" + edit: "編輯頻道" + setBanner: "設定橫幅圖像" + removeBanner: "移除橫幅圖像" + featured: "熱門貼文" + owned: "管理中" + following: "關注中" + usersCount: "有{n}人參與" + notesCount: "有{n}個貼文" + nameAndDescription: "名稱與說明" + nameOnly: "僅名稱" +_menuDisplay: + sideFull: "側向" + sideIcon: "側向(圖示)" + top: "頂部" + hide: "隱藏" +_wordMute: + muteWords: "加入靜音文字" + muteWordsDescription: "用空格分隔指定AND,用換行分隔指定OR。" + muteWordsDescription2: "將關鍵字用斜線括起來表示正規表達式。" + softDescription: "隱藏時間線中指定條件的貼文。" + hardDescription: "具有指定條件的貼文將不添加到時間線。 即使您更改條件,未被添加的貼文也會被排除在外。" + soft: "軟性靜音" + hard: "硬性靜音" + mutedNotes: "已靜音的貼文" +_instanceMute: + instanceMuteDescription: "包括對被靜音伺服器上的用戶的回覆,被設定的伺服器上所有貼文及轉發都會被靜音。" + instanceMuteDescription2: "設定時以換行進行分隔" + title: "被設定的伺服器,貼文將被隱藏。" + heading: "將會被靜音的伺服器" +_theme: + explore: "取得佈景主題" + install: "安裝佈景主題" + manage: "佈景主題管理員" + code: "主題代碼" + description: "描述" + installed: "{name}已安裝" + installedThemes: "已經安裝的主題" + builtinThemes: "標準主題" + alreadyInstalled: "此主題已經安裝" + invalid: "主題格式錯誤" + make: "製作主題" + base: "基於" + addConstant: "添加常數" + constant: "常數" + defaultValue: "預設值" + color: "顏色" + refProp: "查看屬性" + refConst: "查看常數" + key: "按鍵" + func: "函数" + funcKind: "功能類型" + argument: "參數" + basedProp: "要基於的屬性的名稱" + alpha: "透明度" + darken: "暗度" + lighten: "亮度" + inputConstantName: "請輸入常數的名稱" + importInfo: "您可以在此貼上主題代碼,將其匯入編輯器中" + deleteConstantConfirm: "確定要删除常數{const}嗎?" + keys: + accent: "重點色彩" + bg: "背景" + fg: "文本" + focus: "聚焦" + indicator: "指標" + panel: "面板" + shadow: "陰影" + header: "標題" + navBg: "側邊欄的背景" + navFg: "側邊欄的文字" + navHoverFg: "側邊欄文字(懸停)" + navActive: "側邊欄文本 (活動)" + navIndicator: "側邊欄指示符" + link: "鏈接" + hashtag: "#tag" + mention: "提到" + mentionMe: "提到了我" + renote: "轉發貼文" + modalBg: "對話框背景" + divider: "分割線" + scrollbarHandle: "捲動條" + scrollbarHandleHover: "捲動條 (漂浮)" + dateLabelFg: "日期標籤文字" + infoBg: "資訊背景" + infoFg: "資訊內容" + infoWarnBg: "警告背景" + infoWarnFg: "警告字元" + cwBg: "CW 按鈕背景" + cwFg: "CW 按鈕文本" + cwHoverBg: "CW 按鈕背景 (漂浮)" + toastBg: "通知背景" + toastFg: "通知文本" + buttonBg: "按鈕背景" + buttonHoverBg: "按鈕背景 (漂浮)" + inputBorder: "輸入框邊框" + listItemHoverBg: "列表物品背景 (漂浮)" + driveFolderBg: "雲端硬碟文件夾背景" + wallpaperOverlay: "壁紙覆蓋層" + badge: "獎章" + messageBg: "私訊背景" + accentDarken: "強調色(偏暗)" + accentLighten: "強調色(明亮)" + fgHighlighted: "高亮顯示文本" +_sfx: + note: "貼文" + noteMy: "我的貼文" + notification: "通知" + chat: "傳送訊息" + chatBg: "聊天背景" + antenna: "天線接收" + channel: "頻道通知" +_ago: + future: "未來" + justNow: "剛剛" + secondsAgo: "{n}秒前" + minutesAgo: "{n}分鐘{n2}秒前" + hoursAgo: "{n}小時{n2}分鐘前" + daysAgo: "{n}天{n2}小時前" + weeksAgo: "{n}周{n2}天前" + monthsAgo: "{n}個月{n2}周前" + yearsAgo: "{n}年{n2}個月前" +_time: + second: "秒" + minute: "分鐘" + hour: "小時" + day: "日" +_tutorial: + title: "如何使用Iceshrimp" + step1_1: "歡迎!" + step1_2: "讓我們把你安排好。你很快就會啟動並運行!" + step2_1: "首先,請完成你的個人資料。" + step2_2: "通過提供一些關於你自己的資料,其他人會更容易了解他們是否想看到你的貼文或關注你。" + step3_1: "現在是時候追隨一些人了!" + step3_2: "你的主頁和社交時間線是基於你所追蹤的人,所以試著先追蹤幾個帳戶。\n點擊個人資料右上角的加號圈就可以關注它。" + step4_1: "讓我們出去找你。" + step4_2: "對於他們的第一條信息,有些人喜歡做 {introduction} 或一個簡單的 \"hello world!\"" + step5_1: "時間線,到處都是時間線!" + step5_2: "您的伺服器已啟用了{timelines}個時間線。" + step5_3: "首頁 {icon} 時間線是顯示你追蹤的帳號的貼文。" + step5_4: "本地 {icon} 時間線是你可以看到伺服器中所有其他用戶的貼文的時間線。" + step5_5: "社交 {icon} 時間線是你的 首頁時間線 和 本地時間線 的結合體。" + step5_6: "推薦 {icon} 時間線是顯示你的伺服器管理員推薦的貼文。" + step5_7: "全球 {icon} 時間線是顯示來自所有其他連接的伺服器的貼文。" + step6_1: "那麼,這裡是什麼地方?" + step6_2: "你不只是加入Iceshrimp。你已經加入了Fediverse的一個門戶,這是一個由成千上萬台服務器組成的互聯網絡。" + step6_3: "每個服務器也有不同,而並不是所有的服務器都運行Iceshrimp。但這個服務器確實是運行Iceshrimp的! 你可能會覺得有點複雜,但你很快就會明白的。" + step6_4: "現在開始探索吧!" +_2fa: + alreadyRegistered: "你已註冊過一個雙重認證的裝置。" + registerTOTP: "註冊裝置" + registerSecurityKey: "註冊鍵" + step1: "首先,在您的設備上安裝二步驗證程式,例如{a}或{b}。" + step2: "然後,掃描螢幕上的QR code。" + step2Url: "在桌面版應用中,請輸入以下的URL:" + step3: "輸入您的App提供的權杖以完成設定。" + step4: "從現在開始,任何登入操作都將要求您提供權杖。" + securityKeyInfo: "您可以設定使用支援FIDO2的硬體安全鎖、終端設備的指纹認證或者PIN碼來登入。" +_permissions: + "read:account": "查看我的帳戶資訊" + "write:account": "更改我的帳戶資訊" + "read:blocks": "已封鎖用戶名單" + "write:blocks": "編輯已封鎖用戶名單" + "read:drive": "存取雲端硬碟" + "write:drive": "編輯雲端硬碟的檔案" + "read:favorites": "瀏覽我的最愛" + "write:favorites": "編輯我的最愛列表" + "read:following": "查看追隨中的用戶資訊" + "write:following": "追隨/解除追隨" + "read:messaging": "顯示訊息" + "write:messaging": "撰寫或刪除私人訊息" + "read:mutes": "顯示已靜音列表" + "write:mutes": "編輯已靜音列表" + "write:notes": "撰寫或刪除貼文" + "read:notifications": "查看通知" + "write:notifications": "編輯通知" + "read:reactions": "查看反應" + "write:reactions": "編輯反應" + "write:votes": "投票" + "read:pages": "顯示頁面" + "write:pages": "編輯頁面" + "read:page-likes": "顯示已按讚的頁面" + "write:page-likes": "編輯頁面上喜歡" + "read:user-groups": "顯示使用者群組" + "write:user-groups": "編輯使用者群組" + "read:channels": "已查看的頻道" + "write:channels": "編輯頻道" + "read:gallery": "瀏覽圖庫" + "write:gallery": "操作圖庫" + "read:gallery-likes": "讀取喜歡的圖片" + "write:gallery-likes": "操作喜歡的圖片" +_auth: + shareAccess: "要授權「“{name}”」存取您的帳戶嗎?" + shareAccessAsk: "您確定要授權這個應用程式使用您的帳戶嗎?" + permissionAsk: "此應用程式需要以下權限" + pleaseGoBack: "請返回至應用程式" + callback: "回到應用程式" + denied: "拒絕訪問" +_antennaSources: + all: "全部貼文" + homeTimeline: "來自已追隨使用者的貼文" + users: "來自特定使用者的貼文" + userList: "來自特定清單中的貼文" + userGroup: "來自特定群組的貼文" +_weekday: + sunday: "週日" + monday: "週一" + tuesday: "週二" + wednesday: "週三" + thursday: "週四" + friday: "週五" + saturday: "週六" +_widgets: + memo: "備忘錄" + notifications: "通知" + timeline: "時間線" + calendar: "行事曆" + trends: "發燒貼文" + clock: "時鐘" + rss: "RSS閱讀器" + rssTicker: "RSS跑馬燈" + activity: "動態" + photos: "照片" + digitalClock: "電子時鐘" + unixClock: "UNIX時間" + federation: "聯邦宇宙" + postForm: "發佈窗口" + slideshow: "幻燈片" + button: "按鈕" + onlineUsers: "線上的用戶" + jobQueue: "佇列" + serverMetric: "伺服器指標" + aiscript: "AiScript控制台" + aichan: "小藍" +_cw: + hide: "隱藏" + show: "瀏覽更多" + chars: "{count}字元" + files: "{count} 個檔案" +_poll: + noOnlyOneChoice: "至少需要兩個選項" + choiceN: "選擇{n}" + noMore: "沒辦法再添加選項了" + canMultipleVote: "可以多次投票" + expiration: "期限" + infinite: "無期限" + at: "結束時間" + after: "在指定時間後結束..." + deadlineDate: "截止日期" + deadlineTime: "小時" + duration: "時長" + votesCount: "{n}票" + totalVotes: "一共{n}票" + vote: "投票" + showResult: "顯示結果" + voted: "已投票" + closed: "已結束" + remainingDays: "{d}天{h}小時後結束" + remainingHours: "{h}小時{m}分後結束" + remainingMinutes: "{m}分{s}秒後結束" + remainingSeconds: "{s}秒後截止" +_visibility: + public: "公開" + publicDescription: "發布給所有用戶" + home: "不在主頁顯示" + homeDescription: "僅發送至首頁的時間線" + followers: "追隨者" + followersDescription: "僅發送至關注者" + specified: "指定使用者" + specifiedDescription: "僅發送至指定使用者" + localOnly: "僅限本地" + localOnlyDescription: "對遠端使用者隱藏" +_postForm: + replyPlaceholder: "回覆此貼文..." + quotePlaceholder: "引用此貼文..." + channelPlaceholder: "發佈到頻道..." + _placeholders: + a: "今天過得如何?" + b: "有什麼新鮮事嗎?" + c: "有什麼新鮮想法嗎?" + d: "想要發布些什麼嗎?" + e: "寫些什麼吧..." + f: "期待你發佈的內容..." +_profile: + name: "名稱" + username: "使用者名稱" + description: "關於我" + youCanIncludeHashtags: "你也可以在「關於我」中加上 #tag。" + metadata: "進階資訊" + metadataEdit: "編輯進階資訊" + metadataDescription: "可以在個人資料中以表格形式顯示其他資訊。您可以添加帶有 {rel} 的 {a} 標籤或 {l} 標籤來驗證您個人資料上的鏈接!" + metadataLabel: "標籤" + metadataContent: "内容" + changeAvatar: "更換大頭貼" + changeBanner: "變更橫幅圖像" + locationDescription: 如果你先輸入你所在的城市,則會向其他用戶顯示你的當地時間。 +_exportOrImport: + allNotes: "所有貼文" + followingList: "追隨中" + muteList: "靜音" + blockingList: "封鎖" + userLists: "清單" + excludeMutingUsers: "排除被靜音的用戶" + excludeInactiveUsers: "排除不活躍帳戶" +_charts: + federation: "站台聯邦" + apRequest: "請求" + usersIncDec: "使用者増減" + usersTotal: "使用者合共" + activeUsers: "活躍使用者" + notesIncDec: "貼文増減" + localNotesIncDec: "本地貼文増減" + remoteNotesIncDec: "遠端貼文數目增减" + notesTotal: "貼文合共" + filesIncDec: "檔案増減" + filesTotal: "累計檔案" + storageUsageIncDec: "儲存空間的増減" + storageUsageTotal: "已使用的儲存空間合共" +_instanceCharts: + requests: "請求" + users: "使用者増減" + usersTotal: "總計使用者" + notes: "貼文増減" + notesTotal: "累計貼文" + ff: "追隨/追隨者的増減 " + ffTotal: "追隨/追隨者累計" + cacheSize: "增加或減少快取用量" + cacheSizeTotal: "快取大小總計" + files: "檔案數量的増減" + filesTotal: "檔案數量總計" +_timelines: + home: "首頁" + local: "本地" + social: "社交" + global: "公開" + recommended: 推薦 +_pages: + newPage: "建立頁面" + editPage: "編輯頁面" + readPage: "正檢視原始碼" + created: "頁面已建立" + updated: "頁面已更新" + deleted: "頁面已被刪除" + pageSetting: "頁面設定" + nameAlreadyExists: "指定的頁面URL已經存在" + invalidNameTitle: "指定的頁面URL無效" + invalidNameText: "請確定是否為非空白" + editThisPage: "編輯此頁面" + viewSource: "檢視原始碼" + viewPage: "顯示頁面" + like: "喜歡" + unlike: "收回喜歡" + my: "我的頁面" + liked: "已喜歡的頁面" + featured: "人氣" + inspector: "面板檢查" + contents: "內容" + content: "頁面方塊" + variables: "變數" + title: "標題" + url: "頁面網址" + summary: "頁面摘要" + alignCenter: "置中" + hideTitleWhenPinned: "被置頂於個人資料時隱藏頁面標題" + font: "字型" + fontSerif: "襯線體" + fontSansSerif: "無襯線體" + eyeCatchingImageSet: "設定封面影像" + eyeCatchingImageRemove: "刪除封面影像" + chooseBlock: "新增方塊" + selectType: "選擇類型" + enterVariableName: "請輸入變數名稱" + variableNameIsAlreadyUsed: "變數名稱已被佔用" + contentBlocks: "內容" + inputBlocks: "輸入" + specialBlocks: "特殊" + blocks: + text: "字串" + textarea: "字串區域" + section: "區段" + image: "圖片" + button: "按鈕" + if: "如果" + _if: + variable: "變數" + post: "發佈窗口" + _post: + text: "内容" + attachCanvasImage: "附加相簿圖像" + canvasId: "畫布ID" + textInput: "插入字串" + _textInput: + name: "變數名稱" + text: "標題" + default: "預設值" + textareaInput: "多行文字输入" + _textareaInput: + name: "變數名稱" + text: "標題" + default: "預設值" + numberInput: "輸入數值" + _numberInput: + name: "變數名稱" + text: "標題" + default: "預設值" + canvas: "畫布" + _canvas: + id: "畫布ID" + width: "寬度" + height: "高度" + note: "嵌式貼文" + _note: + id: "貼文ID" + idDescription: "您也可以粘貼筆記 URL 並進行設置。" + detailed: "顯示詳細內容" + switch: "開關" + _switch: + name: "變數名稱" + text: "標題" + default: "預設值" + counter: "計數器" + _counter: + name: "變數名稱" + text: "標題" + inc: "増加値" + _button: + text: "標題" + colored: "彩色" + action: "按下按鈕後發生的行為" + _action: + dialog: "顯示對話框" + _dialog: + content: "内容" + resetRandom: "重設亂數" + pushEvent: "發送事件" + _pushEvent: + event: "事件名稱" + message: "按下時顯示的消息" + variable: "要發送的變數" + no-variable: "沒有" + callAiScript: "調用AiScript" + _callAiScript: + functionName: "函數名稱" + radioButton: "選項" + _radioButton: + name: "變數名稱" + title: "標題" + values: "由換行符分隔的選項" + default: "預設值" + script: + categories: + flow: "控制" + logical: "邏輯運算" + operation: "計算" + comparison: "對比" + random: "隨機" + value: "數值" + fn: "函数" + text: "文本操作" + convert: "轉換" + list: "清單" + blocks: + text: "字串" + multiLineText: "字串(多行)" + textList: "字串串列" + _textList: + info: "請分開每個換行符" + strLen: "字串長度" + _strLen: + arg1: "字串" + strPick: "提取字元" + _strPick: + arg1: "字串" + arg2: "字元位置" + strReplace: "替換字串" + _strReplace: + arg1: "字串" + arg2: "替換前" + arg3: "替換後" + strReverse: "倒轉字串" + _strReverse: + arg1: "字串" + join: "合併字串" + _join: + arg1: "清單" + arg2: "分隔字元" + add: "加" + _add: + arg1: "A" + arg2: "B" + subtract: "减去" + _subtract: + arg1: "A" + arg2: "B" + multiply: "乘" + _multiply: + arg1: "A" + arg2: "B" + divide: "除" + _divide: + arg1: "A" + arg2: "B" + mod: "餘數" + _mod: + arg1: "A" + arg2: "B" + round: "四舍五入" + _round: + arg1: "數值" + eq: "A和B相等" + _eq: + arg1: "A" + arg2: "B" + notEq: "A和B不等" + _notEq: + arg1: "A" + arg2: "B" + and: "A和B" + _and: + arg1: "A" + arg2: "B" + or: "A或B" + _or: + arg1: "A" + arg2: "B" + lt: "< A小於B" + _lt: + arg1: "A" + arg2: "B" + gt: "> A大於B" + _gt: + arg1: "A" + arg2: "B" + ltEq: "<= A小於或等於B" + _ltEq: + arg1: "A" + arg2: "B" + gtEq: ">= A大於或等於B" + _gtEq: + arg1: "A" + arg2: "B" + if: "分支" + _if: + arg1: "如果" + arg2: "如果" + arg3: "除此以外" + not: "否" + _not: + arg1: "否" + random: "隨機" + _random: + arg1: "機率" + rannum: "亂數" + _rannum: + arg1: "下限" + arg2: "上限" + randomPick: "從列表中隨機選擇" + _randomPick: + arg1: "清單" + dailyRandom: "隨機(使用者每日變化 )" + _dailyRandom: + arg1: "機率" + dailyRannum: "亂數(使用者每日變化)" + _dailyRannum: + arg1: "下限" + arg2: "上限" + dailyRandomPick: "從列表中隨機選擇(使用者每日變化 )" + _dailyRandomPick: + arg1: "清單" + seedRandom: "隨機抽選種子碼" + _seedRandom: + arg1: "種子" + arg2: "機率" + seedRannum: "亂數 (種子)" + _seedRannum: + arg1: "種子" + arg2: "最小值" + arg3: "最大值" + seedRandomPick: "從列表中隨機選擇 (種子)" + _seedRandomPick: + arg1: "種子" + arg2: "清單" + DRPWPM: "从機率列表中隨機選擇(每個用户每天)" + _DRPWPM: + arg1: "字串串列" + pick: "從清單中選取" + _pick: + arg1: "清單" + arg2: "位置" + listLen: "取得清單長度" + _listLen: + arg1: "清單" + number: "數值" + stringToNumber: "將字串轉換至數値" + _stringToNumber: + arg1: "字串" + numberToString: "將數値轉換至字串" + _numberToString: + arg1: "數值" + splitStrByLine: "於換行時分割字串" + _splitStrByLine: + arg1: "字串" + ref: "變數" + aiScriptVar: "AiScript的變數" + fn: "函数" + _fn: + slots: "欄位" + slots-info: "用換行符分隔每個欄位" + arg1: "輸出" + for: "重複" + _for: + arg1: "重複次數" + arg2: "處理" + typeError: "槽參數{slot}需要傳入“{expect}”,但是實際傳入為“{actual}”!" + thereIsEmptySlot: "參數{slot}是空的!" + types: + string: "字串" + number: "数值" + boolean: "標記" + array: "清單" + stringArray: "字串列表" + emptySlot: "空欄位" + enviromentVariables: "環境變數" + pageVariables: "頁面元素" + argVariables: "輸入欄位" +_relayStatus: + requesting: "等待核准" + accepted: "已通過核准" + rejected: "已拒絕" +_notification: + fileUploaded: "上傳檔案成功" + youGotMention: "{name}提及到您" + youGotReply: "{name}回覆了您" + youGotQuote: "{name}引用了您" + youRenoted: "{name} 轉發了你的貼文" + youGotPoll: "{name}已投票" + youGotMessagingMessageFromUser: "{name}發送給您的訊息" + youGotMessagingMessageFromGroup: "{name}發送給您的訊息" + youWereFollowed: "您有新的追隨者" + youReceivedFollowRequest: "您有新的追隨請求" + yourFollowRequestAccepted: "您的追隨請求已通過" + youWereInvitedToGroup: "您有新的群組邀請" + pollEnded: "問卷調查已產生結果" + emptyPushNotificationMessage: "推送通知已更新" + _types: + all: "全部" + follow: "追隨中" + mention: "提及" + reply: "回覆" + renote: "轉發貼文" + quote: "引用" + reaction: "反應" + pollVote: "統計已投票數" + pollEnded: "問卷調查結束" + receiveFollowRequest: "已收到追隨請求" + followRequestAccepted: "追隨請求已接受" + groupInvited: "群組加入邀請" + app: "應用程式通知" + _actions: + followBack: "回關" + reply: "回覆" + renote: "轉發" +_deck: + alwaysShowMainColumn: "總是顯示主欄" + columnAlign: "對齊欄位" + addColumn: "新增欄位" + configureColumn: "欄位的設定" + swapLeft: "向左移動" + swapRight: "向右移動" + swapUp: "往上移動" + swapDown: "往下移動" + stackLeft: "向左折疊" + popRight: "向右彈出" + profile: "工作區" + newProfile: "新增工作區" + renameProfile: "重新命名工作區" + deleteProfile: "刪除工作區" + nameAlreadyExists: "該工作區名稱已經存在。" + introduction: "組合欄位來製作屬於自己的介面吧!" + introduction2: "您可以隨時透過按畫面右方的 + 來添加欄位。" + widgetsIntroduction: "請從欄位的選單中,選擇「編輯小工具」來添加小工具。" + _columns: + main: "主列" + widgets: "小工具" + notifications: "通知" + tl: "時間線" + antenna: "天線" + list: "清單" + mentions: "提及" + direct: "指定使用者" +secureMode: 安全模式(授權獲取) +instanceSecurity: 伺服器安全性 +privateMode: 私人模式 +allowedInstances: 列入白名單的伺服器 +secureModeInfo: 當從其他伺服器請求時,不要在沒有證據的情況下發回。 +_messaging: + dms: 私訊 + groups: 群組 +manageGroups: 管理群組 +replayTutorial: 重新播放教程 +moveFromLabel: '您想遷移的舊帳戶:' +customMOTDDescription: 每次用戶加載/重新加載頁面時,由換行符號分隔的 MOTD(啟動畫面)的自定信息將隨機顯示。 +privateModeInfo: 啟用後,只有列入白名單的伺服器才能與你的伺服器聯合。所有貼文都將對公眾隱藏。 +adminCustomCssWarn: 除非你知道它的作用,否則請不要使用此設定。 輸入不正確的值可能會導致每個人的客戶端無法正常運行。你可在你的的用戶設定中測試,確保你的 + CSS 正常工作。 +showUpdates: Iceshrimp 更新時顯示彈出視窗 +recommendedInstances: 建議的伺服器 +caption: 自動字幕 +enterSendsMessage: 在 Messaging 中按 Return 發送消息 (如關閉則是 Ctrl + Return) +migrationConfirm: "您確定要將你的帳戶遷移到 {account} 嗎? 一旦這樣做,你將無法復原,而你將無法再次正常使用您的帳戶。\n另外,請確保你已將此當前帳戶設置為您要遷移的帳戶。" +customSplashIconsDescription: 每次用戶加載/重新加載頁面時,以換行符號分隔的自定啟動畫面圖標的網址將隨機顯示。請確保圖片位於靜態網址上,最好所有圖片解析度調整為 + 192x192。 +accountMoved: '該使用者已遷移至新帳戶:' +showAds: 顯示廣告 +noThankYou: 不用了,謝謝 +selectInstance: 選擇伺服器 +enableRecommendedTimeline: 啟用推薦時間線 +antennaInstancesDescription: 分行列出一個伺服器 +moveTo: 遷移此帳戶到新帳戶 +moveToLabel: '請輸入你將會遷移到的帳戶:' +moveAccount: 遷移帳戶! +moveAccountDescription: '這個過程是不可逆的。 在遷移前,請確保您已在新帳戶上為此帳戶設置了別名(Alias)。 請輸入帳戶標籤 (格式: + @person@server.com)' +moveFrom: 由舊帳戶移至此帳戶 +moveFromDescription: '這將為你的舊帳戶設置一個別名(Alias),以便你可以從該帳戶轉移到當前帳戶。 在你的舊帳戶移動之前請執行此操作。 請輸入帳戶標籤 + (格式: @person@server.com)' +enableEmojiReactions: 啟用表情符號反應 +breakFollowConfirm: 您確定要移除該關注者嗎? +socialTimeline: 社交時間軸 +cannotUploadBecauseExceedsFileSizeLimit: 因檔案太大而無法上傳。 +customMOTD: 自定義MOTD (網頁載入時顯示的信息) +customSplashIcons: 啟動畫面圖標 (網址) +splash: 啟動畫面 +updateAvailable: 可能有可用的更新! +showAdminUpdates: 表明新的 Iceshrimp 版本可用(只限管理員) +migration: 遷移 +homeTimeline: 主頁時間軸 +swipeOnDesktop: 允許在桌面上進行手機式滑動 +logoImageUrl: 圖標網址 +addInstance: 增加一個伺服器 +noInstances: 沒有伺服器 +flagSpeakAsCat: 像貓一樣地說話 +silenceThisInstance: 靜音此伺服器 +silencedInstances: 已靜音的伺服器 +silenced: 已靜音 +findOtherInstance: 找找另一個伺服器 +noGraze: 瀏覽器擴展 "Graze for Mastodon" 會與Iceshrimp發生衝突,請停用該擴展。 +userSaysSomethingReasonRenote: '{name} 轉傳了包含 {reason} 的貼文' +pushNotificationNotSupported: 你的瀏覽器或伺服器不支援推送通知 +accessibility: 輔助功能 +userSaysSomethingReasonReply: '{name} 回覆了包含 {reason} 的貼文' +hiddenTags: 隱藏主題標籤 +indexPosts: 索引貼文 +indexNotice: 現在開始索引。 這可能需要一段時間,請不要在一個小時內重啟你的伺服器。 +deleted: 已刪除 +editNote: 編輯筆記 +edited: '於 {date} {time} 編輯' +userSaysSomethingReason: '{name} 說了 {reason}' +allowedInstancesDescription: 要加入聯邦白名單的服務器,每台伺服器用新行分隔(僅適用於私有模式)。 +defaultReaction: 默認的表情符號反應 +license: 授權 +apps: 應用 +pushNotification: 推送通知 +subscribePushNotification: 啟用推送通知 +unsubscribePushNotification: 禁用推送通知 +pushNotificationAlreadySubscribed: 推送通知已經啟用 +recommendedInstancesDescription: 以每行分隔的推薦伺服器出現在推薦的時間線中。 +searchPlaceholder: 在聯邦網路上搜尋 +cw: 內容警告 +selectChannel: 選擇一個頻道 +newer: 較新 +older: 較舊 +jumpToPrevious: 跳到上一個 +removeReaction: 移除你的反應 +listsDesc: 清單可以創建一個只有您指定用戶的時間線。 可以從時間線頁面訪問它們。 +flagSpeakAsCatDescription: 在喵咪模式下你的貼文會被喵化ヾ(•ω•`)o +antennasDesc: "天線會顯示符合您設置條件的新貼文!\n 可以從時間線訪問它們。" +expandOnNoteClick: 點擊以打開貼文 +expandOnNoteClickDesc: 如果禁用,您仍然可以通過右鍵單擊菜單或單擊時間戳來打開貼文。 +hiddenTagsDescription: '列出您希望隱藏趨勢和探索的主題標籤(不帶 #)。 隱藏的主題標籤仍然可以通過其他方式發現。' +userSaysSomethingReasonQuote: '{name} 引用了一篇包含 {reason} 的貼文' +silencedInstancesDescription: 列出您想要靜音的伺服器的網址。 您列出的伺服器內的帳戶將被視為“沉默”,只能發出追隨請求,如果不追隨則不能提及本地帳戶。 + 這不會影響被阻止的伺服器。 +video: 影片 +audio: 音訊 +sendPushNotificationReadMessageCaption: 包含文本 “{emptyPushNotificationMessage}” 的通知將顯示一小段時間。 + 這可能會增加您設備的電池使用量(如果適用)。 +channelFederationWarn: 頻道功能尚未與聯邦宇宙連動 +swipeOnMobile: 允許以滑動在頁面之間切換 +sendPushNotificationReadMessage: 閱讀相關通知或消息後刪除推送通知 +image: 圖片 +seperateRenoteQuote: 分別獨立的轉傳及引用按鈕 +clipsDesc: 摘錄就像一個可以分享的書籤。 你可以從每個貼文的菜單創建新摘錄或將貼文加入已有的摘錄。 +noteId: 貼文 ID +sendModMail: 發送審核通知 +enableIdenticonGeneration: 啟用碎片生成 +enableServerMachineStats: 啟用伺服器硬體統計資訊 +reactionPickerSkinTone: 首選表情符號膚色 +indexFromDescription: 留空以索引每個貼文 +preventAiLearning: 防止 AI 機器人抓取 +preventAiLearningDescription: 請求第三方 AI 語言模型不要研究您上傳的內容,例如貼文和圖像。 +indexFrom: 從貼文 ID 開始的索引 +isLocked: 該帳戶已獲得以下批准 +isModerator: 板主 +isAdmin: 管理員 +isPatron: Iceshrimp 項目贊助者 +silencedWarning: 顯示此頁面是因為這些使用者來自您伺服器管理員已靜音的伺服器,因此他們可能是垃圾訊息。 +signupsDisabled: 該伺服器上的註冊當前已被禁用,但您隨時可以在另一台伺服器上註冊!或是您有該伺服器的邀請碼,請在下面輸入。 +showPopup: 通過彈出式視窗通知用戶 +showWithSparkles: 閃閃發光的顯示 +youHaveUnreadAnnouncements: 您有未讀的公告 +donationLink: 連結到贊助頁面 +neverShow: 不再顯示 +remindMeLater: 可能之後 +removeQuote: 删除引用 +removeRecipient: 刪除收件者 +removeMember: 刪除成員 +isBot: 此帳戶是機器人 diff --git a/package.json b/package.json new file mode 100644 index 0000000..436654f --- /dev/null +++ b/package.json @@ -0,0 +1,90 @@ +{ + "name": "iceshrimp", + "version": "v267G.1", + "repository": { + "type": "git", + "url": "https://iceshrimp.dev/iceshrimp/iceshrimp.git" + }, + "type": "module", + "private": true, + "scripts": { + "rebuild": "clean && node ./scripts/build-greet.js && yarn workspaces foreach --include iceshrimp-sdk -Apitv run build && yarn workspaces foreach --exclude iceshrimp-sdk -Apitv run build && node scripts/assets.js", + "build": "node ./scripts/build-greet.js && yarn workspaces foreach --include iceshrimp-sdk -Apitv run build && yarn workspaces foreach --exclude iceshrimp-sdk -Apitv run build && node scripts/assets.js", + "build:debug": "node ./scripts/build-greet.js && yarn workspace iceshrimp-sdk run build:debug && yarn workspaces foreach -Apitv run build:debug && node scripts/assets.js", + "build:optimize": "node ./scripts/optimize.mjs", + "start": "yarn workspace backend run start", + "start:debug": "yarn workspace backend run start:debug", + "start:test": "yarn workspace backend run start:test", + "init": "yarn migrate", + "migrate": "yarn workspace backend run migrate", + "revertmigration": "yarn workspace backend run revertmigration", + "migrateandstart": "yarn migrate && yarn start", + "watch": "yarn dev", + "dev": "node ./scripts/dev.js", + "dev:staging": "NODE_OPTIONS=--max_old_space_size=3072 NODE_ENV=development yarn build && yarn start", + "lint": "yarn workspaces foreach -Ap run lint", + "mocha": "yarn workspace backend run mocha", + "test": "yarn mocha", + "format": "yarn workspaces foreach -Ap run format", + "clean": "node ./scripts/clean.js", + "clean-all": "node ./scripts/clean-all.js", + "cleanall": "yarn clean-all", + "focus-production": "node ./scripts/focus-production.js", + "regen-version": "node ./scripts/regen-version.js", + "db:backup": "node ./scripts/db-backup.mjs", + "db:restore": "node ./scripts/db-restore.mjs", + "full:backup": "node ./scripts/db-backup.mjs", + "full:restore": "node ./scripts/db-restore.mjs", + "pack-yarn": "corepack pack -o .yarn/corepack.tgz" + }, + "workspaces": [ + "packages/backend", + "packages/client", + "packages/sw", + "packages/iceshrimp-sdk" + ], + "resolutions": { + "chokidar": "^3.3.1", + "jwa": "^2.0.1" + }, + "dependencies": { + "@bull-board/api": "5.6.0", + "@bull-board/ui": "5.6.0", + "esbuild": "^0.28.0", + "js-yaml": "4.1.0", + "seedrandom": "^3.0.5", + "yoctocolors": "^2.1.2" + }, + "devDependencies": { + "@biomejs/biome": "^2.4.14", + "@types/node": "^22", + "chokidar": "^5.0.0", + "cross-env": "7.0.3", + "execa": "5.1.1", + "glob": "^13.0.6", + "install-peers": "^1.0.4", + "typescript": "^6.0.3", + "yaml": "^2.3.4" + }, + "packageManager": "yarn@4.17.0+sha512.c2957de2f9025ab14d63b24d0d8be1f1655810e22c341042c27f7ecd017b180ec12db73d69ac366d71b304ef9f069349ce462de96f04f8f1da317f4f762c95ae", + "engines": { + "node": "^22.22.2" + }, + "dependenciesMeta": { + "@discordapp/twemoji@16.0.1": { + "unplugged": true + }, + "@microsoft/api-documenter@7.22.30": { + "unplugged": true + }, + "@microsoft/api-extractor@7.36.3": { + "unplugged": true + }, + "@microsoft/api-extractor-model@7.27.5": { + "unplugged": true + }, + "eventemitter3@4.0.7": { + "unplugged": true + } + } +} diff --git a/packages/README.md b/packages/README.md new file mode 100644 index 0000000..3b6f4bd --- /dev/null +++ b/packages/README.md @@ -0,0 +1,8 @@ +# 📦 Packages + +This directory contains all of the packages Iceshrimp uses. + +- `backend`: Main backend code written in TypeScript for NodeJS +- `client`: Web interface written in Vue3 and TypeScript +- `sw`: Web [Service Worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API) written in TypeScript +- `iceshrimp-sdk`: TypeScript SDK for both backend and client diff --git a/packages/backend/.idea/.gitignore b/packages/backend/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/packages/backend/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/packages/backend/.swcrc b/packages/backend/.swcrc new file mode 100644 index 0000000..272d9f6 --- /dev/null +++ b/packages/backend/.swcrc @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "jsc": { + "parser": { + "syntax": "typescript", + "dynamicImport": true, + "decorators": true + }, + "transform": { + "legacyDecorator": true, + "decoratorMetadata": true + }, + "experimental": { + "keepImportAssertions": true + }, + "baseUrl": ".", + "paths": { + "@/*": [ + "./src/*" + ] + }, + "target": "es2022" + }, + "minify": false +} diff --git a/packages/backend/.vscode/settings.json b/packages/backend/.vscode/settings.json new file mode 100644 index 0000000..9fb3b29 --- /dev/null +++ b/packages/backend/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "typescript.tsdk": "node_modules\\typescript\\lib", + "path-intellisense.mappings": { + "@": "${workspaceRoot}/packages/backend/src/" + }, + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll": true + } +} diff --git a/packages/backend/assets/api-doc.png b/packages/backend/assets/api-doc.png new file mode 100644 index 0000000..88ffb11 --- /dev/null +++ b/packages/backend/assets/api-doc.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4800fe0eadfb361d0d99b8b3f7ee13afd287e24f1fc506237bb91f4c677acbc7 +size 29281 diff --git a/packages/backend/assets/apple-touch-icon.png b/packages/backend/assets/apple-touch-icon.png new file mode 100644 index 0000000..d1cac35 --- /dev/null +++ b/packages/backend/assets/apple-touch-icon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b8a0bea2ef9092d90d71a3b342d3bdc91e30806bd162144580be36ffc0bd198d +size 81462 diff --git a/packages/backend/assets/avatar.png b/packages/backend/assets/avatar.png new file mode 100644 index 0000000..c1a3501 --- /dev/null +++ b/packages/backend/assets/avatar.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e770a13738887f9fbb0b62f9881c7035a36c36832676ae10de531cd5c4c2cc8 +size 14059 diff --git a/packages/backend/assets/badges/error.png b/packages/backend/assets/badges/error.png new file mode 100644 index 0000000..53419f1 --- /dev/null +++ b/packages/backend/assets/badges/error.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12534af236ea0fb67db6ac7df1c62a4e847fb3e7dd31b05d142dd3a66cf9c8eb +size 18990 diff --git a/packages/backend/assets/badges/info.png b/packages/backend/assets/badges/info.png new file mode 100644 index 0000000..9e61007 --- /dev/null +++ b/packages/backend/assets/badges/info.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a3d6e5464d6ab6eb8a91d8228545b4d80a4b93df6c03a439f533326b4c5fc928 +size 11892 diff --git a/packages/backend/assets/favicon.ico b/packages/backend/assets/favicon.ico new file mode 100644 index 0000000..8515bba --- /dev/null +++ b/packages/backend/assets/favicon.ico @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6850c6efc848f9eb056e33c3451f3f64bdfa3241b8493ed28267f9f1a17e56c8 +size 11625 diff --git a/packages/backend/assets/favicon.png b/packages/backend/assets/favicon.png new file mode 100644 index 0000000..9e61007 --- /dev/null +++ b/packages/backend/assets/favicon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a3d6e5464d6ab6eb8a91d8228545b4d80a4b93df6c03a439f533326b4c5fc928 +size 11892 diff --git a/packages/backend/assets/icons/192.png b/packages/backend/assets/icons/192.png new file mode 100644 index 0000000..26a6d84 --- /dev/null +++ b/packages/backend/assets/icons/192.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4c73f95238cc1b75fe51cd70a93c7989c1ed1195e9ea51eff39c81e396ad117 +size 6096 diff --git a/packages/backend/assets/icons/512.png b/packages/backend/assets/icons/512.png new file mode 100644 index 0000000..8825825 --- /dev/null +++ b/packages/backend/assets/icons/512.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b42afd75ca07eef1ad4ecba313cc1156a20b30fe4bc85a54acb55c1c12fb5893 +size 23083 diff --git a/packages/backend/assets/icons/maskable.png b/packages/backend/assets/icons/maskable.png new file mode 100644 index 0000000..0aa399f --- /dev/null +++ b/packages/backend/assets/icons/maskable.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d2c9107c73b24bad24a0fe252575613b7446f7c621d23bdc0a761dd51ea92472 +size 21656 diff --git a/packages/backend/assets/icons/monochrome.png b/packages/backend/assets/icons/monochrome.png new file mode 100644 index 0000000..f61670a --- /dev/null +++ b/packages/backend/assets/icons/monochrome.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a5149e416ce02d268a39d03963bed54fd3eb5079be874a04c87d9a9ac2e19fc +size 17971 diff --git a/packages/backend/assets/mail-wordmark.png b/packages/backend/assets/mail-wordmark.png new file mode 100644 index 0000000..f6eb492 --- /dev/null +++ b/packages/backend/assets/mail-wordmark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d1828d70668ff03f0412fbd149323abad74320e3b4452322ca53b051a21cbd7 +size 26995 diff --git a/packages/backend/assets/notification-badges/LICENSE b/packages/backend/assets/notification-badges/LICENSE new file mode 100644 index 0000000..28b0b50 --- /dev/null +++ b/packages/backend/assets/notification-badges/LICENSE @@ -0,0 +1,24 @@ +Phosphor Icons +------------------------- + +MIT License + +Copyright (c) 2020 Phosphor Icons + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/backend/assets/notification-badges/at.png b/packages/backend/assets/notification-badges/at.png new file mode 100644 index 0000000..7f4d3eb --- /dev/null +++ b/packages/backend/assets/notification-badges/at.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2acf07f96f8037c39c5bf6ba6438f3ebb63fb1e7becbf7cdb2f1ae96216e1045 +size 2412 diff --git a/packages/backend/assets/notification-badges/check.png b/packages/backend/assets/notification-badges/check.png new file mode 100644 index 0000000..e28bce5 --- /dev/null +++ b/packages/backend/assets/notification-badges/check.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0cbf3965a97ad3cc4d291f708f6415247808e5a094fd5cbd52519c851fef29f +size 1183 diff --git a/packages/backend/assets/notification-badges/clipboard-check-solid.png b/packages/backend/assets/notification-badges/clipboard-check-solid.png new file mode 100644 index 0000000..8a744e3 --- /dev/null +++ b/packages/backend/assets/notification-badges/clipboard-check-solid.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1782b1bcdaa3b47857ff94981086a4868923bede3f0fdfee4f8af18bbd8744cb +size 1047 diff --git a/packages/backend/assets/notification-badges/clock.png b/packages/backend/assets/notification-badges/clock.png new file mode 100644 index 0000000..87f8613 --- /dev/null +++ b/packages/backend/assets/notification-badges/clock.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa6d2ca17a1192b895fb8bc4a5d8cf2de8fb500a2b6a0b027ce33bf2cb86ccc4 +size 2101 diff --git a/packages/backend/assets/notification-badges/comments.png b/packages/backend/assets/notification-badges/comments.png new file mode 100644 index 0000000..8e781da --- /dev/null +++ b/packages/backend/assets/notification-badges/comments.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6533ffd26453bd8e66a0362103dac72f34b96e615b4ca5bdfb04e6799f9c56bb +size 1843 diff --git a/packages/backend/assets/notification-badges/id-card-alt.png b/packages/backend/assets/notification-badges/id-card-alt.png new file mode 100644 index 0000000..ce05110 --- /dev/null +++ b/packages/backend/assets/notification-badges/id-card-alt.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e45f153f1ad6eee5d17acaaadc5b4d39a74327678b3d84264a1222b4b0dadcbc +size 1445 diff --git a/packages/backend/assets/notification-badges/null.png b/packages/backend/assets/notification-badges/null.png new file mode 100644 index 0000000..a8fbc87 --- /dev/null +++ b/packages/backend/assets/notification-badges/null.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c1e542d70142b51f0305bc4ae85f7c5c741b7d9239e65e4156e39c3d959931ca +size 174 diff --git a/packages/backend/assets/notification-badges/plus.png b/packages/backend/assets/notification-badges/plus.png new file mode 100644 index 0000000..5a10b20 --- /dev/null +++ b/packages/backend/assets/notification-badges/plus.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3b9c3a2745786c926dd4308554387e594c7988899845dbf0e7c1aad0954b53d1 +size 816 diff --git a/packages/backend/assets/notification-badges/poll-h.png b/packages/backend/assets/notification-badges/poll-h.png new file mode 100644 index 0000000..e5b166f --- /dev/null +++ b/packages/backend/assets/notification-badges/poll-h.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ce9d33ab77b722d07775b748d96ce78a337c6323ac18842c5afd970a84c5731d +size 889 diff --git a/packages/backend/assets/notification-badges/quote-right.png b/packages/backend/assets/notification-badges/quote-right.png new file mode 100644 index 0000000..635dae6 --- /dev/null +++ b/packages/backend/assets/notification-badges/quote-right.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ca86384ee70212e43c5d2bf919953283eedb0428da5e03a66f03370bce7084d +size 1383 diff --git a/packages/backend/assets/notification-badges/reply.png b/packages/backend/assets/notification-badges/reply.png new file mode 100644 index 0000000..16f6406 --- /dev/null +++ b/packages/backend/assets/notification-badges/reply.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c6607481760bf4da84a165f97e99ab22ad798145b5793c92ef22986680303e9 +size 1194 diff --git a/packages/backend/assets/notification-badges/retweet.png b/packages/backend/assets/notification-badges/retweet.png new file mode 100644 index 0000000..783559c --- /dev/null +++ b/packages/backend/assets/notification-badges/retweet.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7407b07d880bfe5a43cc283253a7200d120d8e11bf74bdc1faa71c0d544c270 +size 798 diff --git a/packages/backend/assets/notification-badges/user-plus.png b/packages/backend/assets/notification-badges/user-plus.png new file mode 100644 index 0000000..5b8fb7a --- /dev/null +++ b/packages/backend/assets/notification-badges/user-plus.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43413340454060b15469be0f1a1b9d87844b2d3c2a27998f2765869e4a0306c6 +size 1972 diff --git a/packages/backend/assets/redoc.html b/packages/backend/assets/redoc.html new file mode 100644 index 0000000..d918b10 --- /dev/null +++ b/packages/backend/assets/redoc.html @@ -0,0 +1,23 @@ + + + + Iceshrimp API + + + + + + + + + + + + diff --git a/packages/backend/assets/robots.txt b/packages/backend/assets/robots.txt new file mode 100644 index 0000000..dc17e04 --- /dev/null +++ b/packages/backend/assets/robots.txt @@ -0,0 +1,4 @@ +user-agent: * +allow: / + +# todo: sitemap diff --git a/packages/backend/assets/screenshots/1.webp b/packages/backend/assets/screenshots/1.webp new file mode 100644 index 0000000..83d59a6 --- /dev/null +++ b/packages/backend/assets/screenshots/1.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e8f30c4cea205ae8c93524f08a937e33b4177f01e741b761fb99e5c0b4b5469 +size 42328 diff --git a/packages/backend/assets/screenshots/2.webp b/packages/backend/assets/screenshots/2.webp new file mode 100644 index 0000000..bc2b6ce --- /dev/null +++ b/packages/backend/assets/screenshots/2.webp @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4015c8837179d2c3ed44f2cadd9da0279124df35d9d4574dbc9428cc77b72421 +size 28696 diff --git a/packages/backend/assets/sounds/aisha/1.mp3 b/packages/backend/assets/sounds/aisha/1.mp3 new file mode 100644 index 0000000..493e46f --- /dev/null +++ b/packages/backend/assets/sounds/aisha/1.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:37185482a492c277e04178be64a879f403a4d8a7f1b3c17f75849b3edca26a0f +size 34480 diff --git a/packages/backend/assets/sounds/aisha/2.mp3 b/packages/backend/assets/sounds/aisha/2.mp3 new file mode 100644 index 0000000..5c9c57b --- /dev/null +++ b/packages/backend/assets/sounds/aisha/2.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5b3b259a7161d1d7ba6b37572748a85cd208c02d9d8f7b1272ebcabe0762bb0 +size 24031 diff --git a/packages/backend/assets/sounds/aisha/3.mp3 b/packages/backend/assets/sounds/aisha/3.mp3 new file mode 100644 index 0000000..a7bc140 --- /dev/null +++ b/packages/backend/assets/sounds/aisha/3.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa164bbe105f786ff85bce43e13f12e61552530b1fda0c22aeb1f19c4fb60196 +size 29256 diff --git a/packages/backend/assets/sounds/noizenecio/kick_gaba.mp3 b/packages/backend/assets/sounds/noizenecio/kick_gaba.mp3 new file mode 100644 index 0000000..2721348 --- /dev/null +++ b/packages/backend/assets/sounds/noizenecio/kick_gaba.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a77d6160ebdc0cf92444c9bed4ca7364b2d5ebda0394e8494971160b9b9d2846 +size 18866 diff --git a/packages/backend/assets/sounds/noizenecio/kick_gaba2.mp3 b/packages/backend/assets/sounds/noizenecio/kick_gaba2.mp3 new file mode 100644 index 0000000..5b27941 --- /dev/null +++ b/packages/backend/assets/sounds/noizenecio/kick_gaba2.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:93c9aabc4d23bae420527930936c823f94b29528fb83789502d056afc79a9eff +size 27144 diff --git a/packages/backend/assets/sounds/syuilo/down.mp3 b/packages/backend/assets/sounds/syuilo/down.mp3 new file mode 100644 index 0000000..3971d11 --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/down.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6f80048f15350e70aac71279cadb4f601c7217682044ae12f559579cd2909fb2 +size 18240 diff --git a/packages/backend/assets/sounds/syuilo/kick.mp3 b/packages/backend/assets/sounds/syuilo/kick.mp3 new file mode 100644 index 0000000..7c2d025 --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/kick.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bf29c3b4d76d2c548ac28fc91772524d33aea65f45fecb9c7d97b6adfb436060 +size 15672 diff --git a/packages/backend/assets/sounds/syuilo/pirori-square-wet.mp3 b/packages/backend/assets/sounds/syuilo/pirori-square-wet.mp3 new file mode 100644 index 0000000..b606b43 --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/pirori-square-wet.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a58e53a334229a4cd95e2896462f0e2352637f98878d73053eba7fb8bbb3e10 +size 139200 diff --git a/packages/backend/assets/sounds/syuilo/pirori-wet.mp3 b/packages/backend/assets/sounds/syuilo/pirori-wet.mp3 new file mode 100644 index 0000000..9301cde --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/pirori-wet.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c40b975ffb07f4f04fb6574574e62a4812d30fec3c395202766d0fb1dce7db81 +size 139200 diff --git a/packages/backend/assets/sounds/syuilo/pirori.mp3 b/packages/backend/assets/sounds/syuilo/pirori.mp3 new file mode 100644 index 0000000..7e0ae43 --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/pirori.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:940d13d64a10081296c8b76200f36b0e5a9cb2d2f0a6536a5a0cf8a4bbcf5ca4 +size 19200 diff --git a/packages/backend/assets/sounds/syuilo/poi1.mp3 b/packages/backend/assets/sounds/syuilo/poi1.mp3 new file mode 100644 index 0000000..3a278b9 --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/poi1.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1aad7455f27a47bdcd45bfd3da884d598cd6fcc5899a11c64a4d5af0f47e79fe +size 18240 diff --git a/packages/backend/assets/sounds/syuilo/poi2.mp3 b/packages/backend/assets/sounds/syuilo/poi2.mp3 new file mode 100644 index 0000000..67e7220 --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/poi2.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:01a710f89105b5b4c4619f30d882bb38b61b632b735ce0fa4dff6fee5d1664c9 +size 18240 diff --git a/packages/backend/assets/sounds/syuilo/pope1.mp3 b/packages/backend/assets/sounds/syuilo/pope1.mp3 new file mode 100644 index 0000000..541e3b7 --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/pope1.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1fab4cbee557ca8970bbe780ea0ae2889a42ad68e362b34a1cb8846814a3b833 +size 18240 diff --git a/packages/backend/assets/sounds/syuilo/pope2.mp3 b/packages/backend/assets/sounds/syuilo/pope2.mp3 new file mode 100644 index 0000000..6cf8dbe --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/pope2.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09f99dd288a66667b889a43cb2110746514fd2bb473e86cf6e48a537694984b5 +size 18240 diff --git a/packages/backend/assets/sounds/syuilo/popo.mp3 b/packages/backend/assets/sounds/syuilo/popo.mp3 new file mode 100644 index 0000000..9819555 --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/popo.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:98867595eb034d709e1b414f2e68f2c7c24b7a7f95bdcdd2b024b0f8e97d24db +size 18240 diff --git a/packages/backend/assets/sounds/syuilo/queue-jammed.mp3 b/packages/backend/assets/sounds/syuilo/queue-jammed.mp3 new file mode 100644 index 0000000..ae749e2 --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/queue-jammed.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:de74f557d6f003945db689e9c1f56fc2c2154236edd8f713d70512cde86a3b44 +size 351466 diff --git a/packages/backend/assets/sounds/syuilo/reverved.mp3 b/packages/backend/assets/sounds/syuilo/reverved.mp3 new file mode 100644 index 0000000..473d6b5 --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/reverved.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dd91fe33b1b158643794db0ac7a44b5792860f0450d22d9a1f87249fb1081229 +size 276480 diff --git a/packages/backend/assets/sounds/syuilo/ryukyu.mp3 b/packages/backend/assets/sounds/syuilo/ryukyu.mp3 new file mode 100644 index 0000000..e01f136 --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/ryukyu.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e5fd322114c786ade25e34a0b5e11cc789a419ec0986094f6295d18e557f5380 +size 139200 diff --git a/packages/backend/assets/sounds/syuilo/snare.mp3 b/packages/backend/assets/sounds/syuilo/snare.mp3 new file mode 100644 index 0000000..c1e86f0 --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/snare.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7802a1f89d72a036c7a3bab4a115dca50302ba47d06e9c8a6379550553484d54 +size 26121 diff --git a/packages/backend/assets/sounds/syuilo/square-pico.mp3 b/packages/backend/assets/sounds/syuilo/square-pico.mp3 new file mode 100644 index 0000000..20eee06 --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/square-pico.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1aecd011cba14582ccf65b8bdfb33163a8196fdb7f35e038bd9d053bc24299fe +size 19200 diff --git a/packages/backend/assets/sounds/syuilo/triple.mp3 b/packages/backend/assets/sounds/syuilo/triple.mp3 new file mode 100644 index 0000000..ee68032 --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/triple.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ea5df52551fd233a1e6861d237691f802c2ecce598cdbf75acc225e9194b3a16 +size 18240 diff --git a/packages/backend/assets/sounds/syuilo/up.mp3 b/packages/backend/assets/sounds/syuilo/up.mp3 new file mode 100644 index 0000000..12a2ba3 --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/up.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:880dcde2d0884c8c0dc7c422cd7e5d75c093da20ecdc1e7d9c0d9f6cebd0ecfa +size 18240 diff --git a/packages/backend/assets/sounds/syuilo/waon.mp3 b/packages/backend/assets/sounds/syuilo/waon.mp3 new file mode 100644 index 0000000..15a76bc --- /dev/null +++ b/packages/backend/assets/sounds/syuilo/waon.mp3 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6a30d2f1133ddcef4e0a6c1453c5b0660e84839dcba24cce0a40ba68ff4f448d +size 18240 diff --git a/packages/backend/assets/splash.png b/packages/backend/assets/splash.png new file mode 100644 index 0000000..3c4e274 --- /dev/null +++ b/packages/backend/assets/splash.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d34508152004efc7780cc335929d5e7bd484e7e4b33c085703010ab77931e5c +size 85320 diff --git a/packages/backend/assets/test-color-glyph.svg b/packages/backend/assets/test-color-glyph.svg new file mode 100644 index 0000000..855071b --- /dev/null +++ b/packages/backend/assets/test-color-glyph.svg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a934877650eafe8f312cf57fd3211335cde6d632f3e0f05d2f661215d4bd252 +size 320 diff --git a/packages/backend/assets/transparent.png b/packages/backend/assets/transparent.png new file mode 100644 index 0000000..39271f6 --- /dev/null +++ b/packages/backend/assets/transparent.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:43c3c6bce258b1792a7eb2f0bfe7949fc0560df2165823a9c96badf2a80312f4 +size 68 diff --git a/packages/backend/assets/user-unknown.png b/packages/backend/assets/user-unknown.png new file mode 100644 index 0000000..ef88b98 --- /dev/null +++ b/packages/backend/assets/user-unknown.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d03c2c53fd6f38b37c0a8e3024062129209ceabcd4bed02bc29aa855972f542c +size 3518 diff --git a/packages/backend/assets/woozy.png b/packages/backend/assets/woozy.png new file mode 100644 index 0000000..53419f1 --- /dev/null +++ b/packages/backend/assets/woozy.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:12534af236ea0fb67db6ac7df1c62a4e847fb3e7dd31b05d142dd3a66cf9c8eb +size 18990 diff --git a/packages/backend/jsconfig.json b/packages/backend/jsconfig.json new file mode 100644 index 0000000..f3f4f9c --- /dev/null +++ b/packages/backend/jsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "allowSyntheticDefaultImports": true + }, + "exclude": ["node_modules", "jspm_packages", "tmp", "temp"] +} diff --git a/packages/backend/package.json b/packages/backend/package.json new file mode 100644 index 0000000..6c1486d --- /dev/null +++ b/packages/backend/package.json @@ -0,0 +1,176 @@ +{ + "name": "backend", + "main": "./index.js", + "private": true, + "type": "module", + "scripts": { + "start": "NODE_ENV=production node ./built/index.js", + "start:debug": "node --heapsnapshot-signal=SIGUSR2 --inspect ./built/index.js", + "start:test": "NODE_ENV=test node ./built/index.js", + "migrate": "typeorm migration:run -d built/ormconfig.js", + "revertmigration": "typeorm migration:revert -d built/ormconfig.js", + "generatemigration": "yarn build && typeorm migration:generate -d built/ormconfig.js", + "build": "swc src -d built --extensions .ts,.js --delete-dir-on-start && node ../../scripts/backend-web-assets.js", + "build:debug": "swc src -d built -s --extensions .ts,.js --delete-dir-on-start && node ../../scripts/backend-web-assets.js", + "watch": "swc src -d built -D -w", + "lint": "biome check --apply *", + "format": "biome format * --write" + }, + "optionalDependencies": { + "@swc/core-android-arm64": "1.3.11", + "@types/formidable": "^2.0.5" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.1045.0", + "@aws-sdk/lib-storage": "^3.1045.0", + "@bull-board/api": "6.0.0", + "@bull-board/koa": "6.0.0", + "@bull-board/ui": "6.0.0", + "@discordapp/twemoji": "^16.0.1", + "@iceshrimp/summaly": "2.7.3", + "@koa/cors": "3.4.3", + "@koa/multer": "^4.0.0", + "@koa/router": "^15.5.0", + "@paralleldrive/cuid2": "^2.2.2", + "@peertube/http-signature": "1.7.0", + "@smithy/node-http-handler": "4.6.1", + "@twemoji/parser": "^17.0.1", + "adm-zip": "^0.5.10", + "ajv": "8.12.0", + "archiver": "5.3.1", + "argon2": "^0.44.0", + "async-lock": "1.4.0", + "async-mutex": "^0.4.0", + "bcryptjs": "^3.0.3", + "blurhash": "2.0.5", + "bullmq": "5.16.0", + "cacheable-lookup": "7.0.0", + "cbor": "^10.0.12", + "chalk": "^5.6.2", + "chalk-template": "0.4.0", + "cli-highlight": "2.1.11", + "color-convert": "2.0.1", + "content-disposition": "0.5.4", + "date-fns": "^4.1.0", + "decompress": "^4.2.1", + "deep-email-validator": "0.1.21", + "escape-regexp": "0.0.1", + "fast-xml-parser": "^4.2.7", + "feed": "4.2.2", + "file-type": "^22.0.1", + "fluent-ffmpeg": "2.1.2", + "got": "^15.0.5", + "gunzip-maybe": "^1.4.2", + "happy-dom": "^20.9.0", + "hpagent": "0.1.2", + "iceshrimp-sdk": "workspace:*", + "ioredis": "5.4.1", + "ip-cidr": "3.1.0", + "is-svg": "4.3.2", + "js-yaml": "4.1.0", + "jsdom": "^26.1.0", + "json5": "2.2.3", + "jsonld": "8.2.0", + "jsrsasign": "10.8.6", + "koa": "2.16.4", + "koa-body": "^6.0.1", + "koa-bodyparser": "4.4.1", + "koa-favicon": "2.1.0", + "koa-logger": "3.2.1", + "koa-mount": "4.0.0", + "koa-remove-trailing-slashes": "2.0.3", + "koa-send": "5.0.1", + "koa-slow": "2.1.0", + "koa-views": "7.0.2", + "mfm-js": "^0.25.0", + "mime-types": "2.1.35", + "msgpackr": "1.11.2", + "multer": "^2.1.1", + "nested-property": "4.0.0", + "node-fetch": "3.3.2", + "nodemailer": "6.9.3", + "oauth": "^0.10.0", + "os-utils": "0.0.14", + "otpauth": "^9.1.3", + "parse-duration": "^1.1.0", + "parse5": "7.1.2", + "pg": "8.11.1", + "private-ip": "2.3.4", + "probe-image-size": "7.2.3", + "prom-client": "^15.1.0", + "promise-limit": "2.7.0", + "punycode": "2.3.0", + "pureimage": "^0.4.18", + "qrcode": "1.5.3", + "random-seed": "0.3.0", + "ratelimiter": "3.4.1", + "re2": "^1.22.1", + "redis-lock": "0.1.4", + "redis-semaphore": "5.3.1", + "reflect-metadata": "0.1.13", + "rename": "1.0.4", + "rndstr": "1.0.0", + "rss-parser": "3.13.0", + "sanitize-html": "2.10.0", + "semver": "7.5.4", + "sharp": "0.33.5", + "shogiops": "^0.21.0", + "stringz": "2.1.0", + "syslog-pro": "1.0.0", + "systeminformation": "^5.21.12", + "tar-stream": "^3.1.6", + "tesseract.js": "^7.0.0", + "tinycolor2": "1.5.2", + "tmp": "0.2.1", + "typeorm": "0.3.17", + "uuid": "^14.0.0", + "web-push": "3.6.3", + "websocket": "1.0.34", + "xev": "3.0.2" + }, + "devDependencies": { + "@swc/cli": "^0.1.62", + "@swc/core": "^1.3.68", + "@types/adm-zip": "^0.5.0", + "@types/async-lock": "1.4.0", + "@types/escape-regexp": "0.0.1", + "@types/fluent-ffmpeg": "2.1.21", + "@types/js-yaml": "4.0.5", + "@types/jsonld": "1.5.9", + "@types/jsrsasign": "10.5.8", + "@types/koa": "2.13.6", + "@types/koa-bodyparser": "4.3.10", + "@types/koa-cors": "0.0.2", + "@types/koa-favicon": "2.0.21", + "@types/koa-logger": "3.1.2", + "@types/koa-mount": "4.0.2", + "@types/koa-send": "4.1.3", + "@types/koa-views": "7.0.0", + "@types/koa__cors": "3.3.0", + "@types/node": "^22", + "@types/node-fetch": "3.0.3", + "@types/nodemailer": "6.4.8", + "@types/oauth": "0.9.1", + "@types/pg": "^8.10.5", + "@types/probe-image-size": "^7.2.0", + "@types/pug": "2.0.6", + "@types/punycode": "2.1.0", + "@types/qrcode": "1.5.1", + "@types/random-seed": "0.3.3", + "@types/ratelimiter": "3.4.4", + "@types/redis": "4.0.11", + "@types/rename": "1.0.4", + "@types/sanitize-html": "2.9.0", + "@types/semver": "7.5.0", + "@types/tinycolor2": "1.4.3", + "@types/tmp": "0.2.3", + "@types/uuid": "^10.0.0", + "@types/web-push": "3.3.2", + "@types/websocket": "1.0.5", + "execa": "6.1.0", + "pug": "3.0.2", + "strict-event-emitter-types": "2.0.0", + "tsconfig-paths": "4.2.0", + "typescript": "^6.0.3" + } +} diff --git a/packages/backend/scripts/seed-karaoke-service-test-data.mjs b/packages/backend/scripts/seed-karaoke-service-test-data.mjs new file mode 100644 index 0000000..2fc3cd2 --- /dev/null +++ b/packages/backend/scripts/seed-karaoke-service-test-data.mjs @@ -0,0 +1,313 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import pg from "pg"; +import config from "../built/config/index.js"; + +const { Client } = pg; +const marker = "karaoke-service-rights-safe-test-data"; +const createdAt = new Date(); + +function genId(date = new Date()) { + const time2000 = 946684800000; + const timestamp = Math.max(date.getTime() - time2000, 0).toString(36).padStart(8, "0"); + const random = crypto.randomBytes(8).toString("base64url").replace(/[^a-z0-9]/gi, "").toLowerCase().slice(0, 8).padEnd(8, "0"); + return `${timestamp}${random}`; +} + +function md5(buffer) { + return crypto.createHash("md5").update(buffer).digest("hex"); +} + +function saveInternalFile(name, buffer) { + const accessKey = crypto.randomUUID(); + fs.mkdirSync(config.mediaDir, { recursive: true }); + fs.writeFileSync(path.join(config.mediaDir, accessKey), buffer); + return { + accessKey, + url: `${config.url}/files/${accessKey}`, + name, + size: buffer.byteLength, + md5: md5(buffer), + }; +} + +function createWav() { + const sampleRate = 44100; + const seconds = 16; + const samples = sampleRate * seconds; + const data = Buffer.alloc(samples * 2); + const notes = [261.63, 293.66, 329.63, 392.0, 349.23, 329.63, 293.66, 261.63]; + + for (let i = 0; i < samples; i++) { + const t = i / sampleRate; + const beat = Math.floor(t / 2) % notes.length; + const root = notes[beat] / 2; + const value = + Math.sin(2 * Math.PI * root * t) * 0.22 + + Math.sin(2 * Math.PI * root * 1.5 * t) * 0.12 + + Math.sin(2 * Math.PI * root * 2 * t) * 0.08; + data.writeInt16LE(Math.round(Math.max(-1, Math.min(1, value)) * 0x7fff), i * 2); + } + + const header = Buffer.alloc(44); + header.write("RIFF", 0); + header.writeUInt32LE(36 + data.length, 4); + header.write("WAVE", 8); + header.write("fmt ", 12); + header.writeUInt32LE(16, 16); + header.writeUInt16LE(1, 20); + header.writeUInt16LE(1, 22); + header.writeUInt32LE(sampleRate, 24); + header.writeUInt32LE(sampleRate * 2, 28); + header.writeUInt16LE(2, 32); + header.writeUInt16LE(16, 34); + header.write("data", 36); + header.writeUInt32LE(data.length, 40); + return Buffer.concat([header, data]); +} + +function varLen(value) { + let buffer = value & 0x7f; + while ((value >>= 7) > 0) { + buffer <<= 8; + buffer |= (value & 0x7f) | 0x80; + } + const bytes = []; + for (;;) { + bytes.push(buffer & 0xff); + if (buffer & 0x80) buffer >>= 8; + else break; + } + return Buffer.from(bytes); +} + +function createMidi() { + const ticksPerQuarter = 480; + const events = []; + const push = (...bytes) => events.push(Buffer.from(bytes)); + const pushDelta = (delta) => events.push(varLen(delta)); + + pushDelta(0); + push(0xff, 0x51, 0x03, 0x07, 0xa1, 0x20); // 120 BPM + pushDelta(0); + push(0xc0, 0x00); + + const melody = [60, 62, 64, 67, 65, 64, 62, 60]; + for (const note of melody) { + pushDelta(0); + push(0x90, note, 0x64); + pushDelta(ticksPerQuarter * 2); + push(0x80, note, 0x40); + } + + pushDelta(0); + push(0xff, 0x2f, 0x00); + + const track = Buffer.concat(events); + const header = Buffer.alloc(14); + header.write("MThd", 0); + header.writeUInt32BE(6, 4); + header.writeUInt16BE(0, 8); + header.writeUInt16BE(1, 10); + header.writeUInt16BE(ticksPerQuarter, 12); + + const trackHeader = Buffer.alloc(8); + trackHeader.write("MTrk", 0); + trackHeader.writeUInt32BE(track.length, 4); + return Buffer.concat([header, trackHeader, track]); +} + +function textBuffer(text) { + return Buffer.from(text.replace(/\n/g, "\r\n"), "utf8"); +} + +async function main() { + const client = new Client({ + host: config.db.host, + port: config.db.port, + user: config.db.user, + password: config.db.pass, + database: config.db.db, + ...config.db.extra, + }); + + await client.connect(); + try { + const admin = await client.query( + `SELECT "id", "username", "host" FROM "user" WHERE "usernameLower" = 'admin' AND "host" IS NULL LIMIT 1`, + ); + if (admin.rowCount === 0) { + throw new Error("Local @admin user was not found. Create @admin first, then rerun this script."); + } + const adminUser = admin.rows[0]; + + const existing = await client.query( + `SELECT "id", "fileIds" FROM "note" WHERE "userId" = $1 AND "text" ILIKE $2 LIMIT 1`, + [adminUser.id, `%${marker}%`], + ); + + const assets = [ + { + name: "karaoke-test-accompaniment.wav", + type: "audio/wav", + comment: "KaraokeService test accompaniment. Original generated tone progression.", + buffer: createWav(), + }, + { + name: "karaoke-test-lyrics.lrc", + type: "text/plain", + comment: "KaraokeService timed lyrics.", + buffer: textBuffer(`[00:00.000]Original karaoke test +[00:02.000]Sing the first clear tone +[00:04.000]Move to the next note +[00:06.000]Hold the bright third +[00:08.000]Reach the open fifth +[00:10.000]Return with steady timing +[00:12.000]Finish on the home note +[00:14.000]This data is rights-safe`), + }, + { + name: "karaoke-test-pitch.mid", + type: "audio/midi", + comment: "KaraokeService MIDI pitch and tempo data.", + buffer: createMidi(), + }, + { + name: "karaoke-test-score.json", + type: "application/json", + comment: "KaraokeService scoring metadata.", + buffer: textBuffer(JSON.stringify({ + version: 1, + scoring: "midi-pitch", + pitchFile: "karaoke-test-pitch.mid", + toleranceCents: 50, + partialToleranceCents: 100, + extraSingingPenalty: true, + }, null, 2)), + }, + { + name: "karaoke-test-song-info.txt", + type: "text/plain", + comment: "KaraokeService song metadata.", + buffer: textBuffer(`Title: Original Karaoke Test +Artist: FrozenFriendsYume test data +License: Public-domain equivalent test fixture generated for this repository +BPM: 120 +Key: C major +Notes: All melody, lyrics, accompaniment, and metadata were generated locally for testing.`), + }, + ]; + + const files = assets.map((asset) => ({ + id: genId(createdAt), + createdAt, + userId: adminUser.id, + userHost: null, + ...saveInternalFile(asset.name, asset.buffer), + type: asset.type, + comment: asset.comment, + })); + + await client.query("BEGIN"); + for (const file of files) { + await client.query( + `INSERT INTO "drive_file" ( + "id", "createdAt", "userId", "userHost", "md5", "name", "type", "size", "comment", + "blurhash", "properties", "storedInternal", "url", "thumbnailUrl", "webpublicUrl", + "webpublicType", "accessKey", "thumbnailAccessKey", "webpublicAccessKey", "uri", "src", + "folderId", "isSensitive", "allowDownload", "isLink", "requestHeaders", "requestIp" + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, + NULL, '{}', TRUE, $10, NULL, NULL, + NULL, $11, NULL, NULL, NULL, NULL, + NULL, FALSE, FALSE, FALSE, NULL, NULL + )`, + [ + file.id, + file.createdAt, + file.userId, + file.userHost, + file.md5, + file.name, + file.type, + file.size, + file.comment, + file.url, + file.accessKey, + ], + ); + } + + const noteText = `Original Karaoke Test +Rights-safe generated karaoke test data. +Marker: ${marker} +#KaraokeService`; + + if (existing.rowCount > 0) { + const noteId = existing.rows[0].id; + const oldFileIds = existing.rows[0].fileIds ?? []; + const oldFiles = oldFileIds.length > 0 + ? await client.query(`SELECT "id", "accessKey" FROM "drive_file" WHERE "id" = ANY($1)`, [oldFileIds]) + : { rows: [] }; + + await client.query( + `UPDATE "note" SET "text" = $2, "fileIds" = $3, "attachedFileTypes" = $4, "updatedAt" = $5 WHERE "id" = $1`, + [noteId, noteText, files.map((file) => file.id), files.map((file) => file.type), new Date()], + ); + if (oldFileIds.length > 0) { + await client.query(`DELETE FROM "drive_file" WHERE "id" = ANY($1)`, [oldFileIds]); + for (const file of oldFiles.rows) { + if (!file.accessKey) continue; + fs.rmSync(path.join(config.mediaDir, file.accessKey), { force: true }); + } + } + await client.query("COMMIT"); + console.log(`Recreated KaraokeService test media for existing @admin note: ${noteId}`); + return; + } + + const noteId = genId(createdAt); + await client.query( + `INSERT INTO "note" ( + "id", "createdAt", "replyId", "renoteId", "threadId", "text", "name", "cw", + "userId", "groupId", "localOnly", "renoteCount", "repliesCount", "viewCount", + "reactions", "visibility", "uri", "url", "score", "fileIds", "attachedFileTypes", + "visibleUserIds", "mentions", "mentionedRemoteUsers", "emojis", "tags", "hasPoll", + "channelId", "quoteAuthorization", "canQuote", "userHost", "replyUserId", + "replyUserHost", "renoteUserId", "renoteUserHost", "updatedAt" + ) VALUES ( + $1, $2, NULL, NULL, NULL, $3, NULL, NULL, + $4, NULL, FALSE, 0, 0, 0, + '{}', 'public', NULL, NULL, 0, $5, $6, + '{}', '{}', '[]', '{}', $7, FALSE, + NULL, NULL, TRUE, NULL, NULL, + NULL, NULL, NULL, NULL + )`, + [ + noteId, + createdAt, + noteText, + adminUser.id, + files.map((file) => file.id), + files.map((file) => file.type), + ["karaokeservice"], + ], + ); + + await client.query(`UPDATE "user" SET "notesCount" = "notesCount" + 1 WHERE "id" = $1`, [adminUser.id]); + await client.query("COMMIT"); + console.log(`Created KaraokeService test note as @admin: ${noteId}`); + } catch (err) { + await client.query("ROLLBACK").catch(() => undefined); + throw err; + } finally { + await client.end(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/packages/backend/src/@types/hcaptcha.d.ts b/packages/backend/src/@types/hcaptcha.d.ts new file mode 100644 index 0000000..21f65c6 --- /dev/null +++ b/packages/backend/src/@types/hcaptcha.d.ts @@ -0,0 +1,14 @@ +declare module "hcaptcha" { + interface IVerifyResponse { + success: boolean; + challenge_ts: string; + hostname: string; + credit?: boolean; + "error-codes"?: unknown[]; + } + + export function verify( + secret: string, + token: string, + ): Promise; +} diff --git a/packages/backend/src/@types/http-signature.d.ts b/packages/backend/src/@types/http-signature.d.ts new file mode 100644 index 0000000..3bfece8 --- /dev/null +++ b/packages/backend/src/@types/http-signature.d.ts @@ -0,0 +1,98 @@ +declare module "@peertube/http-signature" { + import type { IncomingMessage, ClientRequest } from "node:http"; + + interface ISignature { + keyId: string; + algorithm: string; + headers: string[]; + signature: string; + } + + interface IOptions { + headers?: string[]; + algorithm?: string; + strict?: boolean; + authorizationHeaderName?: string; + } + + interface IParseRequestOptions extends IOptions { + clockSkew?: number; + } + + interface IParsedSignature { + scheme: string; + params: ISignature; + signingString: string; + algorithm: string; + keyId: string; + } + + type RequestSignerConstructorOptions = + | IRequestSignerConstructorOptionsFromProperties + | IRequestSignerConstructorOptionsFromFunction; + + interface IRequestSignerConstructorOptionsFromProperties { + keyId: string; + key: string | Buffer; + algorithm?: string; + } + + interface IRequestSignerConstructorOptionsFromFunction { + sign?: (data: string, cb: (err: any, sig: ISignature) => void) => void; + } + + class RequestSigner { + constructor(options: RequestSignerConstructorOptions); + + public writeHeader(header: string, value: string): string; + + public writeDateHeader(): string; + + public writeTarget(method: string, path: string): void; + + public sign(cb: (err: any, authz: string) => void): void; + } + + interface ISignRequestOptions extends IOptions { + keyId: string; + key: string; + httpVersion?: string; + } + + export function parse( + request: IncomingMessage, + options?: IParseRequestOptions, + ): IParsedSignature; + export function parseRequest( + request: IncomingMessage, + options?: IParseRequestOptions, + ): IParsedSignature; + + export function sign( + request: ClientRequest, + options: ISignRequestOptions, + ): boolean; + export function signRequest( + request: ClientRequest, + options: ISignRequestOptions, + ): boolean; + export function createSigner(): RequestSigner; + export function isSigner(obj: any): obj is RequestSigner; + + export function sshKeyToPEM(key: string): string; + export function sshKeyFingerprint(key: string): string; + export function pemToRsaSSHKey(pem: string, comment: string): string; + + export function verify( + parsedSignature: IParsedSignature, + pubkey: string | Buffer, + ): boolean; + export function verifySignature( + parsedSignature: IParsedSignature, + pubkey: string | Buffer, + ): boolean; + export function verifyHMAC( + parsedSignature: IParsedSignature, + secret: string, + ): boolean; +} diff --git a/packages/backend/src/@types/koa-remove-trailing-slashes/index.d.ts b/packages/backend/src/@types/koa-remove-trailing-slashes/index.d.ts new file mode 100644 index 0000000..429d1d5 --- /dev/null +++ b/packages/backend/src/@types/koa-remove-trailing-slashes/index.d.ts @@ -0,0 +1 @@ +declare module "koa-remove-trailing-slashes"; diff --git a/packages/backend/src/@types/koa-slow.d.ts b/packages/backend/src/@types/koa-slow.d.ts new file mode 100644 index 0000000..e24be51 --- /dev/null +++ b/packages/backend/src/@types/koa-slow.d.ts @@ -0,0 +1,14 @@ +declare module "koa-slow" { + import type { Middleware } from "koa"; + + interface ISlowOptions { + url?: RegExp; + delay?: number; + } + + function slow(options?: ISlowOptions): Middleware; + + namespace slow {} // Hack + + export = slow; +} diff --git a/packages/backend/src/@types/os-utils.d.ts b/packages/backend/src/@types/os-utils.d.ts new file mode 100644 index 0000000..504096a --- /dev/null +++ b/packages/backend/src/@types/os-utils.d.ts @@ -0,0 +1,33 @@ +declare module "os-utils" { + type FreeCommandCallback = (usedmem: number) => void; + + type HarddriveCallback = (total: number, free: number, used: number) => void; + + type GetProcessesCallback = (result: string) => void; + + type CPUCallback = (perc: number) => void; + + export function platform(): NodeJS.Platform; + export function cpuCount(): number; + export function sysUptime(): number; + export function processUptime(): number; + + export function freemem(): number; + export function totalmem(): number; + export function freememPercentage(): number; + export function freeCommand(callback: FreeCommandCallback): void; + + export function harddrive(callback: HarddriveCallback): void; + + export function getProcesses(callback: GetProcessesCallback): void; + export function getProcesses( + nProcess: number, + callback: GetProcessesCallback, + ): void; + + export function allLoadavg(): string; + export function loadavg(_time?: number): number; + + export function cpuFree(callback: CPUCallback): void; + export function cpuUsage(callback: CPUCallback): void; +} diff --git a/packages/backend/src/@types/package.json.d.ts b/packages/backend/src/@types/package.json.d.ts new file mode 100644 index 0000000..d8ec636 --- /dev/null +++ b/packages/backend/src/@types/package.json.d.ts @@ -0,0 +1,10 @@ +declare module "*/package.json" { + interface IRepository { + type: string; + url: string; + } + + export const name: string; + export const version: string; + export const repository: IRepository; +} diff --git a/packages/backend/src/boot/index.ts b/packages/backend/src/boot/index.ts new file mode 100644 index 0000000..f7060df --- /dev/null +++ b/packages/backend/src/boot/index.ts @@ -0,0 +1,94 @@ +import cluster from "node:cluster"; +import chalk from "chalk"; +import Xev from "xev"; + +import Logger from "@/services/logger.js"; +import { envOption } from "../env.js"; + +// for typeorm +import "reflect-metadata"; +import { masterMain } from "./master.js"; +import { workerMain } from "./worker.js"; +import os from "node:os"; +import { isIgnorableConnectionError } from "@/server/is-ignorable-connection-error.js"; + +const logger = new Logger("core", "cyan"); +const clusterLogger = logger.createSubLogger("cluster", "orange", false); +const ev = new Xev(); + +/** + * Init process + */ +export default async function () { + process.title = `FrozenFriendsYume (${cluster.isPrimary ? "master" : "worker"})`; + + if (cluster.isPrimary || envOption.disableClustering) { + await masterMain(); + if (cluster.isPrimary) { + ev.mount(); + } + } + + if (cluster.isWorker || envOption.disableClustering) { + await workerMain(); + } + + if (cluster.isPrimary) { + // Leave the master process with a marginally lower priority but not too low. + os.setPriority(2); + } + if (cluster.isWorker) { + // Set workers to a much lower priority so that the master process will be + // able to respond to api calls even if the workers gank everything. + os.setPriority(10); + } + + // For when FrozenFriendsYume is started in a child process during unit testing. + // Otherwise, process.send cannot be used, so start it. + if (process.send) { + process.send("ok"); + } +} + +//#region Events + +// Listen new workers +cluster.on("fork", (worker) => { + clusterLogger.debug(`Process forked: [${worker.id}]`); +}); + +// Listen online workers +cluster.on("online", (worker) => { + clusterLogger.debug(`Process is now online: [${worker.id}]`); +}); + +// Listen for dying workers +cluster.on("exit", (worker) => { + // Replace the dead worker, + // we're not sentimental + clusterLogger.error(chalk.red(`[${worker.id}] died :(`)); + cluster.fork(); +}); + +// Display detail of unhandled promise rejection +if (!envOption.quiet) { + process.on("unhandledRejection", (err) => { + if (isIgnorableConnectionError(err)) return; + console.dir(err); + }); +} + +// Display detail of uncaught exception +process.on("uncaughtException", (err) => { + if (isIgnorableConnectionError(err)) return; + try { + logger.error(err); + } catch {} +}); + +// Dying away... +process.on("exit", (code) => { + logger.info(`The process is going to exit with code ${code}`); +}); + +//#endregion diff --git a/packages/backend/src/boot/master.ts b/packages/backend/src/boot/master.ts new file mode 100644 index 0000000..e07f0af --- /dev/null +++ b/packages/backend/src/boot/master.ts @@ -0,0 +1,197 @@ +import * as fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; +import * as os from "node:os"; +import cluster from "node:cluster"; +import net from "node:net"; +import repl from "node:repl"; +import chalk from "chalk"; +import chalkTemplate from "chalk-template"; +import semver from "semver"; + +import Logger from "@/services/logger.js"; +import loadConfig from "@/config/load.js"; +import type { Config } from "@/config/types.js"; +import { lessThan } from "@/prelude/array.js"; +import { envOption } from "../env.js"; +import { showMachineInfo } from "@/misc/show-machine-info.js"; +import { db, initDb } from "../db/postgre.js"; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +const meta = JSON.parse( + fs.readFileSync(`${_dirname}/../../../../built/meta.json`, "utf-8"), +); + +const logger = new Logger("core", "cyan"); +const bootLogger = logger.createSubLogger("boot", "magenta", false); + +const themeColor = chalk.hex("#31748f"); + +function greet() { + if (!envOption.quiet) { + console.log( + chalkTemplate`--- ${os.hostname()} {gray (PID: ${process.pid.toString()})} ---`, + ); + } + + const displayVersion = meta.version.startsWith("v") ? meta.version : `v${meta.version}`; + bootLogger.info(`FrozenFriendsYume ${displayVersion}, initializing...`, null, true); +} + +/** + * Init master process + */ +export async function masterMain() { + let config!: Config; + + // initialize app + try { + greet(); + showEnvironment(); + await showMachineInfo(bootLogger); + showNodejsVersion(); + config = loadConfigBoot(); + await connectDb(); + if (config.shellAddress !== undefined) + spawnShell(config.shellAddress); + } catch (e) { + bootLogger.error("Fatal error occurred during initialization", null, true); + bootLogger.error(e); + process.exit(1); + } + + bootLogger.succ("FrozenFriendsYume initialized"); + + if (!envOption.disableClustering) { + await spawnWorkers(config.clusterLimit); + } + + bootLogger.succ( + `Now listening on port ${config.port} on ${config.url} - using ${config.domain}`, + null, + true, + ); + + if (!envOption.noDaemons && !config.onlyQueueProcessor) { + import("../daemons/server-stats.js").then((x) => x.default()); + import("../daemons/queue-stats.js").then((x) => x.default()); + import("../daemons/janitor.js").then((x) => x.default()); + } +} + +function showEnvironment(): void { + const env = process.env.NODE_ENV; + const logger = bootLogger.createSubLogger("env"); + logger.info( + typeof env === "undefined" ? "NODE_ENV is not set" : `NODE_ENV: ${env}`, + ); + + if (env !== "production") { + logger.warn("The environment is not in production mode."); + logger.warn("DO NOT USE FOR PRODUCTION PURPOSE!", null, true); + } +} + +function showNodejsVersion(): void { + const nodejsLogger = bootLogger.createSubLogger("nodejs"); + + nodejsLogger.info(`Version ${process.version} detected.`); + + const minVersion = fs + .readFileSync(`${_dirname}/../../../../.node-version`, "utf-8") + .trim(); + if (semver.lt(process.version, minVersion)) { + nodejsLogger.error(`At least Node.js ${minVersion} required!`); + process.exit(1); + } +} + +function loadConfigBoot(): Config { + const configLogger = bootLogger.createSubLogger("config"); + let config; + + try { + config = loadConfig(); + } catch (exception) { + if (exception.code === "ENOENT") { + configLogger.error("Configuration file not found", null, true); + process.exit(1); + } else if (e instanceof Error) { + configLogger.error(e.message); + process.exit(1); + } + throw exception; + } + + configLogger.succ("Loaded"); + + return config; +} + +async function connectDb(): Promise { + const dbLogger = bootLogger.createSubLogger("db"); + + // Try to connect to DB + try { + dbLogger.info("Connecting..."); + await initDb(); + const v = await db + .query("SHOW server_version") + .then((x) => x[0].server_version); + dbLogger.succ(`Connected: v${v}`); + } catch (e) { + dbLogger.error("Cannot connect", null, true); + dbLogger.error(e); + process.exit(1); + } +} + +async function spawnWorkers(limit = 1) { + const workers = Math.min(limit, os.cpus().length); + bootLogger.info(`Starting ${workers} worker${workers === 1 ? "" : "s"}...`); + await Promise.all([...Array(workers)].map(spawnWorker)); + bootLogger.succ("All workers started"); +} + +function spawnWorker(): Promise { + return new Promise((res) => { + const worker = cluster.fork(); + worker.on("message", (message) => { + if (message === "listenFailed") { + bootLogger.error("The server Listen failed due to the previous error."); + process.exit(1); + } + if (message !== "ready") return; + res(); + }); + }); +} + +function spawnShell(address: number | string) { + logger.info(`Spawning debug shell on ${address}`); + if (typeof address == "string") { + try { + fs.unlinkSync(address); + } catch {} + } + net + .createServer() + .unref() + .listen(address) + .on("connection", (socket) => { + const r = repl.start({ input: socket, output: socket }); + socket.on("close", () => r.close()); + socket.on("error", () => r.close()); + r.on("close", () => socket.destroy()); + if (!envOption.disableClustering) { + r.defineCommand("worker", (iid) => { + r.close(); + const id = parseInt(iid.trim()); + cluster.workers[id].send("debug-shell", socket); + }); + } + }) + .on("error", (error) => logger.error(error)); +} diff --git a/packages/backend/src/boot/worker.ts b/packages/backend/src/boot/worker.ts new file mode 100644 index 0000000..5654b08 --- /dev/null +++ b/packages/backend/src/boot/worker.ts @@ -0,0 +1,37 @@ +import net from "node:net"; +import repl from "node:repl"; +import cluster from "node:cluster"; +import { initDb } from "../db/postgre.js"; +import config from "@/config/index.js"; + +/** + * Init worker process + */ +export async function workerMain() { + await initDb(); + + if (!config.onlyQueueProcessor) { + // start server + await import("../server/index.js").then((x) => x.default()); + } + + // start job queue + import("../queue/index.js").then((x) => x.default()); + + if (cluster.isWorker) { + // Send a 'ready' message to parent process + process.send!("ready"); + cluster.worker.on("message", (message, handle) => { + if (message == "debug-shell") spawnWorkerShell(handle); + }); + } +} + +function spawnWorkerShell(socket: net.Socket) { + const r = repl.start({ input: socket, output: socket }); + r.defineCommand("worker", (iid) => { + r.close(); + const id = parseInt(iid.trim()); + cluster.workers[id].send("debug-shell", socket); + }); +} diff --git a/packages/backend/src/config/index.ts b/packages/backend/src/config/index.ts new file mode 100644 index 0000000..ae197b0 --- /dev/null +++ b/packages/backend/src/config/index.ts @@ -0,0 +1,3 @@ +import load from "./load.js"; + +export default load(); diff --git a/packages/backend/src/config/load.ts b/packages/backend/src/config/load.ts new file mode 100644 index 0000000..86c326f --- /dev/null +++ b/packages/backend/src/config/load.ts @@ -0,0 +1,120 @@ +/** + * Config loader + */ + +import * as fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; +import * as yaml from "js-yaml"; +import type { Source, Mixin } from "./types.js"; +import path from "node:path"; +import parseDuration from 'parse-duration' + +export default function load() { + const _filename = fileURLToPath(import.meta.url); + const _dirname = dirname(_filename); + + const dir = `${_dirname}/../../../..`; + const { + ICESHRIMP_CONFIG: configFile, + ICESHRIMP_SECRETS: secretsFile, + ICESHRIMP_MEDIA_DIR: mediaDir, + } = process.env; + + const configPath = + process.env.NODE_ENV === "test" + ? `${dir}/.config/test.yml` + : configFile ?? `${dir}/.config/default.yml`; + + const meta = JSON.parse( + fs.readFileSync(`${_dirname}/../../../../built/meta.json`, "utf-8"), + ); + const clientManifest = JSON.parse( + fs.readFileSync( + `${_dirname}/../../../../built/_client_dist_/manifest.json`, + "utf-8", + ), + ); + let config = yaml.load(fs.readFileSync(configPath, "utf-8")) as Source; + if (secretsFile !== undefined) + config = Object.assign(config, yaml.load(fs.readFileSync(secretsFile, "utf-8")) as Source); + + const mixin = {} as Mixin; + + const url = tryCreateUrl(config.url); + + config.url = url.origin; + + config.port = config.port || parseInt(process.env.PORT || "", 10); + config.listen = process.env.ICESHRIMP_LISTEN ?? config.listen ?? "0.0.0.0"; + + if (config.tls != null) { + config.tls = { + keyPath: + typeof config.tls.keyPath === "string" + ? path.resolve(dir, config.tls.keyPath) + : undefined, + certPath: + typeof config.tls.certPath === "string" + ? path.resolve(dir, config.tls.certPath) + : undefined, + }; + } + + config.images = { + info: '/twemoji/1f440.svg', + notFound: '/twemoji/2049.svg', + error: '/twemoji/1f480.svg', + ...config.images, + }; + + config.htmlCache = { + ttlSeconds: parseDuration(config.htmlCache?.ttl ?? '1h', 's')!, + prewarm: false, + dbFallback: false, + ...config.htmlCache, + } + + if (config.htmlCache.ttlSeconds == null) throw new Error('Failed to parse config.htmlCache.ttl'); + + config.wordMuteCache = { + ttlSeconds: parseDuration(config.wordMuteCache?.ttl ?? '24h', 's')!, + } + + if (config.wordMuteCache.ttlSeconds == null) throw new Error('Failed to parse config.wordMuteCache.ttl'); + + config.searchEngine = config.searchEngine ?? 'https://duckduckgo.com/?q='; + + config.metrics = config.metrics ?? {}; + config.metrics.enable = config.metrics?.enable ?? false; + + if (typeof config.shellAddress === "string") { + config.shellAddress = path.isAbsolute(config.shellAddress) ? config.shellAddress : path.resolve(`../../${config.shellAddress}`); + } + + mixin.version = meta.version; + mixin.host = url.host; + mixin.hostname = url.hostname; + mixin.domain = config.accountDomain ?? url.host; + mixin.scheme = url.protocol.replace(/:$/, ""); + mixin.wsScheme = mixin.scheme.replace("http", "ws"); + mixin.wsUrl = `${mixin.wsScheme}://${mixin.host}`; + mixin.apiUrl = `${mixin.scheme}://${mixin.host}/api`; + mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`; + mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`; + mixin.userAgent = `FrozenFriendsYume/${meta.version} (${config.url})`; + mixin.clientEntry = clientManifest["src/init.ts"]; + mixin.mediaDir = mediaDir ?? `${dir}/files`; + + if (!config.redis.prefix) config.redis.prefix = mixin.hostname; + + return Object.assign(config, mixin); +} + +function tryCreateUrl(url: string) { + try { + return new URL(url); + } catch (e) { + throw new Error(`url="${url}" is not a valid URL.`); + } +} diff --git a/packages/backend/src/config/types.ts b/packages/backend/src/config/types.ts new file mode 100644 index 0000000..732df6f --- /dev/null +++ b/packages/backend/src/config/types.ts @@ -0,0 +1,185 @@ +/** + * ユーザーが設定する必要のある情報 + */ + +export type TypeORMLoggingOptions = + | "error" + | "slow" + | "query" + | "schema" + | "info" + | "log"; + +export type TypeORMLoggingConfig = TypeORMLoggingOptions | TypeORMLoggingOptions[] | "all"; + +export type Source = { + repository_url?: string; + feedback_url?: string; + url: string; + accountDomain?: string; + port: number; + listen?: string; + disableHsts?: boolean; + tls?: { + keyPath?: string; + certPath?: string; + }; + db: { + logging?: TypeORMLoggingConfig; + host: string; + port: number; + db: string; + user: string; + pass: string; + disableCache?: boolean; + extra?: { [x: string]: string }; + }; + redis: { + host: string; + port: number; + family?: number; + pass?: string; + db?: number; + prefix?: string; + user?: string; + tls?: { [y: string]: string }; + }; + + mediaCleanup?: { + cron?: boolean; + maxAgeDays?: number; + keepAvatars?: boolean; + keepHeaders?: boolean; + }; + + images?: { + error?: string; + notFound?: string; + info?: string; + }; + + htmlCache?: { + ttl?: string; + ttlSeconds?: number; + prewarm?: boolean; + dbFallback?: boolean; + } + + wordMuteCache?: { + ttl?: string; + ttlSeconds?: number; + } + + searchEngine?: string; + + proxy?: string; + proxySmtp?: string; + proxyBypassHosts?: string[]; + + allowedPrivateNetworks?: string[]; + + maxFileSize?: number; + + accesslog?: string; + + clusterLimit?: number; + + onlyQueueProcessor?: boolean; + + cuid?: { + length?: number; + fingerprint?: string; + }; + + outgoingAddressFamily?: "ipv4" | "ipv6" | "dual"; + + deliverJobConcurrency?: number; + inboxJobConcurrency?: number; + deliverJobPerSec?: number; + inboxJobPerSec?: number; + deliverJobMaxAttempts?: number; + inboxJobMaxAttempts?: number; + + syslog: { + host: string; + port: number; + }; + + mediaProxy?: string; + proxyRemoteFiles?: boolean; + + twa: { + nameSpace?: string; + packageName?: string; + sha256CertFingerprints?: string[]; + }; + + reservedUsernames?: string[]; + + shellAddress?: number | string; + + // Managed hosting stuff + maxUserSignups?: number; + isManagedHosting?: boolean; + maxNoteLength?: number; + maxCaptionLength?: number; + deepl: { + managed?: boolean; + authKey?: string; + isPro?: boolean; + }; + libreTranslate: { + managed?: boolean; + apiUrl?: string; + apiKey?: string; + }; + email: { + managed?: boolean; + address?: string; + host?: string; + port?: number; + user?: string; + pass?: string; + useImplicitSslTls?: boolean; + }; + objectStorage: { + managed?: boolean; + baseUrl?: string; + bucket?: string; + prefix?: string; + endpoint?: string; + region?: string; + accessKey?: string; + secretKey?: string; + useSsl?: boolean; + connnectOverProxy?: boolean; + setPublicReadOnUpload?: boolean; + s3ForcePathStyle?: boolean; + }; + summalyProxyUrl?: string; + metrics?: { + enable?: boolean; + token?: string; + }; +}; + +/** + * Misskeyが自動的に(ユーザーが設定した情報から推論して)設定する情報 + */ +export type Mixin = { + version: string; + host: string; + hostname: string; + domain: string; + scheme: string; + wsScheme: string; + apiUrl: string; + wsUrl: string; + authUrl: string; + driveUrl: string; + userAgent: string; + clientEntry: string; + mediaDir: string; +}; + +export type Config = Source & Mixin; diff --git a/packages/backend/src/const.ts b/packages/backend/src/const.ts new file mode 100644 index 0000000..a19a303 --- /dev/null +++ b/packages/backend/src/const.ts @@ -0,0 +1,79 @@ +import config from "@/config/index.js"; +import { + DB_MAX_NOTE_TEXT_LENGTH, + DB_MAX_IMAGE_COMMENT_LENGTH, +} from "@/misc/hard-limits.js"; + +export const MAX_NOTE_TEXT_LENGTH = Math.min( + config.maxNoteLength ?? 3000, + DB_MAX_NOTE_TEXT_LENGTH, +); +export const MAX_CAPTION_TEXT_LENGTH = Math.min( + config.maxCaptionLength ?? 1500, + DB_MAX_IMAGE_COMMENT_LENGTH, +); + +export const SECOND = 1000; +export const SEC = 1000; // why do we need this duplicate here? +export const MINUTE = 60 * SEC; +export const MIN = 60 * SEC; // why do we need this duplicate here? +export const HOUR = 60 * MIN; +export const DAY = 24 * HOUR; + +export const USER_ONLINE_THRESHOLD = 10 * MINUTE; +export const USER_ACTIVE_THRESHOLD = 3 * DAY; + +// List of file types allowed to be viewed directly in the browser +// Anything not included here will be responded as application/octet-stream +// SVG is not allowed because it generates XSS <- we need to fix this and later allow it to be viewed directly +export const FILE_TYPE_BROWSERSAFE = [ + // Images + "image/png", + "image/gif", // TODO: deprecated, but still used by old notes, new gifs should be converted to webp in the future + "image/jpeg", + "image/webp", // TODO: make this the default image format + "image/apng", + "image/bmp", + "image/tiff", + "image/x-icon", + "image/avif", // not as good supported now, but its good to introduce initial support for the future + + // OggS + "audio/opus", + "video/ogg", + "audio/ogg", + "application/ogg", + + // ISO/IEC base media file format + "video/quicktime", + "video/mp4", // TODO: we need to check for av1 later + "video/vnd.avi", // also av1 + "audio/mp4", + "video/x-m4v", + "audio/x-m4a", + "video/3gpp", + "video/3gpp2", + "video/3gp2", + "audio/3gpp", + "audio/3gpp2", + "audio/3gp2", + + "video/mpeg", + "audio/mpeg", + + "video/webm", + "audio/webm", + + "audio/aac", + "audio/x-flac", + "audio/flac", + "audio/vnd.wave", + + // Documents + "application/epub+zip", +]; +/* +https://github.com/sindresorhus/file-type/blob/main/supported.js +https://github.com/sindresorhus/file-type/blob/main/core.js +https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers +*/ diff --git a/packages/backend/src/daemons/janitor.ts b/packages/backend/src/daemons/janitor.ts new file mode 100644 index 0000000..2050d54 --- /dev/null +++ b/packages/backend/src/daemons/janitor.ts @@ -0,0 +1,20 @@ +// TODO: 消したい + +const interval = 30 * 60 * 1000; +import { AttestationChallenges } from "@/models/index.js"; +import { LessThan } from "typeorm"; + +/** + * Clean up database occasionally + */ +export default function () { + async function tick() { + await AttestationChallenges.delete({ + createdAt: LessThan(new Date(new Date().getTime() - 5 * 60 * 1000)), + }); + } + + tick(); + + setInterval(tick, interval); +} diff --git a/packages/backend/src/daemons/queue-stats.ts b/packages/backend/src/daemons/queue-stats.ts new file mode 100644 index 0000000..381b52a --- /dev/null +++ b/packages/backend/src/daemons/queue-stats.ts @@ -0,0 +1,60 @@ +import Xev from "xev"; +import { deliverQueue, inboxQueue } from "../queue/queues.js"; + +const ev = new Xev(); + +const interval = 10000; + +/** + * Report queue stats regularly + */ +export default function () { + const log = [] as any[]; + + ev.on("requestQueueStatsLog", (x) => { + ev.emit(`queueStatsLog:${x.id}`, log.slice(0, x.length || 50)); + }); + + let activeDeliverJobs = 0; + let activeInboxJobs = 0; + + deliverQueue.on("global:active", () => { + activeDeliverJobs++; + }); + + inboxQueue.on("global:active", () => { + activeInboxJobs++; + }); + + async function tick() { + const deliverJobCounts = await deliverQueue.getJobCounts(); + const inboxJobCounts = await inboxQueue.getJobCounts(); + + const stats = { + deliver: { + activeSincePrevTick: activeDeliverJobs, + active: deliverJobCounts.active, + waiting: deliverJobCounts.waiting, + delayed: deliverJobCounts.delayed, + }, + inbox: { + activeSincePrevTick: activeInboxJobs, + active: inboxJobCounts.active, + waiting: inboxJobCounts.waiting, + delayed: inboxJobCounts.delayed, + }, + }; + + ev.emit("queueStats", stats); + + log.unshift(stats); + if (log.length > 200) log.pop(); + + activeDeliverJobs = 0; + activeInboxJobs = 0; + } + + tick(); + + setInterval(tick, interval); +} diff --git a/packages/backend/src/daemons/server-stats.ts b/packages/backend/src/daemons/server-stats.ts new file mode 100644 index 0000000..6285321 --- /dev/null +++ b/packages/backend/src/daemons/server-stats.ts @@ -0,0 +1,85 @@ +import si from "systeminformation"; +import Xev from "xev"; +import * as osUtils from "os-utils"; +import { fetchMeta } from "@/misc/fetch-meta.js"; + +const ev = new Xev(); + +const interval = 2000; + +const roundCpu = (num: number) => Math.round(num * 1000) / 1000; +const round = (num: number) => Math.round(num * 10) / 10; + +/** + * Report server stats regularly + */ +export default function () { + const log = [] as any[]; + + ev.on("requestServerStatsLog", (x) => { + ev.emit(`serverStatsLog:${x.id}`, log.slice(0, x.length || 50)); + }); + + fetchMeta().then((meta) => { + if (!meta.enableServerMachineStats) return; + }); + + async function tick() { + const cpu = await cpuUsage(); + const memStats = await mem(); + const netStats = await net(); + const fsStats = await fs(); + + const stats = { + cpu: roundCpu(cpu), + mem: { + used: round(memStats.used - memStats.buffers - memStats.cached), + active: round(memStats.active), + total: round(memStats.total), + }, + net: { + rx: round(Math.max(0, netStats.rx_sec)), + tx: round(Math.max(0, netStats.tx_sec)), + }, + fs: { + r: round(Math.max(0, fsStats.rIO_sec ?? 0)), + w: round(Math.max(0, fsStats.wIO_sec ?? 0)), + } + }; + ev.emit("serverStats", stats); + log.unshift(stats); + if (log.length > 200) log.pop(); + } + + tick(); + + setInterval(tick, interval); +} + +// CPU STAT +function cpuUsage(): Promise { + return new Promise((res, rej) => { + osUtils.cpuUsage((cpuUsage) => { + res(cpuUsage); + }); + }); +} + +// MEMORY STAT +async function mem() { + const data = await si.mem(); + return data; +} + +// NETWORK STAT +async function net() { + const iface = await si.networkInterfaceDefault(); + const data = await si.networkStats(iface); + return data[0]; +} + +// FS STAT +async function fs() { + const data = await si.disksIO().catch(() => ({ rIO_sec: 0, wIO_sec: 0 })); + return data || { rIO_sec: 0, wIO_sec: 0 }; +} diff --git a/packages/backend/src/db/logger.ts b/packages/backend/src/db/logger.ts new file mode 100644 index 0000000..28ec65d --- /dev/null +++ b/packages/backend/src/db/logger.ts @@ -0,0 +1,3 @@ +import Logger from "@/services/logger.js"; + +export const dbLogger = new Logger("db"); diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts new file mode 100644 index 0000000..ba91e3c --- /dev/null +++ b/packages/backend/src/db/postgre.ts @@ -0,0 +1,308 @@ +// https://github.com/typeorm/typeorm/issues/2400 +import pg from "pg"; +pg.types.setTypeParser(20, Number); + +import type { Logger } from "typeorm"; +import { DataSource } from "typeorm"; +import * as highlight from "cli-highlight"; +import config from "@/config/index.js"; + +import { User } from "@/models/entities/user.js"; +import { DriveFile } from "@/models/entities/drive-file.js"; +import { DriveFolder } from "@/models/entities/drive-folder.js"; +import { AccessToken } from "@/models/entities/access-token.js"; +import { App } from "@/models/entities/app.js"; +import { PollVote } from "@/models/entities/poll-vote.js"; +import { Note } from "@/models/entities/note.js"; +import { NoteReaction } from "@/models/entities/note-reaction.js"; +import { NoteWatching } from "@/models/entities/note-watching.js"; +import { NoteThreadMuting } from "@/models/entities/note-thread-muting.js"; +import { NoteUnread } from "@/models/entities/note-unread.js"; +import { Notification } from "@/models/entities/notification.js"; +import { Meta } from "@/models/entities/meta.js"; +import { Following } from "@/models/entities/following.js"; +import { Instance } from "@/models/entities/instance.js"; +import { Muting } from "@/models/entities/muting.js"; +import { RenoteMuting } from "@/models/entities/renote-muting.js"; +import { SwSubscription } from "@/models/entities/sw-subscription.js"; +import { Blocking } from "@/models/entities/blocking.js"; +import { CallBlocking } from "@/models/entities/call-blocking.js"; +import { UserList } from "@/models/entities/user-list.js"; +import { UserListJoining } from "@/models/entities/user-list-joining.js"; +import { UserGroup } from "@/models/entities/user-group.js"; +import { UserGroupJoining } from "@/models/entities/user-group-joining.js"; +import { UserGroupInvitation } from "@/models/entities/user-group-invitation.js"; +import { Hashtag } from "@/models/entities/hashtag.js"; +import { NoteFavorite } from "@/models/entities/note-favorite.js"; +import { AbuseUserReport } from "@/models/entities/abuse-user-report.js"; +import { RegistrationTicket } from "@/models/entities/registration-tickets.js"; +import { MessagingMessage } from "@/models/entities/messaging-message.js"; +import { Signin } from "@/models/entities/signin.js"; +import { AuthSession } from "@/models/entities/auth-session.js"; +import { FollowRequest } from "@/models/entities/follow-request.js"; +import { Emoji } from "@/models/entities/emoji.js"; +import { UserNotePining } from "@/models/entities/user-note-pining.js"; +import { Poll } from "@/models/entities/poll.js"; +import { UserKeypair } from "@/models/entities/user-keypair.js"; +import { UserPublickey } from "@/models/entities/user-publickey.js"; +import { UserProfile } from "@/models/entities/user-profile.js"; +import { UserSecurityKey } from "@/models/entities/user-security-key.js"; +import { AttestationChallenge } from "@/models/entities/attestation-challenge.js"; +import { Page } from "@/models/entities/page.js"; +import { PageLike } from "@/models/entities/page-like.js"; +import { GalleryPost } from "@/models/entities/gallery-post.js"; +import { GalleryLike } from "@/models/entities/gallery-like.js"; +import { ModerationLog } from "@/models/entities/moderation-log.js"; +import { UsedUsername } from "@/models/entities/used-username.js"; +import { Announcement } from "@/models/entities/announcement.js"; +import { AnnouncementRead } from "@/models/entities/announcement-read.js"; +import { Clip } from "@/models/entities/clip.js"; +import { ClipNote } from "@/models/entities/clip-note.js"; +import { Antenna } from "@/models/entities/antenna.js"; +import { PromoNote } from "@/models/entities/promo-note.js"; +import { PromoRead } from "@/models/entities/promo-read.js"; +import { Relay } from "@/models/entities/relay.js"; +import { Channel } from "@/models/entities/channel.js"; +import { ChannelFollowing } from "@/models/entities/channel-following.js"; +import { ChannelNotePining } from "@/models/entities/channel-note-pining.js"; +import { RegistryItem } from "@/models/entities/registry-item.js"; +import { PasswordResetRequest } from "@/models/entities/password-reset-request.js"; +import { UserPending } from "@/models/entities/user-pending.js"; +import { Webhook } from "@/models/entities/webhook.js"; +import { UserIp } from "@/models/entities/user-ip.js"; +import { UserEmoji } from "@/models/entities/user-emoji.js"; +import { NoteEdit } from "@/models/entities/note-edit.js"; +import { entities as charts } from "@/services/chart/entities.js"; +import { dbLogger } from "./logger.js"; +import { OAuthApp } from "@/models/entities/oauth-app.js"; +import { OAuthToken } from "@/models/entities/oauth-token.js"; +import { HtmlNoteCacheEntry } from "@/models/entities/html-note-cache-entry.js"; +import { HtmlUserCacheEntry } from "@/models/entities/html-user-cache-entry.js"; +import { TypeORMLoggingOptions } from "@/config/types.js"; +import { Bite } from "@/models/entities/bite.js"; +import { InteractionStamp } from "@/models/entities/interaction-stamp.js"; +import { ReversiGame } from "@/models/entities/reversi-game.js"; +import { ReversiMatching } from "@/models/entities/reversi-matching.js"; +import { ShogiGame } from "@/models/entities/shogi-game.js"; +import { ScheduledNote } from "@/models/entities/scheduled-note.js"; +import { Memoriet } from "@/models/entities/memoriet.js"; +import { MemorietArchive } from "@/models/entities/memoriet-archive.js"; +import { MemorietView } from "@/models/entities/memoriet-view.js"; +import { VerifiedBadgeRequest } from "@/models/entities/verified-badge-request.js"; +import { Plan } from "@/models/entities/plan.js"; +import { UserPlan } from "@/models/entities/user-plan.js"; + +const sqlLogger = dbLogger.createSubLogger("sql", "gray", false); +const isLogEnabled = (level: TypeORMLoggingOptions): boolean => { + const logLevel = config.db.logging; + return Array.isArray(logLevel) + ? logLevel.includes(level) + : logLevel === level || logLevel?.trim()?.toLowerCase() === "all"; +}; +const isLoggingEnabled = () => { + const logLevel = config.db.logging; + return Array.isArray(logLevel) + ? logLevel.length > 0 + : logLevel != null; +} + +class MyCustomLogger implements Logger { + private highlight(sql: string) { + return highlight.highlight(sql, { + language: "sql", + ignoreIllegals: true, + }); + } + + public logQuery(query: string, parameters?: any[]) { + if (isLogEnabled("query")) + sqlLogger.info(this.highlight(query).substring(0, 100)); + } + + public logQueryError(error: string, query: string, parameters?: any[]) { + if (isLogEnabled("error")) sqlLogger.error(this.highlight(query)); + } + + public logQuerySlow(time: number, query: string, parameters?: any[]) { + if (isLogEnabled("slow")) sqlLogger.warn(this.highlight(query)); + } + + public logSchemaBuild(message: string) { + if (isLogEnabled("schema")) sqlLogger.info(message); + } + + public log(message: string) { + if (isLogEnabled("log")) sqlLogger.info(message); + } + + public logMigration(message: string) { + if (isLogEnabled("info")) sqlLogger.info(message); + } +} + +export const entities = [ + Announcement, + AnnouncementRead, + Meta, + Instance, + App, + AuthSession, + AccessToken, + User, + UserProfile, + UserKeypair, + UserPublickey, + UserList, + UserListJoining, + UserGroup, + UserGroupJoining, + UserGroupInvitation, + UserNotePining, + UserSecurityKey, + UsedUsername, + AttestationChallenge, + Following, + FollowRequest, + Muting, + RenoteMuting, + Blocking, + CallBlocking, + Note, + NoteEdit, + NoteFavorite, + NoteReaction, + NoteWatching, + NoteThreadMuting, + NoteUnread, + Page, + PageLike, + GalleryPost, + GalleryLike, + DriveFile, + DriveFolder, + Poll, + PollVote, + Notification, + Emoji, + Hashtag, + SwSubscription, + AbuseUserReport, + RegistrationTicket, + MessagingMessage, + Signin, + ModerationLog, + Clip, + ClipNote, + Antenna, + PromoNote, + PromoRead, + Relay, + Channel, + ChannelFollowing, + ChannelNotePining, + RegistryItem, + PasswordResetRequest, + UserPending, + Webhook, + UserIp, + UserEmoji, + OAuthApp, + OAuthToken, + HtmlNoteCacheEntry, + HtmlUserCacheEntry, + Bite, + InteractionStamp, + ReversiGame, + ReversiMatching, + ShogiGame, + ScheduledNote, + Memoriet, + MemorietArchive, + MemorietView, + VerifiedBadgeRequest, + Plan, + UserPlan, + ...charts, +]; + +const log = isLoggingEnabled() || process.env.LOG_SQL === "true"; + +export const db = new DataSource({ + type: "postgres", + host: config.db.host, + port: config.db.port, + username: config.db.user, + password: config.db.pass, + database: config.db.db, + extra: { + statement_timeout: 1000 * 10, + ...config.db.extra, + }, + synchronize: process.env.NODE_ENV === "test", + dropSchema: process.env.NODE_ENV === "test", + cache: !config.db.disableCache + ? { + type: "ioredis", + options: { + host: config.redis.host, + port: config.redis.port, + family: config.redis.family == null ? 0 : config.redis.family, + username: config.redis.user ?? "default", + password: config.redis.pass, + keyPrefix: `${config.redis.prefix}:query:`, + db: config.redis.db || 0, + tls: config.redis.tls, + }, + } + : false, + logging: log, + logger: new MyCustomLogger(), + maxQueryExecutionTime: 300, + entities: entities, + migrations: ["../../migration/*.js"], +}); + +export async function initDb(force = false) { + if (force) { + if (db.isInitialized) { + await db.destroy(); + } + await db.initialize(); + return; + } + + if (db.isInitialized) { + // nop + } else { + await db.initialize(); + } +} + +export async function resetDb() { + const reset = async () => { + const { redisClient } = await import("./redis.js"); + await redisClient.flushdb(); + const tables = await db.query(`SELECT relname AS "table" + FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) + WHERE nspname NOT IN ('pg_catalog', 'information_schema') + AND C.relkind = 'r' + AND nspname !~ '^pg_toast';`); + for (const table of tables) { + await db.query(`DELETE FROM "${table.table}" CASCADE`); + } + }; + + for (let i = 1; i <= 3; i++) { + try { + await reset(); + } catch (e) { + if (i === 3) { + throw e; + } else { + await new Promise((resolve) => setTimeout(resolve, 1000)); + continue; + } + } + break; + } +} diff --git a/packages/backend/src/db/redis.ts b/packages/backend/src/db/redis.ts new file mode 100644 index 0000000..40fed15 --- /dev/null +++ b/packages/backend/src/db/redis.ts @@ -0,0 +1,22 @@ +import Redis from "ioredis"; +import config from "@/config/index.js"; + +export function createConnection() { + let source = config.redis; + + return new Redis({ + port: source.port, + host: source.host, + family: source.family ?? 0, + password: source.pass, + username: source.user ?? "default", + keyPrefix: `${source.prefix}:`, + db: source.db || 0, + tls: source.tls, + }); +} + +export const subscriber = createConnection(); +subscriber.subscribe(config.redis.prefix ?? config.host); + +export const redisClient = createConnection(); diff --git a/packages/backend/src/env.ts b/packages/backend/src/env.ts new file mode 100644 index 0000000..a788a0f --- /dev/null +++ b/packages/backend/src/env.ts @@ -0,0 +1,25 @@ +const envOption = { + onlyQueue: false, + onlyServer: false, + noDaemons: false, + disableClustering: false, + verbose: false, + withLogTime: false, + quiet: false, + slow: false, +}; + +for (const key of Object.keys(envOption) as (keyof typeof envOption)[]) { + if ( + process.env[ + `MK_${key.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase()}` + ] + ) + envOption[key] = true; +} + +if (process.env.NODE_ENV === "test") envOption.disableClustering = true; +if (process.env.NODE_ENV === "test") envOption.quiet = true; +if (process.env.NODE_ENV === "test") envOption.noDaemons = true; + +export { envOption }; diff --git a/packages/backend/src/global.d.ts b/packages/backend/src/global.d.ts new file mode 100644 index 0000000..503e26e --- /dev/null +++ b/packages/backend/src/global.d.ts @@ -0,0 +1,2 @@ +// rome-ignore lint/suspicious/noExplicitAny: i have no idea +type FIXME = any; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts new file mode 100644 index 0000000..2ae7cae --- /dev/null +++ b/packages/backend/src/index.ts @@ -0,0 +1,24 @@ +/** + * Misskey Entry Point! + */ + +import { EventEmitter } from "node:events"; + +Error.stackTraceLimit = Infinity; +EventEmitter.defaultMaxListeners = 128; + +const emitWarning = process.emitWarning; +process.emitWarning = ((warning: string | Error, ...args: any[]) => { + const code = + (warning instanceof Error ? (warning as Error & { code?: string }).code : undefined) ?? + (typeof args[0] === "object" ? args[0]?.code : undefined) ?? + (typeof args[1] === "string" ? args[1] : undefined); + if (code === "DEP0040" || code === "DEP0060") return; + return emitWarning.call(process, warning as any, ...args); +}) as typeof process.emitWarning; + +const { default: boot } = await import("./boot/index.js"); + +boot().catch((err) => { + console.error(err); +}); diff --git a/packages/backend/src/metrics.ts b/packages/backend/src/metrics.ts new file mode 100644 index 0000000..b8a6de9 --- /dev/null +++ b/packages/backend/src/metrics.ts @@ -0,0 +1,141 @@ +import Router from "@koa/router"; +import { + collectDefaultMetrics, + register, + Gauge, + Counter, + CounterConfiguration, +} from "prom-client"; +import config from "./config/index.js"; +import { queues } from "./queue/queues.js"; +import cluster from "node:cluster"; +import Xev from "xev"; + +const xev = new Xev(); + +if (config.metrics?.enable) { + if (cluster.isPrimary) { + collectDefaultMetrics(); + + new Gauge({ + name: "iceshrimp_queue_jobs", + help: "Amount of jobs in the bull queues", + labelNames: ["queue", "status"] as const, + async collect() { + for (const queue of queues) { + const counts = await queue.getJobCounts(); + this.set({ queue: queue.name, status: "completed" }, counts.completed); + this.set({ queue: queue.name, status: "waiting" }, counts.waiting); + this.set({ queue: queue.name, status: "active" }, counts.active); + this.set({ queue: queue.name, status: "delayed" }, counts.delayed); + this.set({ queue: queue.name, status: "failed" }, counts.failed); + } + }, + }); + } +} + +if (cluster.isPrimary) { + xev.on("registry-request", async () => { + try { + const metrics = await register.metrics(); + xev.emit("registry-response", { + contentType: register.contentType, + body: metrics + }); + } catch (error) { + xev.emit("registry-response", { error }); + } + }); +} + +export const handleMetrics: Router.Middleware = async (ctx) => { + if (config.metrics?.token !== undefined) { + if (ctx.query.token === undefined) { + ctx.res.statusCode = 401; + ctx.body = "Missing token parameter"; + return; + } + const correct = config.metrics.token === ctx.query.token; + if (!correct) { + ctx.res.statusCode = 403; + ctx.body = "Incorrect token"; + return; + } + } + try { + if (cluster.isPrimary) { + ctx.set("content-type", register.contentType); + ctx.body = await register.metrics(); + } else { + const wait = new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject("Timeout while waiting for cluster master"), + 1000 * 60 + ); + xev.once("registry-response", (response) => { + clearTimeout(timeout); + if (response.error) reject(response.error); + ctx.set("content-type", response.contentType); + ctx.body = response.body; + resolve(); + }); + }); + xev.emit("registry-request"); + await wait; + } + } catch (err) { + ctx.res.statusCode = 500; + ctx.body = err; + } +}; + +const counter = (configuration: CounterConfiguration) => { + if (config.metrics?.enable) { + if (cluster.isPrimary) { + const counter = new Counter(configuration); + counter.reset(); // initialize internal hashmap + xev.on(`metrics-counter-${configuration.name}`, () => counter.inc()); + return () => counter.inc(); + } else { + return () => xev.emit(`metrics-counter-${configuration.name}`); + } + } else { + return () => { }; + } +}; + +export const tickOutbox = counter({ + name: "iceshrimp_outbox_total", + help: "Total AP outbox calls", +}); + +export const tickInbox = counter({ + name: "iceshrimp_inbox_total", + help: "Total AP inbox calls", +}); + +export const tickFetch = counter({ + name: "iceshrimp_fetch_total", + help: "Total AP fetch calls", +}); + +export const tickResolve = counter({ + name: "iceshrimp_resolve_total", + help: "Total AP resolve calls", +}); + +export const tickBiteIncoming = counter({ + name: "iceshrimp_bite_remote_incoming_total", + help: "Total bites received from remote", +}); + +export const tickBiteOutgoing = counter({ + name: "iceshrimp_bite_remote_outgoing_total", + help: "Total bites sent to remote", +}); + +export const tickBiteLocal = counter ({ + name: "iceshrimp_bite_local_total", + help: "Total local bites" +}); diff --git a/packages/backend/src/mfm/from-html.ts b/packages/backend/src/mfm/from-html.ts new file mode 100644 index 0000000..338ed77 --- /dev/null +++ b/packages/backend/src/mfm/from-html.ts @@ -0,0 +1,221 @@ +import * as parse5 from "parse5"; +import { defaultTreeAdapter as treeAdapter } from "parse5"; +import { getSubjectHostFromUriAndUsernameCached } from "@/remote/resolve-user.js"; +import type { + Node as TreeAdapterNode, + ChildNode as TreeAdapterChildNode, +} from "parse5/dist/tree-adapters/default.js"; + +const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; +const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; + +export async function fromHtml(html: string, hashtagNames?: string[]): Promise { + // some AP servers like Pixelfed use br tags as well as newlines + html = html.replace(/\r?\n/gi, "\n"); + + const dom = parse5.parseFragment(html); + + let text = ""; + + for (const n of dom.childNodes) { + await analyze(n); + } + + return text.trim(); + + function getText(node: TreeAdapterNode): string { + if (treeAdapter.isTextNode(node)) return node.value; + if (!treeAdapter.isElementNode(node)) return ""; + if (node.nodeName === "br") return "\n"; + + if (node.childNodes) { + return node.childNodes.map((n) => getText(n)).join(""); + } + + return ""; + } + + async function appendChildren(childNodes: TreeAdapterChildNode[]): Promise { + if (childNodes) { + for (const n of childNodes) { + await analyze(n); + } + } + } + + async function analyze(node: TreeAdapterNode) { + if (treeAdapter.isTextNode(node)) { + text += node.value; + return; + } + + // Skip comment or document type node + if (!treeAdapter.isElementNode(node)) return; + + // Strip quote marker + const classes = node.attrs.find((x) => x.name === "class"); + if (classes && classes.value.split(" ").includes("quote-inline")) { + return; + } + + switch (node.nodeName) { + case "br": { + text += "\n"; + break; + } + + case "a": { + const txt = getText(node); + const rel = node.attrs.find((x) => x.name === "rel"); + const href = node.attrs.find((x) => x.name === "href"); + + // ハッシュタグ + if ( + hashtagNames && + href && + hashtagNames.map((x) => x.toLowerCase()).includes(txt.toLowerCase()) + ) { + text += txt; + // メンション + } else if (txt.startsWith("@") && !rel?.value.match(/^me /)) { + const part = txt.split("@"); + + if (part.length === 2 && href) { + //#region ホスト名部分が省略されているので復元する + const acct = `${txt}@${await getSubjectHostFromUriAndUsernameCached(href.value, txt)}`; + text += acct; + //#endregion + } else if (part.length === 3) { + text += txt; + } + // その他 + } else { + const generateLink = () => { + if (!(href || txt)) { + return ""; + } + if (!href) { + return txt; + } + if (!txt || txt === href.value) { + // #6383: Missing text node + if (href.value.match(urlRegexFull)) { + return href.value; + } else { + return `<${href.value}>`; + } + } + if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) { + return `[${txt}](<${href.value}>)`; // #6846 + } else { + return `[${txt}](${href.value})`; + } + }; + + text += generateLink(); + } + break; + } + + case "h1": { + text += "【"; + await appendChildren(node.childNodes); + text += "】\n"; + break; + } + + case "b": + case "strong": { + text += "**"; + await appendChildren(node.childNodes); + text += "**"; + break; + } + + case "small": { + text += ""; + await appendChildren(node.childNodes); + text += ""; + break; + } + + case "s": + case "del": { + text += "~~"; + await appendChildren(node.childNodes); + text += "~~"; + break; + } + + case "i": + case "em": { + text += ""; + await appendChildren(node.childNodes); + text += ""; + break; + } + + // block code (
)
+			case "pre": {
+				if (
+					node.childNodes.length === 1 &&
+					node.childNodes[0].nodeName === "code"
+				) {
+					text += "\n```\n";
+					text += getText(node.childNodes[0]);
+					text += "\n```\n";
+				} else {
+					await appendChildren(node.childNodes);
+				}
+				break;
+			}
+
+			// inline code ()
+			case "code": {
+				text += "`";
+				await appendChildren(node.childNodes);
+				text += "`";
+				break;
+			}
+
+			case "blockquote": {
+				const t = getText(node);
+				if (t) {
+					text += "\n> ";
+					text += t.split("\n").join("\n> ");
+				}
+				break;
+			}
+
+			case "p":
+			case "h2":
+			case "h3":
+			case "h4":
+			case "h5":
+			case "h6": {
+				text += "\n\n";
+				await appendChildren(node.childNodes);
+				break;
+			}
+
+			// other block elements
+			case "div":
+			case "header":
+			case "footer":
+			case "article":
+			case "li":
+			case "dt":
+			case "dd": {
+				text += "\n";
+				await appendChildren(node.childNodes);
+				break;
+			}
+
+			default: {
+				// includes inline elements
+				await appendChildren(node.childNodes);
+				break;
+			}
+		}
+	}
+}
diff --git a/packages/backend/src/mfm/to-html.ts b/packages/backend/src/mfm/to-html.ts
new file mode 100644
index 0000000..b6d3de4
--- /dev/null
+++ b/packages/backend/src/mfm/to-html.ts
@@ -0,0 +1,205 @@
+import { Window as HappyDom } from "happy-dom";
+import type * as mfm from "mfm-js";
+import config from "@/config/index.js";
+import { intersperse } from "@/prelude/array.js";
+import type { IMentionedRemoteUsers } from "@/models/entities/note.js";
+import { resolveMentionFromCache } from "@/remote/resolve-user.js";
+
+export async function toHtml(
+	nodes: mfm.MfmNode[] | null,
+	mentionedRemoteUsers: IMentionedRemoteUsers = [],
+	objectHost: string | null
+) {
+	if (nodes == null) {
+		return null;
+	}
+
+	const window = new HappyDom();
+
+	const doc = window.document;
+
+	function appendTextWithGlyphs(text: string, targetElement: Element): void {
+		const regexp = /;([^:;\s]{1,100});/g;
+		let last = 0;
+
+		for (const match of text.matchAll(regexp)) {
+			if (match.index! > last) {
+				targetElement.appendChild(doc.createTextNode(text.slice(last, match.index)));
+			}
+
+			targetElement.appendChild(doc.createTextNode(`\u200B:${match[1]}:\u200B`));
+			last = match.index! + match[0].length;
+		}
+
+		if (last < text.length) {
+			targetElement.appendChild(doc.createTextNode(text.slice(last)));
+		}
+	}
+
+	async function appendChildren(children: mfm.MfmNode[], targetElement: any): Promise {
+		if (children) {
+			for (const child of await Promise.all(children.map(async (x) => await (handlers as any)[x.type](x))))
+				targetElement.appendChild(child);
+		}
+	}
+
+	const handlers: {
+		[K in mfm.MfmNode["type"]]: (node: mfm.NodeType) => any;
+	} = {
+		async bold(node) {
+			const el = doc.createElement("b");
+			await appendChildren(node.children, el);
+			return el;
+		},
+
+		async small(node) {
+			const el = doc.createElement("small");
+			await appendChildren(node.children, el);
+			return el;
+		},
+
+		async strike(node) {
+			const el = doc.createElement("del");
+			await appendChildren(node.children, el);
+			return el;
+		},
+
+		async italic(node) {
+			const el = doc.createElement("i");
+			await appendChildren(node.children, el);
+			return el;
+		},
+
+		async fn(node) {
+			const el = doc.createElement("i");
+			await appendChildren(node.children, el);
+			return el;
+		},
+
+		blockCode(node) {
+			const pre = doc.createElement("pre");
+			const inner = doc.createElement("code");
+			inner.textContent = node.props.code;
+			pre.appendChild(inner);
+			return pre;
+		},
+
+		async center(node) {
+			const el = doc.createElement("div");
+			await appendChildren(node.children, el);
+			return el;
+		},
+
+		emojiCode(node) {
+			return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
+		},
+
+		unicodeEmoji(node) {
+			return doc.createTextNode(node.props.emoji);
+		},
+
+		hashtag(node) {
+			const a = doc.createElement("a");
+			a.setAttribute('href', `${config.url}/tags/${node.props.hashtag}`);
+			a.textContent = `#${node.props.hashtag}`;
+			a.setAttribute("rel", "tag");
+			return a;
+		},
+
+		inlineCode(node) {
+			const el = doc.createElement("code");
+			el.textContent = node.props.code;
+			return el;
+		},
+
+		mathInline(node) {
+			const el = doc.createElement("code");
+			el.textContent = node.props.formula;
+			return el;
+		},
+
+		mathBlock(node) {
+			const el = doc.createElement("code");
+			el.textContent = node.props.formula;
+			return el;
+		},
+
+		async link(node) {
+			const a = doc.createElement("a");
+			a.setAttribute('href', node.props.url);
+			await appendChildren(node.children, a);
+			return a;
+		},
+
+		async mention(node) {
+			const { username, host, acct } = node.props;
+			const resolved = await resolveMentionFromCache(username, host, objectHost, mentionedRemoteUsers);
+
+			const el = doc.createElement("span");
+			if (resolved === null) {
+				el.textContent = acct;
+			} else {
+				el.setAttribute("class", "h-card");
+				el.setAttribute("translate", "no");
+				const a = doc.createElement("a");
+				a.setAttribute('href', resolved.href);
+				a.className = "u-url mention";
+				const span = doc.createElement("span");
+				span.textContent = resolved.username;
+				a.textContent = '@';
+				a.appendChild(span);
+				el.appendChild(a);
+			}
+
+			return el;
+		},
+
+		async quote(node) {
+			const el = doc.createElement("blockquote");
+			await appendChildren(node.children, el);
+			return el;
+		},
+
+		text(node) {
+			const el = doc.createElement("span");
+			const lines = node.props.text.split(/\r\n|\r|\n/);
+
+			for (const x of intersperse("br", lines)) {
+				if (x === "br") {
+					el.appendChild(doc.createElement("br"));
+					continue;
+				}
+
+				appendTextWithGlyphs(x, el);
+			}
+
+			return el;
+		},
+
+		url(node) {
+			const a = doc.createElement("a");
+			a.setAttribute('href', node.props.url);
+			a.textContent = node.props.url.replace(/^https?:\/\//, '');
+			return a;
+		},
+
+		search(node) {
+			const a = doc.createElement("a");
+			a.setAttribute('href', `${config.searchEngine}${node.props.query}`);
+			a.textContent = node.props.content;
+			return a;
+		},
+
+		async plain(node) {
+			const el = doc.createElement("span");
+			await appendChildren(node.children, el);
+			return el;
+		},
+	};
+
+	await appendChildren(nodes, doc.body);
+
+	const html = `

${doc.body.innerHTML}

`; + await window.happyDOM.close(); + return html; +} diff --git a/packages/backend/src/migration/1000000000000-Init.ts b/packages/backend/src/migration/1000000000000-Init.ts new file mode 100644 index 0000000..efbe6ea --- /dev/null +++ b/packages/backend/src/migration/1000000000000-Init.ts @@ -0,0 +1,1069 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class Init1000000000000 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "log_level_enum" AS ENUM('error', 'warning', 'info', 'success', 'debug')`, + ); + await queryRunner.query( + `CREATE TABLE "log" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "domain" character varying(64) array NOT NULL DEFAULT '{}'::varchar[], "level" "log_level_enum" NOT NULL, "worker" character varying(8) NOT NULL, "machine" character varying(128) NOT NULL, "message" character varying(1024) NOT NULL, "data" jsonb NOT NULL DEFAULT '{}', CONSTRAINT "PK_350604cbdf991d5930d9e618fbd" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_8e4eb51a35d81b64dda28eed0a" ON "log" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_8cb40cfc8f3c28261e6f887b03" ON "log" ("domain") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_584b536b49e53ac81beb39a177" ON "log" ("level") `, + ); + await queryRunner.query( + `CREATE TABLE "drive_folder" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(128) NOT NULL, "userId" character varying(32), "parentId" character varying(32), CONSTRAINT "PK_7a0c089191f5ebdc214e0af808a" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_02878d441ceae15ce060b73daf" ON "drive_folder" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f4fc06e49c0171c85f1c48060d" ON "drive_folder" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_00ceffb0cdc238b3233294f08f" ON "drive_folder" ("parentId") `, + ); + await queryRunner.query( + `CREATE TABLE "drive_file" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32), "userHost" character varying(128), "md5" character varying(32) NOT NULL, "name" character varying(256) NOT NULL, "type" character varying(128) NOT NULL, "size" integer NOT NULL, "comment" character varying(512), "properties" jsonb NOT NULL DEFAULT '{}', "storedInternal" boolean NOT NULL, "url" character varying(512) NOT NULL, "thumbnailUrl" character varying(512), "webpublicUrl" character varying(512), "accessKey" character varying(256), "thumbnailAccessKey" character varying(256), "webpublicAccessKey" character varying(256), "uri" character varying(512), "src" character varying(512), "folderId" character varying(32), "isSensitive" boolean NOT NULL DEFAULT false, "isLink" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_43ddaaaf18c9e68029b7cbb032e" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_c8dfad3b72196dd1d6b5db168a" ON "drive_file" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_860fa6f6c7df5bb887249fba22" ON "drive_file" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_92779627994ac79277f070c91e" ON "drive_file" ("userHost") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_37bb9a1b4585f8a3beb24c62d6" ON "drive_file" ("md5") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_a40b8df8c989d7db937ea27cf6" ON "drive_file" ("type") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_d85a184c2540d2deba33daf642" ON "drive_file" ("accessKey") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_e74022ce9a074b3866f70e0d27" ON "drive_file" ("thumbnailAccessKey") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_c55b2b7c284d9fef98026fc88e" ON "drive_file" ("webpublicAccessKey") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e5848eac4940934e23dbc17581" ON "drive_file" ("uri") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_bb90d1956dafc4068c28aa7560" ON "drive_file" ("folderId") `, + ); + await queryRunner.query( + `CREATE TABLE "user" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE, "lastFetchedAt" TIMESTAMP WITH TIME ZONE, "username" character varying(128) NOT NULL, "usernameLower" character varying(128) NOT NULL, "name" character varying(128), "followersCount" integer NOT NULL DEFAULT 0, "followingCount" integer NOT NULL DEFAULT 0, "notesCount" integer NOT NULL DEFAULT 0, "avatarId" character varying(32), "bannerId" character varying(32), "tags" character varying(128) array NOT NULL DEFAULT '{}'::varchar[], "avatarUrl" character varying(512), "bannerUrl" character varying(512), "avatarColor" character varying(32), "bannerColor" character varying(32), "isSuspended" boolean NOT NULL DEFAULT false, "isSilenced" boolean NOT NULL DEFAULT false, "isLocked" boolean NOT NULL DEFAULT false, "isBot" boolean NOT NULL DEFAULT false, "isCat" boolean NOT NULL DEFAULT false, "isAdmin" boolean NOT NULL DEFAULT false, "isModerator" boolean NOT NULL DEFAULT false, "isVerified" boolean NOT NULL DEFAULT false, "emojis" character varying(128) array NOT NULL DEFAULT '{}'::varchar[], "host" character varying(128), "inbox" character varying(512), "sharedInbox" character varying(512), "featured" character varying(512), "uri" character varying(512), "token" character(16), CONSTRAINT "UQ_a854e557b1b14814750c7c7b0c9" UNIQUE ("token"), CONSTRAINT "REL_58f5c71eaab331645112cf8cfa" UNIQUE ("avatarId"), CONSTRAINT "REL_afc64b53f8db3707ceb34eb28e" UNIQUE ("bannerId"), CONSTRAINT "PK_cace4a159ff9f2512dd42373760" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e11e649824a45d8ed01d597fd9" ON "user" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_80ca6e6ef65fb9ef34ea8c90f4" ON "user" ("updatedAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_a27b942a0d6dcff90e3ee9b5e8" ON "user" ("usernameLower") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_fa99d777623947a5b05f394cae" ON "user" ("tags") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_3252a5df8d5bbd16b281f7799e" ON "user" ("host") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_be623adaa4c566baf5d29ce0c8" ON "user" ("uri") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_a854e557b1b14814750c7c7b0c" ON "user" ("token") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_5deb01ae162d1d70b80d064c27" ON "user" ("usernameLower", "host") `, + ); + await queryRunner.query( + `CREATE TABLE "app" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32), "secret" character varying(64) NOT NULL, "name" character varying(128) NOT NULL, "description" character varying(512) NOT NULL, "permission" character varying(64) array NOT NULL, "callbackUrl" character varying(512), CONSTRAINT "PK_9478629fc093d229df09e560aea" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_048a757923ed8b157e9895da53" ON "app" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_3f5b0899ef90527a3462d7c2cb" ON "app" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f49922d511d666848f250663c4" ON "app" ("secret") `, + ); + await queryRunner.query( + `CREATE TABLE "access_token" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "token" character varying(128) NOT NULL, "hash" character varying(128) NOT NULL, "userId" character varying(32) NOT NULL, "appId" character varying(32) NOT NULL, CONSTRAINT "PK_f20f028607b2603deabd8182d12" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_70ba8f6af34bc924fc9e12adb8" ON "access_token" ("token") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_64c327441248bae40f7d92f34f" ON "access_token" ("hash") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_9949557d0e1b2c19e5344c171e" ON "access_token" ("userId") `, + ); + await queryRunner.query( + `CREATE TYPE "note_visibility_enum" AS ENUM('public', 'home', 'followers', 'specified')`, + ); + await queryRunner.query( + `CREATE TABLE "note" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "replyId" character varying(32), "renoteId" character varying(32), "text" text, "name" character varying(256), "cw" character varying(512), "appId" character varying(32), "userId" character varying(32) NOT NULL, "viaMobile" boolean NOT NULL DEFAULT false, "localOnly" boolean NOT NULL DEFAULT false, "renoteCount" smallint NOT NULL DEFAULT 0, "repliesCount" smallint NOT NULL DEFAULT 0, "reactions" jsonb NOT NULL DEFAULT '{}', "visibility" "note_visibility_enum" NOT NULL, "uri" character varying(512), "score" integer NOT NULL DEFAULT 0, "fileIds" character varying(32) array NOT NULL DEFAULT '{}'::varchar[], "attachedFileTypes" character varying(256) array NOT NULL DEFAULT '{}'::varchar[], "visibleUserIds" character varying(32) array NOT NULL DEFAULT '{}'::varchar[], "mentions" character varying(32) array NOT NULL DEFAULT '{}'::varchar[], "mentionedRemoteUsers" text NOT NULL DEFAULT '[]', "emojis" character varying(128) array NOT NULL DEFAULT '{}'::varchar[], "tags" character varying(128) array NOT NULL DEFAULT '{}'::varchar[], "hasPoll" boolean NOT NULL DEFAULT false, "geo" jsonb DEFAULT null, "userHost" character varying(128), "replyUserId" character varying(32), "replyUserHost" character varying(128), "renoteUserId" character varying(32), "renoteUserHost" character varying(128), CONSTRAINT "PK_96d0c172a4fba276b1bbed43058" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e7c0567f5261063592f022e9b5" ON "note" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_17cb3553c700a4985dff5a30ff" ON "note" ("replyId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_52ccc804d7c69037d558bac4c9" ON "note" ("renoteId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_153536c67d05e9adb24e99fc2b" ON "note" ("uri") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_51c063b6a133a9cb87145450f5" ON "note" ("fileIds") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_25dfc71b0369b003a4cd434d0b" ON "note" ("attachedFileTypes") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_796a8c03959361f97dc2be1d5c" ON "note" ("visibleUserIds") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_54ebcb6d27222913b908d56fd8" ON "note" ("mentions") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_88937d94d7443d9a99a76fa5c0" ON "note" ("tags") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_7125a826ab192eb27e11d358a5" ON "note" ("userHost") `, + ); + await queryRunner.query( + `CREATE TABLE "poll_vote" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, "choice" integer NOT NULL, CONSTRAINT "PK_fd002d371201c472490ba89c6a0" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0fb627e1c2f753262a74f0562d" ON "poll_vote" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_66d2bd2ee31d14bcc23069a89f" ON "poll_vote" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_aecfbd5ef60374918e63ee95fa" ON "poll_vote" ("noteId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_50bd7164c5b78f1f4a42c4d21f" ON "poll_vote" ("userId", "noteId", "choice") `, + ); + await queryRunner.query( + `CREATE TABLE "note_reaction" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, "reaction" character varying(128) NOT NULL, CONSTRAINT "PK_767ec729b108799b587a3fcc9cf" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_01f4581f114e0ebd2bbb876f0b" ON "note_reaction" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_13761f64257f40c5636d0ff95e" ON "note_reaction" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_45145e4953780f3cd5656f0ea6" ON "note_reaction" ("noteId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_ad0c221b25672daf2df320a817" ON "note_reaction" ("userId", "noteId") `, + ); + await queryRunner.query( + `CREATE TABLE "note_watching" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, "noteUserId" character varying(32) NOT NULL, CONSTRAINT "PK_49286fdb23725945a74aa27d757" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_318cdf42a9cfc11f479bd802bb" ON "note_watching" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b0134ec406e8d09a540f818288" ON "note_watching" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_03e7028ab8388a3f5e3ce2a861" ON "note_watching" ("noteId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_44499765eec6b5489d72c4253b" ON "note_watching" ("noteUserId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_a42c93c69989ce1d09959df4cf" ON "note_watching" ("userId", "noteId") `, + ); + await queryRunner.query( + `CREATE TABLE "note_unread" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, "noteUserId" character varying(32) NOT NULL, "isSpecified" boolean NOT NULL, CONSTRAINT "PK_1904eda61a784f57e6e51fa9c1f" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_56b0166d34ddae49d8ef7610bb" ON "note_unread" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e637cba4dc4410218c4251260e" ON "note_unread" ("noteId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_d908433a4953cc13216cd9c274" ON "note_unread" ("userId", "noteId") `, + ); + await queryRunner.query( + `CREATE TABLE "notification" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "notifieeId" character varying(32) NOT NULL, "notifierId" character varying(32) NOT NULL, "type" character varying(32) NOT NULL, "isRead" boolean NOT NULL DEFAULT false, "noteId" character varying(32), "reaction" character varying(128), "choice" integer, CONSTRAINT "PK_705b6c7cdf9b2c2ff7ac7872cb7" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b11a5e627c41d4dc3170f1d370" ON "notification" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_3c601b70a1066d2c8b517094cb" ON "notification" ("notifieeId") `, + ); + await queryRunner.query( + `CREATE TABLE "meta" ("id" character varying(32) NOT NULL, "name" character varying(128), "description" character varying(1024), "maintainerName" character varying(128), "maintainerEmail" character varying(128), "announcements" jsonb NOT NULL DEFAULT '[]', "disableRegistration" boolean NOT NULL DEFAULT false, "disableLocalTimeline" boolean NOT NULL DEFAULT false, "disableGlobalTimeline" boolean NOT NULL DEFAULT false, "enableEmojiReaction" boolean NOT NULL DEFAULT true, "useStarForReactionFallback" boolean NOT NULL DEFAULT false, "langs" character varying(64) array NOT NULL DEFAULT '{}'::varchar[], "hiddenTags" character varying(256) array NOT NULL DEFAULT '{}'::varchar[], "blockedHosts" character varying(256) array NOT NULL DEFAULT '{}'::varchar[], "mascotImageUrl" character varying(512) DEFAULT '/twemoji/1f440.svg', "bannerUrl" character varying(512), "errorImageUrl" character varying(512) DEFAULT '/twemoji/1f480.svg', "iconUrl" character varying(512), "cacheRemoteFiles" boolean NOT NULL DEFAULT false, "proxyAccount" character varying(128), "enableRecaptcha" boolean NOT NULL DEFAULT false, "recaptchaSiteKey" character varying(64), "recaptchaSecretKey" character varying(64), "localDriveCapacityMb" integer NOT NULL DEFAULT 1024, "remoteDriveCapacityMb" integer NOT NULL DEFAULT 32, "maxNoteTextLength" integer NOT NULL DEFAULT 500, "summalyProxy" character varying(128), "enableEmail" boolean NOT NULL DEFAULT false, "email" character varying(128), "smtpSecure" boolean NOT NULL DEFAULT false, "smtpHost" character varying(128), "smtpPort" integer, "smtpUser" character varying(128), "smtpPass" character varying(128), "enableServiceWorker" boolean NOT NULL DEFAULT false, "swPublicKey" character varying(128), "swPrivateKey" character varying(128), "enableTwitterIntegration" boolean NOT NULL DEFAULT false, "twitterConsumerKey" character varying(128), "twitterConsumerSecret" character varying(128), "enableGithubIntegration" boolean NOT NULL DEFAULT false, "githubClientId" character varying(128), "githubClientSecret" character varying(128), "enableDiscordIntegration" boolean NOT NULL DEFAULT false, "discordClientId" character varying(128), "discordClientSecret" character varying(128), CONSTRAINT "PK_c4c17a6c2bd7651338b60fc590b" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "following" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "followeeId" character varying(32) NOT NULL, "followerId" character varying(32) NOT NULL, "followerHost" character varying(128), "followerInbox" character varying(512), "followerSharedInbox" character varying(512), "followeeHost" character varying(128), "followeeInbox" character varying(512), "followeeSharedInbox" character varying(512), CONSTRAINT "PK_c76c6e044bdf76ecf8bfb82a645" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_582f8fab771a9040a12961f3e7" ON "following" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_24e0042143a18157b234df186c" ON "following" ("followeeId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_6516c5a6f3c015b4eed39978be" ON "following" ("followerId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_307be5f1d1252e0388662acb96" ON "following" ("followerId", "followeeId") `, + ); + await queryRunner.query( + `CREATE TABLE "instance" ("id" character varying(32) NOT NULL, "caughtAt" TIMESTAMP WITH TIME ZONE NOT NULL, "host" character varying(128) NOT NULL, "system" character varying(64), "usersCount" integer NOT NULL DEFAULT 0, "notesCount" integer NOT NULL DEFAULT 0, "followingCount" integer NOT NULL DEFAULT 0, "followersCount" integer NOT NULL DEFAULT 0, "driveUsage" integer NOT NULL DEFAULT 0, "driveFiles" integer NOT NULL DEFAULT 0, "latestRequestSentAt" TIMESTAMP WITH TIME ZONE, "latestStatus" integer, "latestRequestReceivedAt" TIMESTAMP WITH TIME ZONE, "lastCommunicatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "isNotResponding" boolean NOT NULL DEFAULT false, "isMarkedAsClosed" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_eaf60e4a0c399c9935413e06474" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_2cd3b2a6b4cf0b910b260afe08" ON "instance" ("caughtAt") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_8d5afc98982185799b160e10eb" ON "instance" ("host") `, + ); + await queryRunner.query( + `CREATE TABLE "muting" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "muteeId" character varying(32) NOT NULL, "muterId" character varying(32) NOT NULL, CONSTRAINT "PK_2e92d06c8b5c602eeb27ca9ba48" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f86d57fbca33c7a4e6897490cc" ON "muting" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_ec96b4fed9dae517e0dbbe0675" ON "muting" ("muteeId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_93060675b4a79a577f31d260c6" ON "muting" ("muterId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_1eb9d9824a630321a29fd3b290" ON "muting" ("muterId", "muteeId") `, + ); + await queryRunner.query( + `CREATE TABLE "sw_subscription" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "endpoint" character varying(512) NOT NULL, "auth" character varying(256) NOT NULL, "publickey" character varying(128) NOT NULL, CONSTRAINT "PK_e8f763631530051b95eb6279b91" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_97754ca6f2baff9b4abb7f853d" ON "sw_subscription" ("userId") `, + ); + await queryRunner.query( + `CREATE TABLE "blocking" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "blockeeId" character varying(32) NOT NULL, "blockerId" character varying(32) NOT NULL, CONSTRAINT "PK_e5d9a541cc1965ee7e048ea09dd" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b9a354f7941c1e779f3b33aea6" ON "blocking" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_2cd4a2743a99671308f5417759" ON "blocking" ("blockeeId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0627125f1a8a42c9a1929edb55" ON "blocking" ("blockerId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_98a1bc5cb30dfd159de056549f" ON "blocking" ("blockerId", "blockeeId") `, + ); + await queryRunner.query( + `CREATE TABLE "user_list" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "name" character varying(128) NOT NULL, CONSTRAINT "PK_87bab75775fd9b1ff822b656402" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b7fcefbdd1c18dce86687531f9" ON "user_list" ("userId") `, + ); + await queryRunner.query( + `CREATE TABLE "user_list_joining" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "userListId" character varying(32) NOT NULL, CONSTRAINT "PK_11abb3768da1c5f8de101c9df45" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_d844bfc6f3f523a05189076efa" ON "user_list_joining" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_605472305f26818cc93d1baaa7" ON "user_list_joining" ("userListId") `, + ); + await queryRunner.query( + `CREATE TABLE "hashtag" ("id" character varying(32) NOT NULL, "name" character varying(128) NOT NULL, "mentionedUserIds" character varying(32) array NOT NULL, "mentionedUsersCount" integer NOT NULL DEFAULT 0, "mentionedLocalUserIds" character varying(32) array NOT NULL, "mentionedLocalUsersCount" integer NOT NULL DEFAULT 0, "mentionedRemoteUserIds" character varying(32) array NOT NULL, "mentionedRemoteUsersCount" integer NOT NULL DEFAULT 0, "attachedUserIds" character varying(32) array NOT NULL, "attachedUsersCount" integer NOT NULL DEFAULT 0, "attachedLocalUserIds" character varying(32) array NOT NULL, "attachedLocalUsersCount" integer NOT NULL DEFAULT 0, "attachedRemoteUserIds" character varying(32) array NOT NULL, "attachedRemoteUsersCount" integer NOT NULL DEFAULT 0, CONSTRAINT "PK_cb36eb8af8412bfa978f1165d78" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_347fec870eafea7b26c8a73bac" ON "hashtag" ("name") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_2710a55f826ee236ea1a62698f" ON "hashtag" ("mentionedUsersCount") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0e206cec573f1edff4a3062923" ON "hashtag" ("mentionedLocalUsersCount") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_4c02d38a976c3ae132228c6fce" ON "hashtag" ("mentionedRemoteUsersCount") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_d57f9030cd3af7f63ffb1c267c" ON "hashtag" ("attachedUsersCount") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0c44bf4f680964145f2a68a341" ON "hashtag" ("attachedLocalUsersCount") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0b03cbcd7e6a7ce068efa8ecc2" ON "hashtag" ("attachedRemoteUsersCount") `, + ); + await queryRunner.query( + `CREATE TABLE "note_favorite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, CONSTRAINT "PK_af0da35a60b9fa4463a62082b36" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_47f4b1892f5d6ba8efb3057d81" ON "note_favorite" ("userId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_0f4fb9ad355f3effff221ef245" ON "note_favorite" ("userId", "noteId") `, + ); + await queryRunner.query( + `CREATE TABLE "abuse_user_report" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "reporterId" character varying(32) NOT NULL, "comment" character varying(512) NOT NULL, CONSTRAINT "PK_87873f5f5cc5c321a1306b2d18c" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_db2098070b2b5a523c58181f74" ON "abuse_user_report" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_d049123c413e68ca52abe73420" ON "abuse_user_report" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_04cc96756f89d0b7f9473e8cdf" ON "abuse_user_report" ("reporterId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_5cd442c3b2e74fdd99dae20243" ON "abuse_user_report" ("userId", "reporterId") `, + ); + await queryRunner.query( + `CREATE TABLE "registration_ticket" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "code" character varying(64) NOT NULL, CONSTRAINT "PK_f11696b6fafcf3662d4292734f8" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_0ff69e8dfa9fe31bb4a4660f59" ON "registration_ticket" ("code") `, + ); + await queryRunner.query( + `CREATE TABLE "messaging_message" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "recipientId" character varying(32) NOT NULL, "text" character varying(4096), "isRead" boolean NOT NULL DEFAULT false, "fileId" character varying(32), CONSTRAINT "PK_db398fd79dc95d0eb8c30456eaa" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e21cd3646e52ef9c94aaf17c2e" ON "messaging_message" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_5377c307783fce2b6d352e1203" ON "messaging_message" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_cac14a4e3944454a5ce7daa514" ON "messaging_message" ("recipientId") `, + ); + await queryRunner.query( + `CREATE TABLE "signin" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "ip" character varying(128) NOT NULL, "headers" jsonb NOT NULL, "success" boolean NOT NULL, CONSTRAINT "PK_9e96ddc025712616fc492b3b588" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_2c308dbdc50d94dc625670055f" ON "signin" ("userId") `, + ); + await queryRunner.query( + `CREATE TABLE "auth_session" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "token" character varying(128) NOT NULL, "userId" character varying(32), "appId" character varying(32) NOT NULL, CONSTRAINT "PK_19354ed146424a728c1112a8cbf" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_62cb09e1129f6ec024ef66e183" ON "auth_session" ("token") `, + ); + await queryRunner.query( + `CREATE TABLE "follow_request" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "followeeId" character varying(32) NOT NULL, "followerId" character varying(32) NOT NULL, "requestId" character varying(128), "followerHost" character varying(128), "followerInbox" character varying(512), "followerSharedInbox" character varying(512), "followeeHost" character varying(128), "followeeInbox" character varying(512), "followeeSharedInbox" character varying(512), CONSTRAINT "PK_53a9aa3725f7a3deb150b39dbfc" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_12c01c0d1a79f77d9f6c15fadd" ON "follow_request" ("followeeId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_a7fd92dd6dc519e6fb435dd108" ON "follow_request" ("followerId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_d54a512b822fac7ed52800f6b4" ON "follow_request" ("followerId", "followeeId") `, + ); + await queryRunner.query( + `CREATE TABLE "emoji" ("id" character varying(32) NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE, "name" character varying(128) NOT NULL, "host" character varying(128), "url" character varying(512) NOT NULL, "uri" character varying(512), "type" character varying(64), "aliases" character varying(128) array NOT NULL DEFAULT '{}'::varchar[], CONSTRAINT "PK_df74ce05e24999ee01ea0bc50a3" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b37dafc86e9af007e3295c2781" ON "emoji" ("name") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_5900e907bb46516ddf2871327c" ON "emoji" ("host") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_4f4d35e1256c84ae3d1f0eab10" ON "emoji" ("name", "host") `, + ); + await queryRunner.query( + `CREATE TABLE "reversi_game" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "startedAt" TIMESTAMP WITH TIME ZONE, "user1Id" character varying(32) NOT NULL, "user2Id" character varying(32) NOT NULL, "user1Accepted" boolean NOT NULL DEFAULT false, "user2Accepted" boolean NOT NULL DEFAULT false, "black" integer, "isStarted" boolean NOT NULL DEFAULT false, "isEnded" boolean NOT NULL DEFAULT false, "winnerId" character varying(32), "surrendered" character varying(32), "logs" jsonb NOT NULL DEFAULT '[]', "map" character varying(64) array NOT NULL, "bw" character varying(32) NOT NULL, "isLlotheo" boolean NOT NULL DEFAULT false, "canPutEverywhere" boolean NOT NULL DEFAULT false, "loopedBoard" boolean NOT NULL DEFAULT false, "form1" jsonb DEFAULT null, "form2" jsonb DEFAULT null, "crc32" character varying(32), CONSTRAINT "PK_76b30eeba71b1193ad7c5311c3f" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b46ec40746efceac604142be1c" ON "reversi_game" ("createdAt") `, + ); + await queryRunner.query( + `CREATE TABLE "reversi_matching" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "parentId" character varying(32) NOT NULL, "childId" character varying(32) NOT NULL, CONSTRAINT "PK_880bd0afbab232f21c8b9d146cf" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b604d92d6c7aec38627f6eaf16" ON "reversi_matching" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_3b25402709dd9882048c2bbade" ON "reversi_matching" ("parentId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e247b23a3c9b45f89ec1299d06" ON "reversi_matching" ("childId") `, + ); + await queryRunner.query( + `CREATE TABLE "user_note_pining" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, CONSTRAINT "PK_a6a2dad4ae000abce2ea9d9b103" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_bfbc6f79ba4007b4ce5097f08d" ON "user_note_pining" ("userId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_410cd649884b501c02d6e72738" ON "user_note_pining" ("userId", "noteId") `, + ); + await queryRunner.query( + `CREATE TYPE "poll_notevisibility_enum" AS ENUM('public', 'home', 'followers', 'specified')`, + ); + await queryRunner.query( + `CREATE TABLE "poll" ("noteId" character varying(32) NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE, "multiple" boolean NOT NULL, "choices" character varying(128) array NOT NULL DEFAULT '{}'::varchar[], "votes" integer array NOT NULL, "noteVisibility" "poll_notevisibility_enum" NOT NULL, "userId" character varying(32) NOT NULL, "userHost" character varying(128), CONSTRAINT "REL_da851e06d0dfe2ef397d8b1bf1" UNIQUE ("noteId"), CONSTRAINT "PK_da851e06d0dfe2ef397d8b1bf1b" PRIMARY KEY ("noteId"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0610ebcfcfb4a18441a9bcdab2" ON "poll" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_7fa20a12319c7f6dc3aed98c0a" ON "poll" ("userHost") `, + ); + await queryRunner.query( + `CREATE TABLE "user_keypair" ("userId" character varying(32) NOT NULL, "publicKey" character varying(4096) NOT NULL, "privateKey" character varying(4096) NOT NULL, CONSTRAINT "REL_f4853eb41ab722fe05f81cedeb" UNIQUE ("userId"), CONSTRAINT "PK_f4853eb41ab722fe05f81cedeb6" PRIMARY KEY ("userId"))`, + ); + await queryRunner.query( + `CREATE TABLE "user_publickey" ("userId" character varying(32) NOT NULL, "keyId" character varying(256) NOT NULL, "keyPem" character varying(4096) NOT NULL, CONSTRAINT "REL_10c146e4b39b443ede016f6736" UNIQUE ("userId"), CONSTRAINT "PK_10c146e4b39b443ede016f6736d" PRIMARY KEY ("userId"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_171e64971c780ebd23fae140bb" ON "user_publickey" ("keyId") `, + ); + await queryRunner.query( + `CREATE TABLE "user_profile" ("userId" character varying(32) NOT NULL, "location" character varying(128), "birthday" character(10), "description" character varying(1024), "fields" jsonb NOT NULL DEFAULT '[]', "url" character varying(512), "email" character varying(128), "emailVerifyCode" character varying(128), "emailVerified" boolean NOT NULL DEFAULT false, "twoFactorTempSecret" character varying(128), "twoFactorSecret" character varying(128), "twoFactorEnabled" boolean NOT NULL DEFAULT false, "password" character varying(128), "clientData" jsonb NOT NULL DEFAULT '{}', "autoWatch" boolean NOT NULL DEFAULT false, "autoAcceptFollowed" boolean NOT NULL DEFAULT false, "alwaysMarkNsfw" boolean NOT NULL DEFAULT false, "carefulBot" boolean NOT NULL DEFAULT false, "twitter" boolean NOT NULL DEFAULT false, "twitterAccessToken" character varying(64) DEFAULT null, "twitterAccessTokenSecret" character varying(64) DEFAULT null, "twitterUserId" character varying(64) DEFAULT null, "twitterScreenName" character varying(64) DEFAULT null, "github" boolean NOT NULL DEFAULT false, "githubAccessToken" character varying(64) DEFAULT null, "githubId" integer DEFAULT null, "githubLogin" character varying(64) DEFAULT null, "discord" boolean NOT NULL DEFAULT false, "discordAccessToken" character varying(64) DEFAULT null, "discordRefreshToken" character varying(64) DEFAULT null, "discordExpiresDate" integer DEFAULT null, "discordId" character varying(64) DEFAULT null, "discordUsername" character varying(64) DEFAULT null, "discordDiscriminator" character varying(64) DEFAULT null, "userHost" character varying(128), CONSTRAINT "REL_51cb79b5555effaf7d69ba1cff" UNIQUE ("userId"), CONSTRAINT "PK_51cb79b5555effaf7d69ba1cff9" PRIMARY KEY ("userId"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_dce530b98e454793dac5ec2f5a" ON "user_profile" ("userHost") `, + ); + await queryRunner.query( + `CREATE TYPE "__chart__active_users_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `CREATE TABLE "__chart__active_users" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128), "span" "__chart__active_users_span_enum" NOT NULL, "unique" jsonb NOT NULL DEFAULT '{}', "___local_count" bigint NOT NULL, "___remote_count" bigint NOT NULL, CONSTRAINT "PK_317237a9f733b970604a11e314f" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "__chart__drive_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `CREATE TABLE "__chart__drive" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128), "span" "__chart__drive_span_enum" NOT NULL, "unique" jsonb NOT NULL DEFAULT '{}', "___local_totalCount" bigint NOT NULL, "___local_totalSize" bigint NOT NULL, "___local_incCount" bigint NOT NULL, "___local_incSize" bigint NOT NULL, "___local_decCount" bigint NOT NULL, "___local_decSize" bigint NOT NULL, "___remote_totalCount" bigint NOT NULL, "___remote_totalSize" bigint NOT NULL, "___remote_incCount" bigint NOT NULL, "___remote_incSize" bigint NOT NULL, "___remote_decCount" bigint NOT NULL, "___remote_decSize" bigint NOT NULL, CONSTRAINT "PK_f96bc548a765cd4b3b354221ce7" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "__chart__federation_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `CREATE TABLE "__chart__federation" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128), "span" "__chart__federation_span_enum" NOT NULL, "unique" jsonb NOT NULL DEFAULT '{}', "___instance_total" bigint NOT NULL, "___instance_inc" bigint NOT NULL, "___instance_dec" bigint NOT NULL, CONSTRAINT "PK_b39dcd31a0fe1a7757e348e85fd" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "__chart__hashtag_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `CREATE TABLE "__chart__hashtag" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128), "span" "__chart__hashtag_span_enum" NOT NULL, "unique" jsonb NOT NULL DEFAULT '{}', "___local_count" bigint NOT NULL, "___remote_count" bigint NOT NULL, CONSTRAINT "PK_c32f1ea2b44a5d2f7881e37f8f9" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "__chart__instance_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `CREATE TABLE "__chart__instance" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128), "span" "__chart__instance_span_enum" NOT NULL, "unique" jsonb NOT NULL DEFAULT '{}', "___requests_failed" bigint NOT NULL, "___requests_succeeded" bigint NOT NULL, "___requests_received" bigint NOT NULL, "___notes_total" bigint NOT NULL, "___notes_inc" bigint NOT NULL, "___notes_dec" bigint NOT NULL, "___notes_diffs_normal" bigint NOT NULL, "___notes_diffs_reply" bigint NOT NULL, "___notes_diffs_renote" bigint NOT NULL, "___users_total" bigint NOT NULL, "___users_inc" bigint NOT NULL, "___users_dec" bigint NOT NULL, "___following_total" bigint NOT NULL, "___following_inc" bigint NOT NULL, "___following_dec" bigint NOT NULL, "___followers_total" bigint NOT NULL, "___followers_inc" bigint NOT NULL, "___followers_dec" bigint NOT NULL, "___drive_totalFiles" bigint NOT NULL, "___drive_totalUsage" bigint NOT NULL, "___drive_incFiles" bigint NOT NULL, "___drive_incUsage" bigint NOT NULL, "___drive_decFiles" bigint NOT NULL, "___drive_decUsage" bigint NOT NULL, CONSTRAINT "PK_1267c67c7c2d47b4903975f2c00" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "__chart__network_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `CREATE TABLE "__chart__network" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128), "span" "__chart__network_span_enum" NOT NULL, "unique" jsonb NOT NULL DEFAULT '{}', "___incomingRequests" bigint NOT NULL, "___outgoingRequests" bigint NOT NULL, "___totalTime" bigint NOT NULL, "___incomingBytes" bigint NOT NULL, "___outgoingBytes" bigint NOT NULL, CONSTRAINT "PK_bc4290c2e27fad14ef0c1ca93f3" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "__chart__notes_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `CREATE TABLE "__chart__notes" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128), "span" "__chart__notes_span_enum" NOT NULL, "unique" jsonb NOT NULL DEFAULT '{}', "___local_total" bigint NOT NULL, "___local_inc" bigint NOT NULL, "___local_dec" bigint NOT NULL, "___local_diffs_normal" bigint NOT NULL, "___local_diffs_reply" bigint NOT NULL, "___local_diffs_renote" bigint NOT NULL, "___remote_total" bigint NOT NULL, "___remote_inc" bigint NOT NULL, "___remote_dec" bigint NOT NULL, "___remote_diffs_normal" bigint NOT NULL, "___remote_diffs_reply" bigint NOT NULL, "___remote_diffs_renote" bigint NOT NULL, CONSTRAINT "PK_0aec823fa85c7f901bdb3863b14" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "__chart__per_user_drive_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `CREATE TABLE "__chart__per_user_drive" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128), "span" "__chart__per_user_drive_span_enum" NOT NULL, "unique" jsonb NOT NULL DEFAULT '{}', "___totalCount" bigint NOT NULL, "___totalSize" bigint NOT NULL, "___incCount" bigint NOT NULL, "___incSize" bigint NOT NULL, "___decCount" bigint NOT NULL, "___decSize" bigint NOT NULL, CONSTRAINT "PK_d0ef23d24d666e1a44a0cd3d208" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "__chart__per_user_following_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `CREATE TABLE "__chart__per_user_following" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128), "span" "__chart__per_user_following_span_enum" NOT NULL, "unique" jsonb NOT NULL DEFAULT '{}', "___local_followings_total" bigint NOT NULL, "___local_followings_inc" bigint NOT NULL, "___local_followings_dec" bigint NOT NULL, "___local_followers_total" bigint NOT NULL, "___local_followers_inc" bigint NOT NULL, "___local_followers_dec" bigint NOT NULL, "___remote_followings_total" bigint NOT NULL, "___remote_followings_inc" bigint NOT NULL, "___remote_followings_dec" bigint NOT NULL, "___remote_followers_total" bigint NOT NULL, "___remote_followers_inc" bigint NOT NULL, "___remote_followers_dec" bigint NOT NULL, CONSTRAINT "PK_85bb1b540363a29c2fec83bd907" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "__chart__per_user_notes_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `CREATE TABLE "__chart__per_user_notes" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128), "span" "__chart__per_user_notes_span_enum" NOT NULL, "unique" jsonb NOT NULL DEFAULT '{}', "___total" bigint NOT NULL, "___inc" bigint NOT NULL, "___dec" bigint NOT NULL, "___diffs_normal" bigint NOT NULL, "___diffs_reply" bigint NOT NULL, "___diffs_renote" bigint NOT NULL, CONSTRAINT "PK_334acf6e915af2f29edc11b8e50" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "__chart__per_user_reaction_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `CREATE TABLE "__chart__per_user_reaction" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128), "span" "__chart__per_user_reaction_span_enum" NOT NULL, "unique" jsonb NOT NULL DEFAULT '{}', "___local_count" bigint NOT NULL, "___remote_count" bigint NOT NULL, CONSTRAINT "PK_984f54dae441e65b633e8d27a7f" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "__chart__test_grouped_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `CREATE TABLE "__chart__test_grouped" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128), "span" "__chart__test_grouped_span_enum" NOT NULL, "unique" jsonb NOT NULL DEFAULT '{}', "___foo_total" bigint NOT NULL, "___foo_inc" bigint NOT NULL, "___foo_dec" bigint NOT NULL, CONSTRAINT "PK_f4a2b175d308695af30d4293272" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "__chart__test_unique_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `CREATE TABLE "__chart__test_unique" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128), "span" "__chart__test_unique_span_enum" NOT NULL, "unique" jsonb NOT NULL DEFAULT '{}', "___foo" bigint NOT NULL, CONSTRAINT "PK_409bac9c97cc612d8500012319d" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "__chart__test_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `CREATE TABLE "__chart__test" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128), "span" "__chart__test_span_enum" NOT NULL, "unique" jsonb NOT NULL DEFAULT '{}', "___foo_total" bigint NOT NULL, "___foo_inc" bigint NOT NULL, "___foo_dec" bigint NOT NULL, CONSTRAINT "PK_b4bc31dffbd1b785276a3ecfc1e" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "__chart__users_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `CREATE TABLE "__chart__users" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128), "span" "__chart__users_span_enum" NOT NULL, "unique" jsonb NOT NULL DEFAULT '{}', "___local_total" bigint NOT NULL, "___local_inc" bigint NOT NULL, "___local_dec" bigint NOT NULL, "___remote_total" bigint NOT NULL, "___remote_inc" bigint NOT NULL, "___remote_dec" bigint NOT NULL, CONSTRAINT "PK_4dfcf2c78d03524b9eb2c99d328" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "drive_folder" ADD CONSTRAINT "FK_f4fc06e49c0171c85f1c48060d2" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "drive_folder" ADD CONSTRAINT "FK_00ceffb0cdc238b3233294f08f2" FOREIGN KEY ("parentId") REFERENCES "drive_folder"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "drive_file" ADD CONSTRAINT "FK_860fa6f6c7df5bb887249fba22e" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "drive_file" ADD CONSTRAINT "FK_bb90d1956dafc4068c28aa7560a" FOREIGN KEY ("folderId") REFERENCES "drive_folder"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "user" ADD CONSTRAINT "FK_58f5c71eaab331645112cf8cfa5" FOREIGN KEY ("avatarId") REFERENCES "drive_file"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "user" ADD CONSTRAINT "FK_afc64b53f8db3707ceb34eb28e2" FOREIGN KEY ("bannerId") REFERENCES "drive_file"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "app" ADD CONSTRAINT "FK_3f5b0899ef90527a3462d7c2cb3" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "access_token" ADD CONSTRAINT "FK_9949557d0e1b2c19e5344c171e9" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "access_token" ADD CONSTRAINT "FK_a3ff16c90cc87a82a0b5959e560" FOREIGN KEY ("appId") REFERENCES "app"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "note" ADD CONSTRAINT "FK_17cb3553c700a4985dff5a30ff5" FOREIGN KEY ("replyId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "note" ADD CONSTRAINT "FK_52ccc804d7c69037d558bac4c96" FOREIGN KEY ("renoteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "note" ADD CONSTRAINT "FK_ec5c201576192ba8904c345c5cc" FOREIGN KEY ("appId") REFERENCES "app"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "note" ADD CONSTRAINT "FK_5b87d9d19127bd5d92026017a7b" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "poll_vote" ADD CONSTRAINT "FK_66d2bd2ee31d14bcc23069a89f8" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "poll_vote" ADD CONSTRAINT "FK_aecfbd5ef60374918e63ee95fa7" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "note_reaction" ADD CONSTRAINT "FK_13761f64257f40c5636d0ff95ee" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "note_reaction" ADD CONSTRAINT "FK_45145e4953780f3cd5656f0ea6a" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "note_watching" ADD CONSTRAINT "FK_b0134ec406e8d09a540f8182888" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "note_watching" ADD CONSTRAINT "FK_03e7028ab8388a3f5e3ce2a8619" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "note_unread" ADD CONSTRAINT "FK_56b0166d34ddae49d8ef7610bb9" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "note_unread" ADD CONSTRAINT "FK_e637cba4dc4410218c4251260e4" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "notification" ADD CONSTRAINT "FK_3c601b70a1066d2c8b517094cb9" FOREIGN KEY ("notifieeId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "notification" ADD CONSTRAINT "FK_3b4e96eec8d36a8bbb9d02aa710" FOREIGN KEY ("notifierId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "notification" ADD CONSTRAINT "FK_769cb6b73a1efe22ddf733ac453" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "following" ADD CONSTRAINT "FK_24e0042143a18157b234df186c3" FOREIGN KEY ("followeeId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "following" ADD CONSTRAINT "FK_6516c5a6f3c015b4eed39978be5" FOREIGN KEY ("followerId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "muting" ADD CONSTRAINT "FK_ec96b4fed9dae517e0dbbe0675c" FOREIGN KEY ("muteeId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "muting" ADD CONSTRAINT "FK_93060675b4a79a577f31d260c67" FOREIGN KEY ("muterId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "sw_subscription" ADD CONSTRAINT "FK_97754ca6f2baff9b4abb7f853dd" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "blocking" ADD CONSTRAINT "FK_2cd4a2743a99671308f5417759e" FOREIGN KEY ("blockeeId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "blocking" ADD CONSTRAINT "FK_0627125f1a8a42c9a1929edb552" FOREIGN KEY ("blockerId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "user_list" ADD CONSTRAINT "FK_b7fcefbdd1c18dce86687531f99" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "user_list_joining" ADD CONSTRAINT "FK_d844bfc6f3f523a05189076efaa" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "user_list_joining" ADD CONSTRAINT "FK_605472305f26818cc93d1baaa74" FOREIGN KEY ("userListId") REFERENCES "user_list"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "note_favorite" ADD CONSTRAINT "FK_47f4b1892f5d6ba8efb3057d81a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "note_favorite" ADD CONSTRAINT "FK_0e00498f180193423c992bc4370" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_d049123c413e68ca52abe734203" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_04cc96756f89d0b7f9473e8cdf3" FOREIGN KEY ("reporterId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "messaging_message" ADD CONSTRAINT "FK_5377c307783fce2b6d352e1203b" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "messaging_message" ADD CONSTRAINT "FK_cac14a4e3944454a5ce7daa5142" FOREIGN KEY ("recipientId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "messaging_message" ADD CONSTRAINT "FK_535def119223ac05ad3fa9ef64b" FOREIGN KEY ("fileId") REFERENCES "drive_file"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "signin" ADD CONSTRAINT "FK_2c308dbdc50d94dc625670055f7" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "auth_session" ADD CONSTRAINT "FK_c072b729d71697f959bde66ade0" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "auth_session" ADD CONSTRAINT "FK_dbe037d4bddd17b03a1dc778dee" FOREIGN KEY ("appId") REFERENCES "app"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "follow_request" ADD CONSTRAINT "FK_12c01c0d1a79f77d9f6c15fadd2" FOREIGN KEY ("followeeId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "follow_request" ADD CONSTRAINT "FK_a7fd92dd6dc519e6fb435dd108f" FOREIGN KEY ("followerId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "reversi_game" ADD CONSTRAINT "FK_f7467510c60a45ce5aca6292743" FOREIGN KEY ("user1Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "reversi_game" ADD CONSTRAINT "FK_6649a4e8c5d5cf32fb03b5da9f6" FOREIGN KEY ("user2Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "reversi_matching" ADD CONSTRAINT "FK_3b25402709dd9882048c2bbade0" FOREIGN KEY ("parentId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "reversi_matching" ADD CONSTRAINT "FK_e247b23a3c9b45f89ec1299d066" FOREIGN KEY ("childId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "user_note_pining" ADD CONSTRAINT "FK_bfbc6f79ba4007b4ce5097f08d6" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "user_note_pining" ADD CONSTRAINT "FK_68881008f7c3588ad7ecae471cf" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "poll" ADD CONSTRAINT "FK_da851e06d0dfe2ef397d8b1bf1b" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "user_keypair" ADD CONSTRAINT "FK_f4853eb41ab722fe05f81cedeb6" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "user_publickey" ADD CONSTRAINT "FK_10c146e4b39b443ede016f6736d" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD CONSTRAINT "FK_51cb79b5555effaf7d69ba1cff9" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" DROP CONSTRAINT "FK_51cb79b5555effaf7d69ba1cff9"`, + ); + await queryRunner.query( + `ALTER TABLE "user_publickey" DROP CONSTRAINT "FK_10c146e4b39b443ede016f6736d"`, + ); + await queryRunner.query( + `ALTER TABLE "user_keypair" DROP CONSTRAINT "FK_f4853eb41ab722fe05f81cedeb6"`, + ); + await queryRunner.query( + `ALTER TABLE "poll" DROP CONSTRAINT "FK_da851e06d0dfe2ef397d8b1bf1b"`, + ); + await queryRunner.query( + `ALTER TABLE "user_note_pining" DROP CONSTRAINT "FK_68881008f7c3588ad7ecae471cf"`, + ); + await queryRunner.query( + `ALTER TABLE "user_note_pining" DROP CONSTRAINT "FK_bfbc6f79ba4007b4ce5097f08d6"`, + ); + await queryRunner.query( + `ALTER TABLE "reversi_matching" DROP CONSTRAINT "FK_e247b23a3c9b45f89ec1299d066"`, + ); + await queryRunner.query( + `ALTER TABLE "reversi_matching" DROP CONSTRAINT "FK_3b25402709dd9882048c2bbade0"`, + ); + await queryRunner.query( + `ALTER TABLE "reversi_game" DROP CONSTRAINT "FK_6649a4e8c5d5cf32fb03b5da9f6"`, + ); + await queryRunner.query( + `ALTER TABLE "reversi_game" DROP CONSTRAINT "FK_f7467510c60a45ce5aca6292743"`, + ); + await queryRunner.query( + `ALTER TABLE "follow_request" DROP CONSTRAINT "FK_a7fd92dd6dc519e6fb435dd108f"`, + ); + await queryRunner.query( + `ALTER TABLE "follow_request" DROP CONSTRAINT "FK_12c01c0d1a79f77d9f6c15fadd2"`, + ); + await queryRunner.query( + `ALTER TABLE "auth_session" DROP CONSTRAINT "FK_dbe037d4bddd17b03a1dc778dee"`, + ); + await queryRunner.query( + `ALTER TABLE "auth_session" DROP CONSTRAINT "FK_c072b729d71697f959bde66ade0"`, + ); + await queryRunner.query( + `ALTER TABLE "signin" DROP CONSTRAINT "FK_2c308dbdc50d94dc625670055f7"`, + ); + await queryRunner.query( + `ALTER TABLE "messaging_message" DROP CONSTRAINT "FK_535def119223ac05ad3fa9ef64b"`, + ); + await queryRunner.query( + `ALTER TABLE "messaging_message" DROP CONSTRAINT "FK_cac14a4e3944454a5ce7daa5142"`, + ); + await queryRunner.query( + `ALTER TABLE "messaging_message" DROP CONSTRAINT "FK_5377c307783fce2b6d352e1203b"`, + ); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_04cc96756f89d0b7f9473e8cdf3"`, + ); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_d049123c413e68ca52abe734203"`, + ); + await queryRunner.query( + `ALTER TABLE "note_favorite" DROP CONSTRAINT "FK_0e00498f180193423c992bc4370"`, + ); + await queryRunner.query( + `ALTER TABLE "note_favorite" DROP CONSTRAINT "FK_47f4b1892f5d6ba8efb3057d81a"`, + ); + await queryRunner.query( + `ALTER TABLE "user_list_joining" DROP CONSTRAINT "FK_605472305f26818cc93d1baaa74"`, + ); + await queryRunner.query( + `ALTER TABLE "user_list_joining" DROP CONSTRAINT "FK_d844bfc6f3f523a05189076efaa"`, + ); + await queryRunner.query( + `ALTER TABLE "user_list" DROP CONSTRAINT "FK_b7fcefbdd1c18dce86687531f99"`, + ); + await queryRunner.query( + `ALTER TABLE "blocking" DROP CONSTRAINT "FK_0627125f1a8a42c9a1929edb552"`, + ); + await queryRunner.query( + `ALTER TABLE "blocking" DROP CONSTRAINT "FK_2cd4a2743a99671308f5417759e"`, + ); + await queryRunner.query( + `ALTER TABLE "sw_subscription" DROP CONSTRAINT "FK_97754ca6f2baff9b4abb7f853dd"`, + ); + await queryRunner.query( + `ALTER TABLE "muting" DROP CONSTRAINT "FK_93060675b4a79a577f31d260c67"`, + ); + await queryRunner.query( + `ALTER TABLE "muting" DROP CONSTRAINT "FK_ec96b4fed9dae517e0dbbe0675c"`, + ); + await queryRunner.query( + `ALTER TABLE "following" DROP CONSTRAINT "FK_6516c5a6f3c015b4eed39978be5"`, + ); + await queryRunner.query( + `ALTER TABLE "following" DROP CONSTRAINT "FK_24e0042143a18157b234df186c3"`, + ); + await queryRunner.query( + `ALTER TABLE "notification" DROP CONSTRAINT "FK_769cb6b73a1efe22ddf733ac453"`, + ); + await queryRunner.query( + `ALTER TABLE "notification" DROP CONSTRAINT "FK_3b4e96eec8d36a8bbb9d02aa710"`, + ); + await queryRunner.query( + `ALTER TABLE "notification" DROP CONSTRAINT "FK_3c601b70a1066d2c8b517094cb9"`, + ); + await queryRunner.query( + `ALTER TABLE "note_unread" DROP CONSTRAINT "FK_e637cba4dc4410218c4251260e4"`, + ); + await queryRunner.query( + `ALTER TABLE "note_unread" DROP CONSTRAINT "FK_56b0166d34ddae49d8ef7610bb9"`, + ); + await queryRunner.query( + `ALTER TABLE "note_watching" DROP CONSTRAINT "FK_03e7028ab8388a3f5e3ce2a8619"`, + ); + await queryRunner.query( + `ALTER TABLE "note_watching" DROP CONSTRAINT "FK_b0134ec406e8d09a540f8182888"`, + ); + await queryRunner.query( + `ALTER TABLE "note_reaction" DROP CONSTRAINT "FK_45145e4953780f3cd5656f0ea6a"`, + ); + await queryRunner.query( + `ALTER TABLE "note_reaction" DROP CONSTRAINT "FK_13761f64257f40c5636d0ff95ee"`, + ); + await queryRunner.query( + `ALTER TABLE "poll_vote" DROP CONSTRAINT "FK_aecfbd5ef60374918e63ee95fa7"`, + ); + await queryRunner.query( + `ALTER TABLE "poll_vote" DROP CONSTRAINT "FK_66d2bd2ee31d14bcc23069a89f8"`, + ); + await queryRunner.query( + `ALTER TABLE "note" DROP CONSTRAINT "FK_5b87d9d19127bd5d92026017a7b"`, + ); + await queryRunner.query( + `ALTER TABLE "note" DROP CONSTRAINT "FK_ec5c201576192ba8904c345c5cc"`, + ); + await queryRunner.query( + `ALTER TABLE "note" DROP CONSTRAINT "FK_52ccc804d7c69037d558bac4c96"`, + ); + await queryRunner.query( + `ALTER TABLE "note" DROP CONSTRAINT "FK_17cb3553c700a4985dff5a30ff5"`, + ); + await queryRunner.query( + `ALTER TABLE "access_token" DROP CONSTRAINT "FK_a3ff16c90cc87a82a0b5959e560"`, + ); + await queryRunner.query( + `ALTER TABLE "access_token" DROP CONSTRAINT "FK_9949557d0e1b2c19e5344c171e9"`, + ); + await queryRunner.query( + `ALTER TABLE "app" DROP CONSTRAINT "FK_3f5b0899ef90527a3462d7c2cb3"`, + ); + await queryRunner.query( + `ALTER TABLE "user" DROP CONSTRAINT "FK_afc64b53f8db3707ceb34eb28e2"`, + ); + await queryRunner.query( + `ALTER TABLE "user" DROP CONSTRAINT "FK_58f5c71eaab331645112cf8cfa5"`, + ); + await queryRunner.query( + `ALTER TABLE "drive_file" DROP CONSTRAINT "FK_bb90d1956dafc4068c28aa7560a"`, + ); + await queryRunner.query( + `ALTER TABLE "drive_file" DROP CONSTRAINT "FK_860fa6f6c7df5bb887249fba22e"`, + ); + await queryRunner.query( + `ALTER TABLE "drive_folder" DROP CONSTRAINT "FK_00ceffb0cdc238b3233294f08f2"`, + ); + await queryRunner.query( + `ALTER TABLE "drive_folder" DROP CONSTRAINT "FK_f4fc06e49c0171c85f1c48060d2"`, + ); + await queryRunner.query(`DROP TABLE "__chart__users"`); + await queryRunner.query(`DROP TYPE "__chart__users_span_enum"`); + await queryRunner.query(`DROP TABLE "__chart__test"`); + await queryRunner.query(`DROP TYPE "__chart__test_span_enum"`); + await queryRunner.query(`DROP TABLE "__chart__test_unique"`); + await queryRunner.query(`DROP TYPE "__chart__test_unique_span_enum"`); + await queryRunner.query(`DROP TABLE "__chart__test_grouped"`); + await queryRunner.query(`DROP TYPE "__chart__test_grouped_span_enum"`); + await queryRunner.query(`DROP TABLE "__chart__per_user_reaction"`); + await queryRunner.query(`DROP TYPE "__chart__per_user_reaction_span_enum"`); + await queryRunner.query(`DROP TABLE "__chart__per_user_notes"`); + await queryRunner.query(`DROP TYPE "__chart__per_user_notes_span_enum"`); + await queryRunner.query(`DROP TABLE "__chart__per_user_following"`); + await queryRunner.query( + `DROP TYPE "__chart__per_user_following_span_enum"`, + ); + await queryRunner.query(`DROP TABLE "__chart__per_user_drive"`); + await queryRunner.query(`DROP TYPE "__chart__per_user_drive_span_enum"`); + await queryRunner.query(`DROP TABLE "__chart__notes"`); + await queryRunner.query(`DROP TYPE "__chart__notes_span_enum"`); + await queryRunner.query(`DROP TABLE "__chart__network"`); + await queryRunner.query(`DROP TYPE "__chart__network_span_enum"`); + await queryRunner.query(`DROP TABLE "__chart__instance"`); + await queryRunner.query(`DROP TYPE "__chart__instance_span_enum"`); + await queryRunner.query(`DROP TABLE "__chart__hashtag"`); + await queryRunner.query(`DROP TYPE "__chart__hashtag_span_enum"`); + await queryRunner.query(`DROP TABLE "__chart__federation"`); + await queryRunner.query(`DROP TYPE "__chart__federation_span_enum"`); + await queryRunner.query(`DROP TABLE "__chart__drive"`); + await queryRunner.query(`DROP TYPE "__chart__drive_span_enum"`); + await queryRunner.query(`DROP TABLE "__chart__active_users"`); + await queryRunner.query(`DROP TYPE "__chart__active_users_span_enum"`); + await queryRunner.query(`DROP INDEX "IDX_dce530b98e454793dac5ec2f5a"`); + await queryRunner.query(`DROP TABLE "user_profile"`); + await queryRunner.query(`DROP INDEX "IDX_171e64971c780ebd23fae140bb"`); + await queryRunner.query(`DROP TABLE "user_publickey"`); + await queryRunner.query(`DROP TABLE "user_keypair"`); + await queryRunner.query(`DROP INDEX "IDX_7fa20a12319c7f6dc3aed98c0a"`); + await queryRunner.query(`DROP INDEX "IDX_0610ebcfcfb4a18441a9bcdab2"`); + await queryRunner.query(`DROP TABLE "poll"`); + await queryRunner.query(`DROP TYPE "poll_notevisibility_enum"`); + await queryRunner.query(`DROP INDEX "IDX_410cd649884b501c02d6e72738"`); + await queryRunner.query(`DROP INDEX "IDX_bfbc6f79ba4007b4ce5097f08d"`); + await queryRunner.query(`DROP TABLE "user_note_pining"`); + await queryRunner.query(`DROP INDEX "IDX_e247b23a3c9b45f89ec1299d06"`); + await queryRunner.query(`DROP INDEX "IDX_3b25402709dd9882048c2bbade"`); + await queryRunner.query(`DROP INDEX "IDX_b604d92d6c7aec38627f6eaf16"`); + await queryRunner.query(`DROP TABLE "reversi_matching"`); + await queryRunner.query(`DROP INDEX "IDX_b46ec40746efceac604142be1c"`); + await queryRunner.query(`DROP TABLE "reversi_game"`); + await queryRunner.query(`DROP INDEX "IDX_4f4d35e1256c84ae3d1f0eab10"`); + await queryRunner.query(`DROP INDEX "IDX_5900e907bb46516ddf2871327c"`); + await queryRunner.query(`DROP INDEX "IDX_b37dafc86e9af007e3295c2781"`); + await queryRunner.query(`DROP TABLE "emoji"`); + await queryRunner.query(`DROP INDEX "IDX_d54a512b822fac7ed52800f6b4"`); + await queryRunner.query(`DROP INDEX "IDX_a7fd92dd6dc519e6fb435dd108"`); + await queryRunner.query(`DROP INDEX "IDX_12c01c0d1a79f77d9f6c15fadd"`); + await queryRunner.query(`DROP TABLE "follow_request"`); + await queryRunner.query(`DROP INDEX "IDX_62cb09e1129f6ec024ef66e183"`); + await queryRunner.query(`DROP TABLE "auth_session"`); + await queryRunner.query(`DROP INDEX "IDX_2c308dbdc50d94dc625670055f"`); + await queryRunner.query(`DROP TABLE "signin"`); + await queryRunner.query(`DROP INDEX "IDX_cac14a4e3944454a5ce7daa514"`); + await queryRunner.query(`DROP INDEX "IDX_5377c307783fce2b6d352e1203"`); + await queryRunner.query(`DROP INDEX "IDX_e21cd3646e52ef9c94aaf17c2e"`); + await queryRunner.query(`DROP TABLE "messaging_message"`); + await queryRunner.query(`DROP INDEX "IDX_0ff69e8dfa9fe31bb4a4660f59"`); + await queryRunner.query(`DROP TABLE "registration_ticket"`); + await queryRunner.query(`DROP INDEX "IDX_5cd442c3b2e74fdd99dae20243"`); + await queryRunner.query(`DROP INDEX "IDX_04cc96756f89d0b7f9473e8cdf"`); + await queryRunner.query(`DROP INDEX "IDX_d049123c413e68ca52abe73420"`); + await queryRunner.query(`DROP INDEX "IDX_db2098070b2b5a523c58181f74"`); + await queryRunner.query(`DROP TABLE "abuse_user_report"`); + await queryRunner.query(`DROP INDEX "IDX_0f4fb9ad355f3effff221ef245"`); + await queryRunner.query(`DROP INDEX "IDX_47f4b1892f5d6ba8efb3057d81"`); + await queryRunner.query(`DROP TABLE "note_favorite"`); + await queryRunner.query(`DROP INDEX "IDX_0b03cbcd7e6a7ce068efa8ecc2"`); + await queryRunner.query(`DROP INDEX "IDX_0c44bf4f680964145f2a68a341"`); + await queryRunner.query(`DROP INDEX "IDX_d57f9030cd3af7f63ffb1c267c"`); + await queryRunner.query(`DROP INDEX "IDX_4c02d38a976c3ae132228c6fce"`); + await queryRunner.query(`DROP INDEX "IDX_0e206cec573f1edff4a3062923"`); + await queryRunner.query(`DROP INDEX "IDX_2710a55f826ee236ea1a62698f"`); + await queryRunner.query(`DROP INDEX "IDX_347fec870eafea7b26c8a73bac"`); + await queryRunner.query(`DROP TABLE "hashtag"`); + await queryRunner.query(`DROP INDEX "IDX_605472305f26818cc93d1baaa7"`); + await queryRunner.query(`DROP INDEX "IDX_d844bfc6f3f523a05189076efa"`); + await queryRunner.query(`DROP TABLE "user_list_joining"`); + await queryRunner.query(`DROP INDEX "IDX_b7fcefbdd1c18dce86687531f9"`); + await queryRunner.query(`DROP TABLE "user_list"`); + await queryRunner.query(`DROP INDEX "IDX_98a1bc5cb30dfd159de056549f"`); + await queryRunner.query(`DROP INDEX "IDX_0627125f1a8a42c9a1929edb55"`); + await queryRunner.query(`DROP INDEX "IDX_2cd4a2743a99671308f5417759"`); + await queryRunner.query(`DROP INDEX "IDX_b9a354f7941c1e779f3b33aea6"`); + await queryRunner.query(`DROP TABLE "blocking"`); + await queryRunner.query(`DROP INDEX "IDX_97754ca6f2baff9b4abb7f853d"`); + await queryRunner.query(`DROP TABLE "sw_subscription"`); + await queryRunner.query(`DROP INDEX "IDX_1eb9d9824a630321a29fd3b290"`); + await queryRunner.query(`DROP INDEX "IDX_93060675b4a79a577f31d260c6"`); + await queryRunner.query(`DROP INDEX "IDX_ec96b4fed9dae517e0dbbe0675"`); + await queryRunner.query(`DROP INDEX "IDX_f86d57fbca33c7a4e6897490cc"`); + await queryRunner.query(`DROP TABLE "muting"`); + await queryRunner.query(`DROP INDEX "IDX_8d5afc98982185799b160e10eb"`); + await queryRunner.query(`DROP INDEX "IDX_2cd3b2a6b4cf0b910b260afe08"`); + await queryRunner.query(`DROP TABLE "instance"`); + await queryRunner.query(`DROP INDEX "IDX_307be5f1d1252e0388662acb96"`); + await queryRunner.query(`DROP INDEX "IDX_6516c5a6f3c015b4eed39978be"`); + await queryRunner.query(`DROP INDEX "IDX_24e0042143a18157b234df186c"`); + await queryRunner.query(`DROP INDEX "IDX_582f8fab771a9040a12961f3e7"`); + await queryRunner.query(`DROP TABLE "following"`); + await queryRunner.query(`DROP TABLE "meta"`); + await queryRunner.query(`DROP INDEX "IDX_3c601b70a1066d2c8b517094cb"`); + await queryRunner.query(`DROP INDEX "IDX_b11a5e627c41d4dc3170f1d370"`); + await queryRunner.query(`DROP TABLE "notification"`); + await queryRunner.query(`DROP INDEX "IDX_d908433a4953cc13216cd9c274"`); + await queryRunner.query(`DROP INDEX "IDX_e637cba4dc4410218c4251260e"`); + await queryRunner.query(`DROP INDEX "IDX_56b0166d34ddae49d8ef7610bb"`); + await queryRunner.query(`DROP TABLE "note_unread"`); + await queryRunner.query(`DROP INDEX "IDX_a42c93c69989ce1d09959df4cf"`); + await queryRunner.query(`DROP INDEX "IDX_44499765eec6b5489d72c4253b"`); + await queryRunner.query(`DROP INDEX "IDX_03e7028ab8388a3f5e3ce2a861"`); + await queryRunner.query(`DROP INDEX "IDX_b0134ec406e8d09a540f818288"`); + await queryRunner.query(`DROP INDEX "IDX_318cdf42a9cfc11f479bd802bb"`); + await queryRunner.query(`DROP TABLE "note_watching"`); + await queryRunner.query(`DROP INDEX "IDX_ad0c221b25672daf2df320a817"`); + await queryRunner.query(`DROP INDEX "IDX_45145e4953780f3cd5656f0ea6"`); + await queryRunner.query(`DROP INDEX "IDX_13761f64257f40c5636d0ff95e"`); + await queryRunner.query(`DROP INDEX "IDX_01f4581f114e0ebd2bbb876f0b"`); + await queryRunner.query(`DROP TABLE "note_reaction"`); + await queryRunner.query(`DROP INDEX "IDX_50bd7164c5b78f1f4a42c4d21f"`); + await queryRunner.query(`DROP INDEX "IDX_aecfbd5ef60374918e63ee95fa"`); + await queryRunner.query(`DROP INDEX "IDX_66d2bd2ee31d14bcc23069a89f"`); + await queryRunner.query(`DROP INDEX "IDX_0fb627e1c2f753262a74f0562d"`); + await queryRunner.query(`DROP TABLE "poll_vote"`); + await queryRunner.query(`DROP INDEX "IDX_7125a826ab192eb27e11d358a5"`); + await queryRunner.query(`DROP INDEX "IDX_88937d94d7443d9a99a76fa5c0"`); + await queryRunner.query(`DROP INDEX "IDX_54ebcb6d27222913b908d56fd8"`); + await queryRunner.query(`DROP INDEX "IDX_796a8c03959361f97dc2be1d5c"`); + await queryRunner.query(`DROP INDEX "IDX_25dfc71b0369b003a4cd434d0b"`); + await queryRunner.query(`DROP INDEX "IDX_51c063b6a133a9cb87145450f5"`); + await queryRunner.query(`DROP INDEX "IDX_153536c67d05e9adb24e99fc2b"`); + await queryRunner.query(`DROP INDEX "IDX_5b87d9d19127bd5d92026017a7"`); + await queryRunner.query(`DROP INDEX "IDX_52ccc804d7c69037d558bac4c9"`); + await queryRunner.query(`DROP INDEX "IDX_17cb3553c700a4985dff5a30ff"`); + await queryRunner.query(`DROP INDEX "IDX_e7c0567f5261063592f022e9b5"`); + await queryRunner.query(`DROP TABLE "note"`); + await queryRunner.query(`DROP TYPE "note_visibility_enum"`); + await queryRunner.query(`DROP INDEX "IDX_9949557d0e1b2c19e5344c171e"`); + await queryRunner.query(`DROP INDEX "IDX_64c327441248bae40f7d92f34f"`); + await queryRunner.query(`DROP INDEX "IDX_70ba8f6af34bc924fc9e12adb8"`); + await queryRunner.query(`DROP TABLE "access_token"`); + await queryRunner.query(`DROP INDEX "IDX_f49922d511d666848f250663c4"`); + await queryRunner.query(`DROP INDEX "IDX_3f5b0899ef90527a3462d7c2cb"`); + await queryRunner.query(`DROP INDEX "IDX_048a757923ed8b157e9895da53"`); + await queryRunner.query(`DROP TABLE "app"`); + await queryRunner.query(`DROP INDEX "IDX_5deb01ae162d1d70b80d064c27"`); + await queryRunner.query(`DROP INDEX "IDX_a854e557b1b14814750c7c7b0c"`); + await queryRunner.query(`DROP INDEX "IDX_be623adaa4c566baf5d29ce0c8"`); + await queryRunner.query(`DROP INDEX "IDX_3252a5df8d5bbd16b281f7799e"`); + await queryRunner.query(`DROP INDEX "IDX_fa99d777623947a5b05f394cae"`); + await queryRunner.query(`DROP INDEX "IDX_a27b942a0d6dcff90e3ee9b5e8"`); + await queryRunner.query(`DROP INDEX "IDX_80ca6e6ef65fb9ef34ea8c90f4"`); + await queryRunner.query(`DROP INDEX "IDX_e11e649824a45d8ed01d597fd9"`); + await queryRunner.query(`DROP TABLE "user"`); + await queryRunner.query(`DROP INDEX "IDX_bb90d1956dafc4068c28aa7560"`); + await queryRunner.query(`DROP INDEX "IDX_e5848eac4940934e23dbc17581"`); + await queryRunner.query(`DROP INDEX "IDX_c55b2b7c284d9fef98026fc88e"`); + await queryRunner.query(`DROP INDEX "IDX_e74022ce9a074b3866f70e0d27"`); + await queryRunner.query(`DROP INDEX "IDX_d85a184c2540d2deba33daf642"`); + await queryRunner.query(`DROP INDEX "IDX_a40b8df8c989d7db937ea27cf6"`); + await queryRunner.query(`DROP INDEX "IDX_37bb9a1b4585f8a3beb24c62d6"`); + await queryRunner.query(`DROP INDEX "IDX_92779627994ac79277f070c91e"`); + await queryRunner.query(`DROP INDEX "IDX_860fa6f6c7df5bb887249fba22"`); + await queryRunner.query(`DROP INDEX "IDX_c8dfad3b72196dd1d6b5db168a"`); + await queryRunner.query(`DROP TABLE "drive_file"`); + await queryRunner.query(`DROP INDEX "IDX_00ceffb0cdc238b3233294f08f"`); + await queryRunner.query(`DROP INDEX "IDX_f4fc06e49c0171c85f1c48060d"`); + await queryRunner.query(`DROP INDEX "IDX_02878d441ceae15ce060b73daf"`); + await queryRunner.query(`DROP TABLE "drive_folder"`); + await queryRunner.query(`DROP INDEX "IDX_584b536b49e53ac81beb39a177"`); + await queryRunner.query(`DROP INDEX "IDX_8cb40cfc8f3c28261e6f887b03"`); + await queryRunner.query(`DROP INDEX "IDX_8e4eb51a35d81b64dda28eed0a"`); + await queryRunner.query(`DROP TABLE "log"`); + await queryRunner.query(`DROP TYPE "log_level_enum"`); + } +} diff --git a/packages/backend/src/migration/1556348509290-Pages.ts b/packages/backend/src/migration/1556348509290-Pages.ts new file mode 100644 index 0000000..22f6262 --- /dev/null +++ b/packages/backend/src/migration/1556348509290-Pages.ts @@ -0,0 +1,51 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class Pages1556348509290 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "page_visibility_enum" AS ENUM('public', 'followers', 'specified')`, + ); + await queryRunner.query( + `CREATE TABLE "page" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "title" character varying(256) NOT NULL, "name" character varying(256) NOT NULL, "summary" character varying(256), "alignCenter" boolean NOT NULL, "font" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "eyeCatchingImageId" character varying(32), "content" jsonb NOT NULL DEFAULT '[]', "variables" jsonb NOT NULL DEFAULT '[]', "visibility" "page_visibility_enum" NOT NULL, "visibleUserIds" character varying(32) array NOT NULL DEFAULT '{}'::varchar[], CONSTRAINT "PK_742f4117e065c5b6ad21b37ba1f" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_fbb4297c927a9b85e9cefa2eb1" ON "page" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_af639b066dfbca78b01a920f8a" ON "page" ("updatedAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b82c19c08afb292de4600d99e4" ON "page" ("name") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_ae1d917992dd0c9d9bbdad06c4" ON "page" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_90148bbc2bf0854428786bfc15" ON "page" ("visibleUserIds") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_2133ef8317e4bdb839c0dcbf13" ON "page" ("userId", "name") `, + ); + await queryRunner.query( + `ALTER TABLE "page" ADD CONSTRAINT "FK_ae1d917992dd0c9d9bbdad06c4a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "page" ADD CONSTRAINT "FK_3126dd7c502c9e4d7597ef7ef10" FOREIGN KEY ("eyeCatchingImageId") REFERENCES "drive_file"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "page" DROP CONSTRAINT "FK_3126dd7c502c9e4d7597ef7ef10"`, + ); + await queryRunner.query( + `ALTER TABLE "page" DROP CONSTRAINT "FK_ae1d917992dd0c9d9bbdad06c4a"`, + ); + await queryRunner.query(`DROP INDEX "IDX_2133ef8317e4bdb839c0dcbf13"`); + await queryRunner.query(`DROP INDEX "IDX_90148bbc2bf0854428786bfc15"`); + await queryRunner.query(`DROP INDEX "IDX_ae1d917992dd0c9d9bbdad06c4"`); + await queryRunner.query(`DROP INDEX "IDX_b82c19c08afb292de4600d99e4"`); + await queryRunner.query(`DROP INDEX "IDX_af639b066dfbca78b01a920f8a"`); + await queryRunner.query(`DROP INDEX "IDX_fbb4297c927a9b85e9cefa2eb1"`); + await queryRunner.query(`DROP TABLE "page"`); + await queryRunner.query(`DROP TYPE "page_visibility_enum"`); + } +} diff --git a/packages/backend/src/migration/1556746559567-UserProfile.ts b/packages/backend/src/migration/1556746559567-UserProfile.ts new file mode 100644 index 0000000..51bb7ed --- /dev/null +++ b/packages/backend/src/migration/1556746559567-UserProfile.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class UserProfile1556746559567 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" ALTER COLUMN "githubId" TYPE VARCHAR(64) USING "githubId"::VARCHAR(64)`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ALTER COLUMN "discordExpiresDate" TYPE VARCHAR(64) USING "discordExpiresDate"::VARCHAR(64)`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "user_profile" SET github = FALSE, discord = FALSE`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ALTER COLUMN "githubId" TYPE INTEGER USING NULL`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ALTER COLUMN "discordExpiresDate" TYPE INTEGER USING NULL`, + ); + } +} diff --git a/packages/backend/src/migration/1557476068003-PinnedUsers.ts b/packages/backend/src/migration/1557476068003-PinnedUsers.ts new file mode 100644 index 0000000..fe75eac --- /dev/null +++ b/packages/backend/src/migration/1557476068003-PinnedUsers.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class PinnedUsers1557476068003 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "pinnedUsers" character varying(256) array NOT NULL DEFAULT '{}'::varchar[]`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "pinnedUsers"`); + } +} diff --git a/packages/backend/src/migration/1557761316509-AddSomeUrls.ts b/packages/backend/src/migration/1557761316509-AddSomeUrls.ts new file mode 100644 index 0000000..09cd9a0 --- /dev/null +++ b/packages/backend/src/migration/1557761316509-AddSomeUrls.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class AddSomeUrls1557761316509 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "ToSUrl" character varying(512)`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "repositoryUrl" character varying(512) NOT NULL DEFAULT 'https://codeberg.org/firefish/firefish'`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "feedbackUrl" character varying(512) DEFAULT 'https://codeberg.org/firefish/firefish/issues'`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "feedbackUrl"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "repositoryUrl"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "ToSUrl"`); + } +} diff --git a/packages/backend/src/migration/1557932705754-ObjectStorageSetting.ts b/packages/backend/src/migration/1557932705754-ObjectStorageSetting.ts new file mode 100644 index 0000000..7568fd3 --- /dev/null +++ b/packages/backend/src/migration/1557932705754-ObjectStorageSetting.ts @@ -0,0 +1,67 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class ObjectStorageSetting1557932705754 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "useObjectStorage" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "objectStorageBucket" character varying(512)`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "objectStoragePrefix" character varying(512)`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "objectStorageBaseUrl" character varying(512)`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "objectStorageEndpoint" character varying(512)`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "objectStorageRegion" character varying(512)`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "objectStorageAccessKey" character varying(512)`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "objectStorageSecretKey" character varying(512)`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "objectStoragePort" integer`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "objectStorageUseSSL" boolean NOT NULL DEFAULT true`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "objectStorageUseSSL"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "objectStoragePort"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "objectStorageSecretKey"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "objectStorageAccessKey"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "objectStorageRegion"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "objectStorageEndpoint"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "objectStorageBaseUrl"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "objectStoragePrefix"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "objectStorageBucket"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "useObjectStorage"`, + ); + } +} diff --git a/packages/backend/src/migration/1558072954435-PageLike.ts b/packages/backend/src/migration/1558072954435-PageLike.ts new file mode 100644 index 0000000..b8b5dd3 --- /dev/null +++ b/packages/backend/src/migration/1558072954435-PageLike.ts @@ -0,0 +1,35 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class PageLike1558072954435 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "page_like" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "pageId" character varying(32) NOT NULL, CONSTRAINT "PK_813f034843af992d3ae0f43c64c" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0e61efab7f88dbb79c9166dbb4" ON "page_like" ("userId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_4ce6fb9c70529b4c8ac46c9bfa" ON "page_like" ("userId", "pageId") `, + ); + await queryRunner.query( + `ALTER TABLE "page" ADD "likedCount" integer NOT NULL DEFAULT 0`, + ); + await queryRunner.query( + `ALTER TABLE "page_like" ADD CONSTRAINT "FK_0e61efab7f88dbb79c9166dbb48" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "page_like" ADD CONSTRAINT "FK_cf8782626dced3176038176a847" FOREIGN KEY ("pageId") REFERENCES "page"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "page_like" DROP CONSTRAINT "FK_cf8782626dced3176038176a847"`, + ); + await queryRunner.query( + `ALTER TABLE "page_like" DROP CONSTRAINT "FK_0e61efab7f88dbb79c9166dbb48"`, + ); + await queryRunner.query(`ALTER TABLE "page" DROP COLUMN "likedCount"`); + await queryRunner.query(`DROP INDEX "IDX_4ce6fb9c70529b4c8ac46c9bfa"`); + await queryRunner.query(`DROP INDEX "IDX_0e61efab7f88dbb79c9166dbb4"`); + await queryRunner.query(`DROP TABLE "page_like"`); + } +} diff --git a/packages/backend/src/migration/1558103093633-UserGroup.ts b/packages/backend/src/migration/1558103093633-UserGroup.ts new file mode 100644 index 0000000..c62a6c6 --- /dev/null +++ b/packages/backend/src/migration/1558103093633-UserGroup.ts @@ -0,0 +1,83 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class UserGroup1558103093633 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_group" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(256) NOT NULL, "userId" character varying(32) NOT NULL, "isPrivate" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_3c29fba6fe013ec8724378ce7c9" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_20e30aa35180e317e133d75316" ON "user_group" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_3d6b372788ab01be58853003c9" ON "user_group" ("userId") `, + ); + await queryRunner.query( + `CREATE TABLE "user_group_joining" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "userGroupId" character varying(32) NOT NULL, CONSTRAINT "PK_15f2425885253c5507e1599cfe7" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f3a1b4bd0c7cabba958a0c0b23" ON "user_group_joining" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_67dc758bc0566985d1b3d39986" ON "user_group_joining" ("userGroupId") `, + ); + await queryRunner.query( + `ALTER TABLE "messaging_message" ADD "groupId" character varying(32)`, + ); + await queryRunner.query( + `ALTER TABLE "messaging_message" ADD "reads" character varying(32) array NOT NULL DEFAULT '{}'::varchar[]`, + ); + await queryRunner.query( + `ALTER TABLE "messaging_message" ALTER COLUMN "recipientId" DROP NOT NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "messaging_message"."recipientId" IS 'The recipient user ID.'`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_2c4be03b446884f9e9c502135b" ON "messaging_message" ("groupId") `, + ); + await queryRunner.query( + `ALTER TABLE "messaging_message" ADD CONSTRAINT "FK_2c4be03b446884f9e9c502135be" FOREIGN KEY ("groupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "user_group" ADD CONSTRAINT "FK_3d6b372788ab01be58853003c93" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "user_group_joining" ADD CONSTRAINT "FK_f3a1b4bd0c7cabba958a0c0b231" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "user_group_joining" ADD CONSTRAINT "FK_67dc758bc0566985d1b3d399865" FOREIGN KEY ("userGroupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_group_joining" DROP CONSTRAINT "FK_67dc758bc0566985d1b3d399865"`, + ); + await queryRunner.query( + `ALTER TABLE "user_group_joining" DROP CONSTRAINT "FK_f3a1b4bd0c7cabba958a0c0b231"`, + ); + await queryRunner.query( + `ALTER TABLE "user_group" DROP CONSTRAINT "FK_3d6b372788ab01be58853003c93"`, + ); + await queryRunner.query( + `ALTER TABLE "messaging_message" DROP CONSTRAINT "FK_2c4be03b446884f9e9c502135be"`, + ); + await queryRunner.query(`DROP INDEX "IDX_2c4be03b446884f9e9c502135b"`); + await queryRunner.query( + `COMMENT ON COLUMN "messaging_message"."recipientId" IS ''`, + ); + await queryRunner.query( + `ALTER TABLE "messaging_message" ALTER COLUMN "recipientId" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "messaging_message" DROP COLUMN "reads"`, + ); + await queryRunner.query( + `ALTER TABLE "messaging_message" DROP COLUMN "groupId"`, + ); + await queryRunner.query(`DROP INDEX "IDX_67dc758bc0566985d1b3d39986"`); + await queryRunner.query(`DROP INDEX "IDX_f3a1b4bd0c7cabba958a0c0b23"`); + await queryRunner.query(`DROP TABLE "user_group_joining"`); + await queryRunner.query(`DROP INDEX "IDX_3d6b372788ab01be58853003c9"`); + await queryRunner.query(`DROP INDEX "IDX_20e30aa35180e317e133d75316"`); + await queryRunner.query(`DROP TABLE "user_group"`); + } +} diff --git a/packages/backend/src/migration/1558257926829-UserGroupInvite.ts b/packages/backend/src/migration/1558257926829-UserGroupInvite.ts new file mode 100644 index 0000000..5445e8d --- /dev/null +++ b/packages/backend/src/migration/1558257926829-UserGroupInvite.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class UserGroupInvite1558257926829 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_group_invite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "userGroupId" character varying(32) NOT NULL, CONSTRAINT "PK_3893884af0d3a5f4d01e7921a97" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_1039988afa3bf991185b277fe0" ON "user_group_invite" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e10924607d058004304611a436" ON "user_group_invite" ("userGroupId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_78787741f9010886796f2320a4" ON "user_group_invite" ("userId", "userGroupId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_d9ecaed8c6dc43f3592c229282" ON "user_group_joining" ("userId", "userGroupId") `, + ); + await queryRunner.query( + `ALTER TABLE "user_group_invite" ADD CONSTRAINT "FK_1039988afa3bf991185b277fe03" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "user_group_invite" ADD CONSTRAINT "FK_e10924607d058004304611a436a" FOREIGN KEY ("userGroupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_group_invite" DROP CONSTRAINT "FK_e10924607d058004304611a436a"`, + ); + await queryRunner.query( + `ALTER TABLE "user_group_invite" DROP CONSTRAINT "FK_1039988afa3bf991185b277fe03"`, + ); + await queryRunner.query(`DROP INDEX "IDX_d9ecaed8c6dc43f3592c229282"`); + await queryRunner.query(`DROP INDEX "IDX_78787741f9010886796f2320a4"`); + await queryRunner.query(`DROP INDEX "IDX_e10924607d058004304611a436"`); + await queryRunner.query(`DROP INDEX "IDX_1039988afa3bf991185b277fe0"`); + await queryRunner.query(`DROP TABLE "user_group_invite"`); + } +} diff --git a/packages/backend/src/migration/1558266512381-UserListJoining.ts b/packages/backend/src/migration/1558266512381-UserListJoining.ts new file mode 100644 index 0000000..d4e2c3b --- /dev/null +++ b/packages/backend/src/migration/1558266512381-UserListJoining.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class UserListJoining1558266512381 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_90f7da835e4c10aca6853621e1" ON "user_list_joining" ("userId", "userListId") `, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_90f7da835e4c10aca6853621e1"`); + } +} diff --git a/packages/backend/src/migration/1561706992953-webauthn.ts b/packages/backend/src/migration/1561706992953-webauthn.ts new file mode 100644 index 0000000..da4e224 --- /dev/null +++ b/packages/backend/src/migration/1561706992953-webauthn.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class webauthn1561706992953 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "attestation_challenge" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "challenge" character varying(64) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "registrationChallenge" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_d0ba6786e093f1bcb497572a6b5" PRIMARY KEY ("id", "userId"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f1a461a618fa1755692d0e0d59" ON "attestation_challenge" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_47efb914aed1f72dd39a306c7b" ON "attestation_challenge" ("challenge") `, + ); + await queryRunner.query( + `CREATE TABLE "user_security_key" ("id" character varying NOT NULL, "userId" character varying(32) NOT NULL, "publicKey" character varying NOT NULL, "lastUsed" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(30) NOT NULL, CONSTRAINT "PK_3e508571121ab39c5f85d10c166" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_ff9ca3b5f3ee3d0681367a9b44" ON "user_security_key" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0d7718e562dcedd0aa5cf2c9f7" ON "user_security_key" ("publicKey") `, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "securityKeysAvailable" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "attestation_challenge" ADD CONSTRAINT "FK_f1a461a618fa1755692d0e0d592" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "user_security_key" ADD CONSTRAINT "FK_ff9ca3b5f3ee3d0681367a9b447" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_security_key" DROP CONSTRAINT "FK_ff9ca3b5f3ee3d0681367a9b447"`, + ); + await queryRunner.query( + `ALTER TABLE "attestation_challenge" DROP CONSTRAINT "FK_f1a461a618fa1755692d0e0d592"`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "securityKeysAvailable"`, + ); + await queryRunner.query(`DROP INDEX "IDX_0d7718e562dcedd0aa5cf2c9f7"`); + await queryRunner.query(`DROP INDEX "IDX_ff9ca3b5f3ee3d0681367a9b44"`); + await queryRunner.query(`DROP TABLE "user_security_key"`); + await queryRunner.query(`DROP INDEX "IDX_47efb914aed1f72dd39a306c7b"`); + await queryRunner.query(`DROP INDEX "IDX_f1a461a618fa1755692d0e0d59"`); + await queryRunner.query(`DROP TABLE "attestation_challenge"`); + } +} diff --git a/packages/backend/src/migration/1561873850023-ChartIndexes.ts b/packages/backend/src/migration/1561873850023-ChartIndexes.ts new file mode 100644 index 0000000..c30a794 --- /dev/null +++ b/packages/backend/src/migration/1561873850023-ChartIndexes.ts @@ -0,0 +1,377 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class ChartIndexes1561873850023 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX "IDX_0ad37b7ef50f4ddc84363d7ccc" ON "__chart__active_users" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_15e91a03aeeac9dbccdf43fc06" ON "__chart__active_users" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_00ed5f86db1f7efafb1978bf21" ON "__chart__active_users" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_20f57cc8f142c131340ee16742" ON "__chart__active_users" ("span", "date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_9a3ed15a30ab7e3a37702e6e08" ON "__chart__active_users" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_c26e2c1cbb6e911e0554b27416" ON "__chart__active_users" ("span", "date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_13565815f618a1ff53886c5b28" ON "__chart__drive" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_3fa0d0f17ca72e3dc80999a032" ON "__chart__drive" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_7a170f67425e62a8fabb76c872" ON "__chart__drive" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_6e1df243476e20cbf86572ecc0" ON "__chart__drive" ("span", "date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_3313d7288855ec105b5bbf6c21" ON "__chart__drive" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_06690fc959f1c9fdaf21928222" ON "__chart__drive" ("span", "date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_36cb699c49580d4e6c2e6159f9" ON "__chart__federation" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e447064455928cf627590ef527" ON "__chart__federation" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_76e87c7bfc5d925fcbba405d84" ON "__chart__federation" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_2d416e6af791a82e338c79d480" ON "__chart__federation" ("span", "date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_dd907becf76104e4b656659e6b" ON "__chart__federation" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e9cd07672b37d8966cf3709283" ON "__chart__federation" ("span", "date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_07747a1038c05f532a718fe1de" ON "__chart__hashtag" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_fcc181fb8283009c61cc4083ef" ON "__chart__hashtag" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_99a7d2faaef84a6f728d714ad6" ON "__chart__hashtag" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_49975586f50ed7b800fdd88fbd" ON "__chart__hashtag" ("span", "date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_25a97c02003338124b2b75fdbc" ON "__chart__hashtag" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_6d6f156ceefc6bc5f273a0e370" ON "__chart__hashtag" ("span", "date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_6b8f34a1a64b06014b6fb66824" ON "__chart__instance" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_c12f0af4a66cdd30c2287ce8aa" ON "__chart__instance" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_da8a46ba84ca1d8bb5a29bfb63" ON "__chart__instance" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_d0a4f79af5a97b08f37b547197" ON "__chart__instance" ("span", "date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_39ee857ab2f23493037c6b6631" ON "__chart__instance" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f5448d9633cff74208d850aabe" ON "__chart__instance" ("span", "date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_a1efd3e0048a5f2793a47360dc" ON "__chart__network" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f8dd01baeded2ffa833e0a610a" ON "__chart__network" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_7b5da130992ec9df96712d4290" ON "__chart__network" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_08fac0eb3b11f04c200c0b40dd" ON "__chart__network" ("span", "date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0a905b992fecd2b5c3fb98759e" ON "__chart__network" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_9ff6944f01acb756fdc92d7563" ON "__chart__network" ("span", "date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_42eb716a37d381cdf566192b2b" ON "__chart__notes" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e69096589f11e3baa98ddd64d0" ON "__chart__notes" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_7036f2957151588b813185c794" ON "__chart__notes" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0c9a159c5082cbeef3ca6706b5" ON "__chart__notes" ("span", "date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f09d543e3acb16c5976bdb31fa" ON "__chart__notes" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_924fc196c80ca24bae01dd37e4" ON "__chart__notes" ("span", "date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_5f86db6492274e07c1a3cdf286" ON "__chart__per_user_drive" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_328f259961e60c4fa0bfcf55ca" ON "__chart__per_user_drive" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e496ca8096d28f6b9b509264dc" ON "__chart__per_user_drive" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_42ea9381f0fda8dfe0fa1c8b53" ON "__chart__per_user_drive" ("span", "date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_30bf67687f483ace115c5ca642" ON "__chart__per_user_drive" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f2aeafde2ae6fbad38e857631b" ON "__chart__per_user_drive" ("span", "date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_7af07790712aa3438ff6773f3b" ON "__chart__per_user_following" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f92dd6d03f8d994f29987f6214" ON "__chart__per_user_following" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_4b3593098b6edc9c5afe36b18b" ON "__chart__per_user_following" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_57b5458d0d3d6d1e7f13d4e57f" ON "__chart__per_user_following" ("span", "date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b77d4dd9562c3a899d9a286fcd" ON "__chart__per_user_following" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_4db3b84c7be0d3464714f3e0b1" ON "__chart__per_user_following" ("span", "date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_84234bd1abb873f07329681c83" ON "__chart__per_user_notes" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_8d2cbbc8114d90d19b44d626b6" ON "__chart__per_user_notes" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_55bf20f366979f2436de99206b" ON "__chart__per_user_notes" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_046feeb12e9ef5f783f409866a" ON "__chart__per_user_notes" ("span", "date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_5048e9daccbbbc6d567bb142d3" ON "__chart__per_user_notes" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f68a5ab958f9f5fa17a32ac23b" ON "__chart__per_user_notes" ("span", "date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f7bf4c62059764c2c2bb40fdab" ON "__chart__per_user_reaction" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_65633a106bce43fc7c5c30a5c7" ON "__chart__per_user_reaction" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_8cf3156fd7a6b15c43459c6e3b" ON "__chart__per_user_reaction" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_edeb73c09c3143a81bcb34d569" ON "__chart__per_user_reaction" ("span", "date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_229a41ad465f9205f1f5703291" ON "__chart__per_user_reaction" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e316f01a6d24eb31db27f88262" ON "__chart__per_user_reaction" ("span", "date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0c641990ecf47d2545df4edb75" ON "__chart__test_grouped" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_2be7ec6cebddc14dc11e206686" ON "__chart__test_grouped" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_234dff3c0b56a6150b95431ab9" ON "__chart__test_grouped" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_a5133470f4825902e170328ca5" ON "__chart__test_grouped" ("span", "date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b14489029e4b3aaf4bba5fb524" ON "__chart__test_grouped" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_84e661abb7bd1e51b690d4b017" ON "__chart__test_grouped" ("span", "date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_437bab3c6061d90f6bb65fd2cc" ON "__chart__test_unique" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_5c73bf61da4f6e6f15bae88ed1" ON "__chart__test_unique" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_bbfa573a8181018851ed0b6357" ON "__chart__test_unique" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_d70c86baedc68326be11f9c0ce" ON "__chart__test_unique" ("span", "date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_a0cd75442dd10d0643a17c4a49" ON "__chart__test_unique" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_66e1e1ecd2f29e57778af35b59" ON "__chart__test_unique" ("span", "date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b070a906db04b44c67c6c2144d" ON "__chart__test" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_92255988735563f0fe4aba1f05" ON "__chart__test" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_d41cce6aee1a50bfc062038f9b" ON "__chart__test" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_c5870993e25c3d5771f91f5003" ON "__chart__test" ("span", "date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_a319e5dbf47e8a17497623beae" ON "__chart__test" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f170de677ea75ad4533de2723e" ON "__chart__test" ("span", "date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_845254b3eaf708ae8a6cac3026" ON "__chart__users" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_7c184198ecf66a8d3ecb253ab3" ON "__chart__users" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_ed9b95919c672a13008e9487ee" ON "__chart__users" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f091abb24193d50c653c6b77fc" ON "__chart__users" ("span", "date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_337e9599f278bd7537fe30876f" ON "__chart__users" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_a770a57c70e668cc61590c9161" ON "__chart__users" ("span", "date", "group") `, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_a770a57c70e668cc61590c9161"`); + await queryRunner.query(`DROP INDEX "IDX_337e9599f278bd7537fe30876f"`); + await queryRunner.query(`DROP INDEX "IDX_f091abb24193d50c653c6b77fc"`); + await queryRunner.query(`DROP INDEX "IDX_ed9b95919c672a13008e9487ee"`); + await queryRunner.query(`DROP INDEX "IDX_7c184198ecf66a8d3ecb253ab3"`); + await queryRunner.query(`DROP INDEX "IDX_845254b3eaf708ae8a6cac3026"`); + await queryRunner.query(`DROP INDEX "IDX_f170de677ea75ad4533de2723e"`); + await queryRunner.query(`DROP INDEX "IDX_a319e5dbf47e8a17497623beae"`); + await queryRunner.query(`DROP INDEX "IDX_c5870993e25c3d5771f91f5003"`); + await queryRunner.query(`DROP INDEX "IDX_d41cce6aee1a50bfc062038f9b"`); + await queryRunner.query(`DROP INDEX "IDX_92255988735563f0fe4aba1f05"`); + await queryRunner.query(`DROP INDEX "IDX_b070a906db04b44c67c6c2144d"`); + await queryRunner.query(`DROP INDEX "IDX_66e1e1ecd2f29e57778af35b59"`); + await queryRunner.query(`DROP INDEX "IDX_a0cd75442dd10d0643a17c4a49"`); + await queryRunner.query(`DROP INDEX "IDX_d70c86baedc68326be11f9c0ce"`); + await queryRunner.query(`DROP INDEX "IDX_bbfa573a8181018851ed0b6357"`); + await queryRunner.query(`DROP INDEX "IDX_5c73bf61da4f6e6f15bae88ed1"`); + await queryRunner.query(`DROP INDEX "IDX_437bab3c6061d90f6bb65fd2cc"`); + await queryRunner.query(`DROP INDEX "IDX_84e661abb7bd1e51b690d4b017"`); + await queryRunner.query(`DROP INDEX "IDX_b14489029e4b3aaf4bba5fb524"`); + await queryRunner.query(`DROP INDEX "IDX_a5133470f4825902e170328ca5"`); + await queryRunner.query(`DROP INDEX "IDX_234dff3c0b56a6150b95431ab9"`); + await queryRunner.query(`DROP INDEX "IDX_2be7ec6cebddc14dc11e206686"`); + await queryRunner.query(`DROP INDEX "IDX_0c641990ecf47d2545df4edb75"`); + await queryRunner.query(`DROP INDEX "IDX_e316f01a6d24eb31db27f88262"`); + await queryRunner.query(`DROP INDEX "IDX_229a41ad465f9205f1f5703291"`); + await queryRunner.query(`DROP INDEX "IDX_edeb73c09c3143a81bcb34d569"`); + await queryRunner.query(`DROP INDEX "IDX_8cf3156fd7a6b15c43459c6e3b"`); + await queryRunner.query(`DROP INDEX "IDX_65633a106bce43fc7c5c30a5c7"`); + await queryRunner.query(`DROP INDEX "IDX_f7bf4c62059764c2c2bb40fdab"`); + await queryRunner.query(`DROP INDEX "IDX_f68a5ab958f9f5fa17a32ac23b"`); + await queryRunner.query(`DROP INDEX "IDX_5048e9daccbbbc6d567bb142d3"`); + await queryRunner.query(`DROP INDEX "IDX_046feeb12e9ef5f783f409866a"`); + await queryRunner.query(`DROP INDEX "IDX_55bf20f366979f2436de99206b"`); + await queryRunner.query(`DROP INDEX "IDX_8d2cbbc8114d90d19b44d626b6"`); + await queryRunner.query(`DROP INDEX "IDX_84234bd1abb873f07329681c83"`); + await queryRunner.query(`DROP INDEX "IDX_4db3b84c7be0d3464714f3e0b1"`); + await queryRunner.query(`DROP INDEX "IDX_b77d4dd9562c3a899d9a286fcd"`); + await queryRunner.query(`DROP INDEX "IDX_57b5458d0d3d6d1e7f13d4e57f"`); + await queryRunner.query(`DROP INDEX "IDX_4b3593098b6edc9c5afe36b18b"`); + await queryRunner.query(`DROP INDEX "IDX_f92dd6d03f8d994f29987f6214"`); + await queryRunner.query(`DROP INDEX "IDX_7af07790712aa3438ff6773f3b"`); + await queryRunner.query(`DROP INDEX "IDX_f2aeafde2ae6fbad38e857631b"`); + await queryRunner.query(`DROP INDEX "IDX_30bf67687f483ace115c5ca642"`); + await queryRunner.query(`DROP INDEX "IDX_42ea9381f0fda8dfe0fa1c8b53"`); + await queryRunner.query(`DROP INDEX "IDX_e496ca8096d28f6b9b509264dc"`); + await queryRunner.query(`DROP INDEX "IDX_328f259961e60c4fa0bfcf55ca"`); + await queryRunner.query(`DROP INDEX "IDX_5f86db6492274e07c1a3cdf286"`); + await queryRunner.query(`DROP INDEX "IDX_924fc196c80ca24bae01dd37e4"`); + await queryRunner.query(`DROP INDEX "IDX_f09d543e3acb16c5976bdb31fa"`); + await queryRunner.query(`DROP INDEX "IDX_0c9a159c5082cbeef3ca6706b5"`); + await queryRunner.query(`DROP INDEX "IDX_7036f2957151588b813185c794"`); + await queryRunner.query(`DROP INDEX "IDX_e69096589f11e3baa98ddd64d0"`); + await queryRunner.query(`DROP INDEX "IDX_42eb716a37d381cdf566192b2b"`); + await queryRunner.query(`DROP INDEX "IDX_9ff6944f01acb756fdc92d7563"`); + await queryRunner.query(`DROP INDEX "IDX_0a905b992fecd2b5c3fb98759e"`); + await queryRunner.query(`DROP INDEX "IDX_08fac0eb3b11f04c200c0b40dd"`); + await queryRunner.query(`DROP INDEX "IDX_7b5da130992ec9df96712d4290"`); + await queryRunner.query(`DROP INDEX "IDX_f8dd01baeded2ffa833e0a610a"`); + await queryRunner.query(`DROP INDEX "IDX_a1efd3e0048a5f2793a47360dc"`); + await queryRunner.query(`DROP INDEX "IDX_f5448d9633cff74208d850aabe"`); + await queryRunner.query(`DROP INDEX "IDX_39ee857ab2f23493037c6b6631"`); + await queryRunner.query(`DROP INDEX "IDX_d0a4f79af5a97b08f37b547197"`); + await queryRunner.query(`DROP INDEX "IDX_da8a46ba84ca1d8bb5a29bfb63"`); + await queryRunner.query(`DROP INDEX "IDX_c12f0af4a66cdd30c2287ce8aa"`); + await queryRunner.query(`DROP INDEX "IDX_6b8f34a1a64b06014b6fb66824"`); + await queryRunner.query(`DROP INDEX "IDX_6d6f156ceefc6bc5f273a0e370"`); + await queryRunner.query(`DROP INDEX "IDX_25a97c02003338124b2b75fdbc"`); + await queryRunner.query(`DROP INDEX "IDX_49975586f50ed7b800fdd88fbd"`); + await queryRunner.query(`DROP INDEX "IDX_99a7d2faaef84a6f728d714ad6"`); + await queryRunner.query(`DROP INDEX "IDX_fcc181fb8283009c61cc4083ef"`); + await queryRunner.query(`DROP INDEX "IDX_07747a1038c05f532a718fe1de"`); + await queryRunner.query(`DROP INDEX "IDX_e9cd07672b37d8966cf3709283"`); + await queryRunner.query(`DROP INDEX "IDX_dd907becf76104e4b656659e6b"`); + await queryRunner.query(`DROP INDEX "IDX_2d416e6af791a82e338c79d480"`); + await queryRunner.query(`DROP INDEX "IDX_76e87c7bfc5d925fcbba405d84"`); + await queryRunner.query(`DROP INDEX "IDX_e447064455928cf627590ef527"`); + await queryRunner.query(`DROP INDEX "IDX_36cb699c49580d4e6c2e6159f9"`); + await queryRunner.query(`DROP INDEX "IDX_06690fc959f1c9fdaf21928222"`); + await queryRunner.query(`DROP INDEX "IDX_3313d7288855ec105b5bbf6c21"`); + await queryRunner.query(`DROP INDEX "IDX_6e1df243476e20cbf86572ecc0"`); + await queryRunner.query(`DROP INDEX "IDX_7a170f67425e62a8fabb76c872"`); + await queryRunner.query(`DROP INDEX "IDX_3fa0d0f17ca72e3dc80999a032"`); + await queryRunner.query(`DROP INDEX "IDX_13565815f618a1ff53886c5b28"`); + await queryRunner.query(`DROP INDEX "IDX_c26e2c1cbb6e911e0554b27416"`); + await queryRunner.query(`DROP INDEX "IDX_9a3ed15a30ab7e3a37702e6e08"`); + await queryRunner.query(`DROP INDEX "IDX_20f57cc8f142c131340ee16742"`); + await queryRunner.query(`DROP INDEX "IDX_00ed5f86db1f7efafb1978bf21"`); + await queryRunner.query(`DROP INDEX "IDX_15e91a03aeeac9dbccdf43fc06"`); + await queryRunner.query(`DROP INDEX "IDX_0ad37b7ef50f4ddc84363d7ccc"`); + await queryRunner.query(`DROP INDEX "IDX_90148bbc2bf0854428786bfc15"`); + await queryRunner.query(`DROP INDEX "IDX_88937d94d7443d9a99a76fa5c0"`); + await queryRunner.query(`DROP INDEX "IDX_54ebcb6d27222913b908d56fd8"`); + await queryRunner.query(`DROP INDEX "IDX_796a8c03959361f97dc2be1d5c"`); + await queryRunner.query(`DROP INDEX "IDX_25dfc71b0369b003a4cd434d0b"`); + await queryRunner.query(`DROP INDEX "IDX_51c063b6a133a9cb87145450f5"`); + await queryRunner.query(`DROP INDEX "IDX_fa99d777623947a5b05f394cae"`); + await queryRunner.query(`DROP INDEX "IDX_315c779174fe8247ab324f036e"`); + await queryRunner.query(`DROP INDEX "IDX_c5d46cbfda48b1c33ed852e21b"`); + await queryRunner.query(`DROP INDEX "IDX_8cb40cfc8f3c28261e6f887b03"`); + } +} diff --git a/packages/backend/src/migration/1562422242907-PasswordLessLogin.ts b/packages/backend/src/migration/1562422242907-PasswordLessLogin.ts new file mode 100644 index 0000000..54f8787 --- /dev/null +++ b/packages/backend/src/migration/1562422242907-PasswordLessLogin.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class PasswordLessLogin1562422242907 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" ADD COLUMN "usePasswordLessLogin" boolean DEFAULT false NOT NULL`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "usePasswordLessLogin"`, + ); + } +} diff --git a/packages/backend/src/migration/1562444565093-PinnedPage.ts b/packages/backend/src/migration/1562444565093-PinnedPage.ts new file mode 100644 index 0000000..fad4d1b --- /dev/null +++ b/packages/backend/src/migration/1562444565093-PinnedPage.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class PinnedPage1562444565093 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "pinnedPageId" character varying(32)`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD CONSTRAINT "UQ_6dc44f1ceb65b1e72bacef2ca27" UNIQUE ("pinnedPageId")`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD CONSTRAINT "FK_6dc44f1ceb65b1e72bacef2ca27" FOREIGN KEY ("pinnedPageId") REFERENCES "page"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" DROP CONSTRAINT "FK_6dc44f1ceb65b1e72bacef2ca27"`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP CONSTRAINT "UQ_6dc44f1ceb65b1e72bacef2ca27"`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "pinnedPageId"`, + ); + } +} diff --git a/packages/backend/src/migration/1562448332510-PageTitleHideOption.ts b/packages/backend/src/migration/1562448332510-PageTitleHideOption.ts new file mode 100644 index 0000000..63b365e --- /dev/null +++ b/packages/backend/src/migration/1562448332510-PageTitleHideOption.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class PageTitleHideOption1562448332510 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "page" ADD "hideTitleWhenPinned" boolean NOT NULL DEFAULT false`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "page" DROP COLUMN "hideTitleWhenPinned"`, + ); + } +} diff --git a/packages/backend/src/migration/1562869971568-ModerationLog.ts b/packages/backend/src/migration/1562869971568-ModerationLog.ts new file mode 100644 index 0000000..f3001c8 --- /dev/null +++ b/packages/backend/src/migration/1562869971568-ModerationLog.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class ModerationLog1562869971568 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "moderation_log" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "type" character varying(128) NOT NULL, "info" jsonb NOT NULL, CONSTRAINT "PK_d0adca6ecfd068db83e4526cc26" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_a08ad074601d204e0f69da9a95" ON "moderation_log" ("userId") `, + ); + await queryRunner.query( + `ALTER TABLE "moderation_log" ADD CONSTRAINT "FK_a08ad074601d204e0f69da9a954" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "moderation_log" DROP CONSTRAINT "FK_a08ad074601d204e0f69da9a954"`, + ); + await queryRunner.query(`DROP INDEX "IDX_a08ad074601d204e0f69da9a95"`); + await queryRunner.query(`DROP TABLE "moderation_log"`); + } +} diff --git a/packages/backend/src/migration/1563757595828-UsedUsername.ts b/packages/backend/src/migration/1563757595828-UsedUsername.ts new file mode 100644 index 0000000..864782c --- /dev/null +++ b/packages/backend/src/migration/1563757595828-UsedUsername.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class UsedUsername1563757595828 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "used_username" ("username" character varying(128) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, CONSTRAINT "PK_78fd79d2d24c6ac2f4cc9a31a5d" PRIMARY KEY ("username"))`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "used_username"`); + } +} diff --git a/packages/backend/src/migration/1565634203341-room.ts b/packages/backend/src/migration/1565634203341-room.ts new file mode 100644 index 0000000..f2e2ee8 --- /dev/null +++ b/packages/backend/src/migration/1565634203341-room.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class room1565634203341 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "room" jsonb NOT NULL DEFAULT '{}'`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "room"`); + } +} diff --git a/packages/backend/src/migration/1571220798684-CustomEmojiCategory.ts b/packages/backend/src/migration/1571220798684-CustomEmojiCategory.ts new file mode 100644 index 0000000..6ff44c9 --- /dev/null +++ b/packages/backend/src/migration/1571220798684-CustomEmojiCategory.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class CustomEmojiCategory1571220798684 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "emoji" ADD "category" character varying(128)`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "emoji" DROP COLUMN "category"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1572760203493-nodeinfo.ts b/packages/backend/src/migration/1572760203493-nodeinfo.ts new file mode 100644 index 0000000..e85b414 --- /dev/null +++ b/packages/backend/src/migration/1572760203493-nodeinfo.ts @@ -0,0 +1,79 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class nodeinfo1572760203493 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "instance" DROP COLUMN "system"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" ADD "softwareName" character varying(64) DEFAULT null`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" ADD "softwareVersion" character varying(64) DEFAULT null`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" ADD "openRegistrations" boolean DEFAULT null`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" ADD "name" character varying(256) DEFAULT null`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" ADD "description" character varying(4096) DEFAULT null`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" ADD "maintainerName" character varying(128) DEFAULT null`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" ADD "maintainerEmail" character varying(256) DEFAULT null`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" ADD "infoUpdatedAt" TIMESTAMP WITH TIME ZONE`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "instance" DROP COLUMN "infoUpdatedAt"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" DROP COLUMN "maintainerEmail"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" DROP COLUMN "maintainerName"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" DROP COLUMN "description"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" DROP COLUMN "name"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" DROP COLUMN "openRegistrations"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" DROP COLUMN "softwareVersion"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" DROP COLUMN "softwareName"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" ADD "system" character varying(64)`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1576269851876-TalkFederationId.ts b/packages/backend/src/migration/1576269851876-TalkFederationId.ts new file mode 100644 index 0000000..068708b --- /dev/null +++ b/packages/backend/src/migration/1576269851876-TalkFederationId.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class TalkFederationId1576269851876 implements MigrationInterface { + constructor() { + this.name = "TalkFederationId1576269851876"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "messaging_message" ADD "uri" character varying(512)`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "messaging_message" DROP COLUMN "uri"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1576869585998-ProxyRemoteFiles.ts b/packages/backend/src/migration/1576869585998-ProxyRemoteFiles.ts new file mode 100644 index 0000000..8ac7c9a --- /dev/null +++ b/packages/backend/src/migration/1576869585998-ProxyRemoteFiles.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class ProxyRemoteFiles1576869585998 implements MigrationInterface { + constructor() { + this.name = "ProxyRemoteFiles1576869585998"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "proxyRemoteFiles" boolean NOT NULL DEFAULT false`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "proxyRemoteFiles"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1579267006611-v12.ts b/packages/backend/src/migration/1579267006611-v12.ts new file mode 100644 index 0000000..0ef6e11 --- /dev/null +++ b/packages/backend/src/migration/1579267006611-v12.ts @@ -0,0 +1,92 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class v121579267006611 implements MigrationInterface { + constructor() { + this.name = "v121579267006611"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "announcement" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "text" character varying(8192) NOT NULL, "title" character varying(256) NOT NULL, "imageUrl" character varying(1024), CONSTRAINT "PK_e0ef0550174fd1099a308fd18a0" PRIMARY KEY ("id"))`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_118ec703e596086fc4515acb39" ON "announcement" ("createdAt") `, + undefined, + ); + await queryRunner.query( + `CREATE TABLE "announcement_read" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "announcementId" character varying(32) NOT NULL, CONSTRAINT "PK_4b90ad1f42681d97b2683890c5e" PRIMARY KEY ("id"))`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_8288151386172b8109f7239ab2" ON "announcement_read" ("userId") `, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_603a7b1e7aa0533c6c88e9bfaf" ON "announcement_read" ("announcementId") `, + undefined, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_924fa71815cfa3941d003702a0" ON "announcement_read" ("userId", "announcementId") `, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user" DROP COLUMN "isVerified"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "announcements"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "enableEmojiReaction"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "announcement_read" ADD CONSTRAINT "FK_8288151386172b8109f7239ab28" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "announcement_read" ADD CONSTRAINT "FK_603a7b1e7aa0533c6c88e9bfafe" FOREIGN KEY ("announcementId") REFERENCES "announcement"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "announcement_read" DROP CONSTRAINT "FK_603a7b1e7aa0533c6c88e9bfafe"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "announcement_read" DROP CONSTRAINT "FK_8288151386172b8109f7239ab28"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "enableEmojiReaction" boolean NOT NULL DEFAULT true`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "announcements" jsonb NOT NULL DEFAULT '[]'`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user" ADD "isVerified" boolean NOT NULL DEFAULT false`, + undefined, + ); + await queryRunner.query( + `DROP INDEX "IDX_924fa71815cfa3941d003702a0"`, + undefined, + ); + await queryRunner.query( + `DROP INDEX "IDX_603a7b1e7aa0533c6c88e9bfaf"`, + undefined, + ); + await queryRunner.query( + `DROP INDEX "IDX_8288151386172b8109f7239ab2"`, + undefined, + ); + await queryRunner.query(`DROP TABLE "announcement_read"`, undefined); + await queryRunner.query( + `DROP INDEX "IDX_118ec703e596086fc4515acb39"`, + undefined, + ); + await queryRunner.query(`DROP TABLE "announcement"`, undefined); + } +} diff --git a/packages/backend/src/migration/1579270193251-v12-2.ts b/packages/backend/src/migration/1579270193251-v12-2.ts new file mode 100644 index 0000000..e964f44 --- /dev/null +++ b/packages/backend/src/migration/1579270193251-v12-2.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class v1221579270193251 implements MigrationInterface { + constructor() { + this.name = "v1221579270193251"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "announcement_read" ADD "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "announcement_read" DROP COLUMN "createdAt"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1579282808087-v12-3.ts b/packages/backend/src/migration/1579282808087-v12-3.ts new file mode 100644 index 0000000..a12fea2 --- /dev/null +++ b/packages/backend/src/migration/1579282808087-v12-3.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class v1231579282808087 implements MigrationInterface { + constructor() { + this.name = "v1231579282808087"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "announcement" ADD "updatedAt" TIMESTAMP WITH TIME ZONE`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "announcement" DROP COLUMN "updatedAt"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1579544426412-v12-4.ts b/packages/backend/src/migration/1579544426412-v12-4.ts new file mode 100644 index 0000000..9d40fed --- /dev/null +++ b/packages/backend/src/migration/1579544426412-v12-4.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class v1241579544426412 implements MigrationInterface { + constructor() { + this.name = "v1241579544426412"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "notification" ADD "followRequestId" character varying(32)`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" ADD CONSTRAINT "FK_bd7fab507621e635b32cd31892c" FOREIGN KEY ("followRequestId") REFERENCES "follow_request"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "notification" DROP CONSTRAINT "FK_bd7fab507621e635b32cd31892c"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" DROP COLUMN "followRequestId"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1579977526288-v12-5.ts b/packages/backend/src/migration/1579977526288-v12-5.ts new file mode 100644 index 0000000..f71bfb0 --- /dev/null +++ b/packages/backend/src/migration/1579977526288-v12-5.ts @@ -0,0 +1,157 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class v1251579977526288 implements MigrationInterface { + constructor() { + this.name = "v1251579977526288"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "clip" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "name" character varying(128) NOT NULL, "isPublic" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_f0685dac8d4dd056d7255670b75" PRIMARY KEY ("id"))`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_2b5ec6c574d6802c94c80313fb" ON "clip" ("userId") `, + undefined, + ); + await queryRunner.query( + `CREATE TABLE "clip_note" ("id" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, "clipId" character varying(32) NOT NULL, CONSTRAINT "PK_e94cda2f40a99b57e032a1a738b" PRIMARY KEY ("id"))`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_a012eaf5c87c65da1deb5fdbfa" ON "clip_note" ("noteId") `, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_ebe99317bbbe9968a0c6f579ad" ON "clip_note" ("clipId") `, + undefined, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_6fc0ec357d55a18646262fdfff" ON "clip_note" ("noteId", "clipId") `, + undefined, + ); + await queryRunner.query( + `CREATE TYPE "antenna_src_enum" AS ENUM('home', 'all', 'list')`, + undefined, + ); + await queryRunner.query( + `CREATE TABLE "antenna" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "name" character varying(128) NOT NULL, "src" "antenna_src_enum" NOT NULL, "userListId" character varying(32), "keywords" jsonb NOT NULL DEFAULT '[]', "withFile" boolean NOT NULL, "expression" character varying(2048), "notify" boolean NOT NULL, "hasNewNote" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_c170b99775e1dccca947c9f2d5f" PRIMARY KEY ("id"))`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_6446c571a0e8d0f05f01c78909" ON "antenna" ("userId") `, + undefined, + ); + await queryRunner.query( + `CREATE TABLE "antenna_note" ("id" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, "antennaId" character varying(32) NOT NULL, CONSTRAINT "PK_fb28d94d0989a3872df19fd6ef8" PRIMARY KEY ("id"))`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_bd0397be22147e17210940e125" ON "antenna_note" ("noteId") `, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0d775946662d2575dfd2068a5f" ON "antenna_note" ("antennaId") `, + undefined, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_335a0bf3f904406f9ef3dd51c2" ON "antenna_note" ("noteId", "antennaId") `, + undefined, + ); + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "geo"`, undefined); + await queryRunner.query( + `ALTER TABLE "clip" ADD CONSTRAINT "FK_2b5ec6c574d6802c94c80313fb2" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "clip_note" ADD CONSTRAINT "FK_a012eaf5c87c65da1deb5fdbfa3" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "clip_note" ADD CONSTRAINT "FK_ebe99317bbbe9968a0c6f579adf" FOREIGN KEY ("clipId") REFERENCES "clip"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna" ADD CONSTRAINT "FK_6446c571a0e8d0f05f01c789096" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna" ADD CONSTRAINT "FK_709d7d32053d0dd7620f678eeb9" FOREIGN KEY ("userListId") REFERENCES "user_list"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna_note" ADD CONSTRAINT "FK_bd0397be22147e17210940e125b" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna_note" ADD CONSTRAINT "FK_0d775946662d2575dfd2068a5f5" FOREIGN KEY ("antennaId") REFERENCES "antenna"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "antenna_note" DROP CONSTRAINT "FK_0d775946662d2575dfd2068a5f5"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna_note" DROP CONSTRAINT "FK_bd0397be22147e17210940e125b"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna" DROP CONSTRAINT "FK_709d7d32053d0dd7620f678eeb9"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna" DROP CONSTRAINT "FK_6446c571a0e8d0f05f01c789096"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "clip_note" DROP CONSTRAINT "FK_ebe99317bbbe9968a0c6f579adf"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "clip_note" DROP CONSTRAINT "FK_a012eaf5c87c65da1deb5fdbfa3"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "clip" DROP CONSTRAINT "FK_2b5ec6c574d6802c94c80313fb2"`, + undefined, + ); + await queryRunner.query(`ALTER TABLE "note" ADD "geo" jsonb`, undefined); + await queryRunner.query( + `DROP INDEX "IDX_335a0bf3f904406f9ef3dd51c2"`, + undefined, + ); + await queryRunner.query( + `DROP INDEX "IDX_0d775946662d2575dfd2068a5f"`, + undefined, + ); + await queryRunner.query( + `DROP INDEX "IDX_bd0397be22147e17210940e125"`, + undefined, + ); + await queryRunner.query(`DROP TABLE "antenna_note"`, undefined); + await queryRunner.query( + `DROP INDEX "IDX_6446c571a0e8d0f05f01c78909"`, + undefined, + ); + await queryRunner.query(`DROP TABLE "antenna"`, undefined); + await queryRunner.query(`DROP TYPE "antenna_src_enum"`, undefined); + await queryRunner.query( + `DROP INDEX "IDX_6fc0ec357d55a18646262fdfff"`, + undefined, + ); + await queryRunner.query( + `DROP INDEX "IDX_ebe99317bbbe9968a0c6f579ad"`, + undefined, + ); + await queryRunner.query( + `DROP INDEX "IDX_a012eaf5c87c65da1deb5fdbfa"`, + undefined, + ); + await queryRunner.query(`DROP TABLE "clip_note"`, undefined); + await queryRunner.query( + `DROP INDEX "IDX_2b5ec6c574d6802c94c80313fb"`, + undefined, + ); + await queryRunner.query(`DROP TABLE "clip"`, undefined); + } +} diff --git a/packages/backend/src/migration/1579993013959-v12-6.ts b/packages/backend/src/migration/1579993013959-v12-6.ts new file mode 100644 index 0000000..f10eddc --- /dev/null +++ b/packages/backend/src/migration/1579993013959-v12-6.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class v1261579993013959 implements MigrationInterface { + constructor() { + this.name = "v1261579993013959"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "antenna" DROP COLUMN "hasNewNote"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna_note" ADD "read" boolean NOT NULL DEFAULT false`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_9937ea48d7ae97ffb4f3f063a4" ON "antenna_note" ("read") `, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "IDX_9937ea48d7ae97ffb4f3f063a4"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna_note" DROP COLUMN "read"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna" ADD "hasNewNote" boolean NOT NULL DEFAULT false`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1580069531114-v12-7.ts b/packages/backend/src/migration/1580069531114-v12-7.ts new file mode 100644 index 0000000..79dcdd6 --- /dev/null +++ b/packages/backend/src/migration/1580069531114-v12-7.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class v1271580069531114 implements MigrationInterface { + constructor() { + this.name = "v1271580069531114"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "antenna" ADD "users" character varying(1024) array NOT NULL DEFAULT '{}'::varchar[]`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna" ADD "caseSensitive" boolean NOT NULL DEFAULT false`, + undefined, + ); + await queryRunner.query( + `ALTER TYPE "public"."antenna_src_enum" RENAME TO "antenna_src_enum_old"`, + undefined, + ); + await queryRunner.query( + `CREATE TYPE "antenna_src_enum" AS ENUM('home', 'all', 'users', 'list')`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna" ALTER COLUMN "src" TYPE "antenna_src_enum" USING "src"::"text"::"antenna_src_enum"`, + undefined, + ); + await queryRunner.query(`DROP TYPE "antenna_src_enum_old"`, undefined); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "antenna_src_enum_old" AS ENUM('home', 'all', 'list')`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna" ALTER COLUMN "src" TYPE "antenna_src_enum_old" USING "src"::"text"::"antenna_src_enum_old"`, + undefined, + ); + await queryRunner.query(`DROP TYPE "antenna_src_enum"`, undefined); + await queryRunner.query( + `ALTER TYPE "antenna_src_enum_old" RENAME TO "antenna_src_enum"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna" DROP COLUMN "caseSensitive"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna" DROP COLUMN "users"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1580148575182-v12-8.ts b/packages/backend/src/migration/1580148575182-v12-8.ts new file mode 100644 index 0000000..f92dc9b --- /dev/null +++ b/packages/backend/src/migration/1580148575182-v12-8.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class v1281580148575182 implements MigrationInterface { + constructor() { + this.name = "v1281580148575182"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "note" DROP CONSTRAINT "FK_ec5c201576192ba8904c345c5cc"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "note" DROP COLUMN "appId"`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "note" ADD "appId" character varying(32)`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "note" ADD CONSTRAINT "FK_ec5c201576192ba8904c345c5cc" FOREIGN KEY ("appId") REFERENCES "app"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1580154400017-v12-9.ts b/packages/backend/src/migration/1580154400017-v12-9.ts new file mode 100644 index 0000000..2bc0bc1 --- /dev/null +++ b/packages/backend/src/migration/1580154400017-v12-9.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class v1291580154400017 implements MigrationInterface { + constructor() { + this.name = "v1291580154400017"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "antenna" ADD "withReplies" boolean NOT NULL DEFAULT false`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "antenna" DROP COLUMN "withReplies"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1580276619901-v12-10.ts b/packages/backend/src/migration/1580276619901-v12-10.ts new file mode 100644 index 0000000..ab25a52 --- /dev/null +++ b/packages/backend/src/migration/1580276619901-v12-10.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class v12101580276619901 implements MigrationInterface { + constructor() { + this.name = "v12101580276619901"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`TRUNCATE TABLE "notification"`, undefined); + await queryRunner.query( + `ALTER TABLE "notification" DROP COLUMN "type"`, + undefined, + ); + await queryRunner.query( + `CREATE TYPE "notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted')`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" ADD "type" "notification_type_enum" NOT NULL`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "notification" DROP COLUMN "type"`, + undefined, + ); + await queryRunner.query(`DROP TYPE "notification_type_enum"`, undefined); + await queryRunner.query( + `ALTER TABLE "notification" ADD "type" character varying(32) NOT NULL`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1580331224276-v12-11.ts b/packages/backend/src/migration/1580331224276-v12-11.ts new file mode 100644 index 0000000..7f2eddf --- /dev/null +++ b/packages/backend/src/migration/1580331224276-v12-11.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class v12111580331224276 implements MigrationInterface { + constructor() { + this.name = "v12111580331224276"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "instance" DROP COLUMN "isMarkedAsClosed"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" ADD "isSuspended" boolean NOT NULL DEFAULT false`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_34500da2e38ac393f7bb6b299c" ON "instance" ("isSuspended") `, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "IDX_34500da2e38ac393f7bb6b299c"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" DROP COLUMN "isSuspended"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "instance" ADD "isMarkedAsClosed" boolean NOT NULL DEFAULT false`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1580508795118-v12-12.ts b/packages/backend/src/migration/1580508795118-v12-12.ts new file mode 100644 index 0000000..0660420 --- /dev/null +++ b/packages/backend/src/migration/1580508795118-v12-12.ts @@ -0,0 +1,146 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class v12121580508795118 implements MigrationInterface { + constructor() { + this.name = "v12121580508795118"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "twitter"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "twitterAccessToken"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "twitterAccessTokenSecret"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "twitterUserId"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "twitterScreenName"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "github"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "githubAccessToken"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "githubId"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "githubLogin"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "discord"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "discordAccessToken"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "discordRefreshToken"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "discordExpiresDate"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "discordId"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "discordUsername"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "discordDiscriminator"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "integrations" jsonb NOT NULL DEFAULT '{}'`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "integrations"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "discordDiscriminator" character varying(64) DEFAULT NULL`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "discordUsername" character varying(64) DEFAULT NULL`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "discordId" character varying(64) DEFAULT NULL`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "discordExpiresDate" character varying(64)`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "discordRefreshToken" character varying(64) DEFAULT NULL`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "discordAccessToken" character varying(64) DEFAULT NULL`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "discord" boolean NOT NULL DEFAULT false`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "githubLogin" character varying(64) DEFAULT NULL`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "githubId" character varying(64)`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "githubAccessToken" character varying(64) DEFAULT NULL`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "github" boolean NOT NULL DEFAULT false`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "twitterScreenName" character varying(64) DEFAULT NULL`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "twitterUserId" character varying(64) DEFAULT NULL`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "twitterAccessTokenSecret" character varying(64) DEFAULT NULL`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "twitterAccessToken" character varying(64) DEFAULT NULL`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "twitter" boolean NOT NULL DEFAULT false`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1580543501339-v12-13.ts b/packages/backend/src/migration/1580543501339-v12-13.ts new file mode 100644 index 0000000..0c8b59d --- /dev/null +++ b/packages/backend/src/migration/1580543501339-v12-13.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class v12131580543501339 implements MigrationInterface { + constructor() { + this.name = "v12131580543501339"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX "IDX_NOTE_TAGS" ON "note" USING gin ("tags")`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_NOTE_TAGS"`, undefined); + } +} diff --git a/packages/backend/src/migration/1580864313253-v12-14.ts b/packages/backend/src/migration/1580864313253-v12-14.ts new file mode 100644 index 0000000..a0032aa --- /dev/null +++ b/packages/backend/src/migration/1580864313253-v12-14.ts @@ -0,0 +1,42 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class v12141580864313253 implements MigrationInterface { + constructor() { + this.name = "v12141580864313253"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" RENAME COLUMN "proxyAccount" TO "proxyAccountId"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "proxyAccountId"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "proxyAccountId" character varying(32)`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD CONSTRAINT "FK_ab1bc0c1e209daa77b8e8d212ad" FOREIGN KEY ("proxyAccountId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP CONSTRAINT "FK_ab1bc0c1e209daa77b8e8d212ad"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "proxyAccountId"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "proxyAccountId" character varying(128)`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "meta" RENAME COLUMN "proxyAccountId" TO "proxyAccount"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1581526429287-user-group-invitation.ts b/packages/backend/src/migration/1581526429287-user-group-invitation.ts new file mode 100644 index 0000000..b8d8f2c --- /dev/null +++ b/packages/backend/src/migration/1581526429287-user-group-invitation.ts @@ -0,0 +1,108 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class userGroupInvitation1581526429287 implements MigrationInterface { + constructor() { + this.name = "userGroupInvitation1581526429287"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_group_invitation" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "userGroupId" character varying(32) NOT NULL, CONSTRAINT "PK_160c63ec02bf23f6a5c5e8140d6" PRIMARY KEY ("id"))`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_bfbc6305547539369fe73eb144" ON "user_group_invitation" ("userId") `, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_5cc8c468090e129857e9fecce5" ON "user_group_invitation" ("userGroupId") `, + undefined, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_e9793f65f504e5a31fbaedbf2f" ON "user_group_invitation" ("userId", "userGroupId") `, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" ADD "userGroupInvitationId" character varying(32)`, + undefined, + ); + await queryRunner.query( + `ALTER TYPE "public"."notification_type_enum" RENAME TO "notification_type_enum_old"`, + undefined, + ); + await queryRunner.query( + `CREATE TYPE "notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited')`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" ALTER COLUMN "type" TYPE "notification_type_enum" USING "type"::"text"::"notification_type_enum"`, + undefined, + ); + await queryRunner.query( + `DROP TYPE "notification_type_enum_old"`, + undefined, + ); + await queryRunner.query( + `COMMENT ON COLUMN "notification"."type" IS 'The type of the Notification.'`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_group_invitation" ADD CONSTRAINT "FK_bfbc6305547539369fe73eb144a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_group_invitation" ADD CONSTRAINT "FK_5cc8c468090e129857e9fecce5a" FOREIGN KEY ("userGroupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" ADD CONSTRAINT "FK_8fe87814e978053a53b1beb7e98" FOREIGN KEY ("userGroupInvitationId") REFERENCES "user_group_invitation"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "notification" DROP CONSTRAINT "FK_8fe87814e978053a53b1beb7e98"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_group_invitation" DROP CONSTRAINT "FK_5cc8c468090e129857e9fecce5a"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "user_group_invitation" DROP CONSTRAINT "FK_bfbc6305547539369fe73eb144a"`, + undefined, + ); + await queryRunner.query( + `COMMENT ON COLUMN "notification"."type" IS ''`, + undefined, + ); + await queryRunner.query( + `CREATE TYPE "notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted')`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" ALTER COLUMN "type" TYPE "notification_type_enum_old" USING "type"::"text"::"notification_type_enum_old"`, + undefined, + ); + await queryRunner.query(`DROP TYPE "notification_type_enum"`, undefined); + await queryRunner.query( + `ALTER TYPE "notification_type_enum_old" RENAME TO "notification_type_enum"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" DROP COLUMN "userGroupInvitationId"`, + undefined, + ); + await queryRunner.query( + `DROP INDEX "IDX_e9793f65f504e5a31fbaedbf2f"`, + undefined, + ); + await queryRunner.query( + `DROP INDEX "IDX_5cc8c468090e129857e9fecce5"`, + undefined, + ); + await queryRunner.query( + `DROP INDEX "IDX_bfbc6305547539369fe73eb144"`, + undefined, + ); + await queryRunner.query(`DROP TABLE "user_group_invitation"`, undefined); + } +} diff --git a/packages/backend/src/migration/1581695816408-user-group-antenna.ts b/packages/backend/src/migration/1581695816408-user-group-antenna.ts new file mode 100644 index 0000000..e6934a0 --- /dev/null +++ b/packages/backend/src/migration/1581695816408-user-group-antenna.ts @@ -0,0 +1,68 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class userGroupAntenna1581695816408 implements MigrationInterface { + constructor() { + this.name = "userGroupAntenna1581695816408"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "antenna" ADD "userGroupJoiningId" character varying(32)`, + undefined, + ); + await queryRunner.query( + `ALTER TYPE "public"."antenna_src_enum" RENAME TO "antenna_src_enum_old"`, + undefined, + ); + await queryRunner.query( + `CREATE TYPE "antenna_src_enum" AS ENUM('home', 'all', 'users', 'list', 'group')`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna" ALTER COLUMN "src" TYPE "antenna_src_enum" USING "src"::"text"::"antenna_src_enum"`, + undefined, + ); + await queryRunner.query(`DROP TYPE "antenna_src_enum_old"`, undefined); + await queryRunner.query( + `ALTER TABLE "antenna" DROP COLUMN "users"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna" ADD "users" character varying(1024) array NOT NULL DEFAULT '{}'::varchar[]`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna" ADD CONSTRAINT "FK_ccbf5a8c0be4511133dcc50ddeb" FOREIGN KEY ("userGroupJoiningId") REFERENCES "user_group_joining"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "antenna" DROP CONSTRAINT "FK_ccbf5a8c0be4511133dcc50ddeb"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna" DROP COLUMN "users"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna" ADD "users" character varying array NOT NULL DEFAULT '{}'`, + undefined, + ); + await queryRunner.query( + `CREATE TYPE "antenna_src_enum_old" AS ENUM('home', 'all', 'users', 'list')`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna" ALTER COLUMN "src" TYPE "antenna_src_enum_old" USING "src"::"text"::"antenna_src_enum_old"`, + undefined, + ); + await queryRunner.query(`DROP TYPE "antenna_src_enum"`, undefined); + await queryRunner.query( + `ALTER TYPE "antenna_src_enum_old" RENAME TO "antenna_src_enum"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna" DROP COLUMN "userGroupJoiningId"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1581708415836-drive-user-folder-id-index.ts b/packages/backend/src/migration/1581708415836-drive-user-folder-id-index.ts new file mode 100644 index 0000000..8bb1888 --- /dev/null +++ b/packages/backend/src/migration/1581708415836-drive-user-folder-id-index.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class driveUserFolderIdIndex1581708415836 implements MigrationInterface { + constructor() { + this.name = "driveUserFolderIdIndex1581708415836"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX "IDX_55720b33a61a7c806a8215b825" ON "drive_file" ("userId", "folderId", "id") `, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "IDX_55720b33a61a7c806a8215b825"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1581979837262-promo.ts b/packages/backend/src/migration/1581979837262-promo.ts new file mode 100644 index 0000000..c20f828 --- /dev/null +++ b/packages/backend/src/migration/1581979837262-promo.ts @@ -0,0 +1,68 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class promo1581979837262 implements MigrationInterface { + constructor() { + this.name = "promo1581979837262"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "promo_note" ("noteId" character varying(32) NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "REL_e263909ca4fe5d57f8d4230dd5" UNIQUE ("noteId"), CONSTRAINT "PK_e263909ca4fe5d57f8d4230dd5c" PRIMARY KEY ("noteId"))`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_83f0862e9bae44af52ced7099e" ON "promo_note" ("userId") `, + undefined, + ); + await queryRunner.query( + `CREATE TABLE "promo_read" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, CONSTRAINT "PK_61917c1541002422b703318b7c9" PRIMARY KEY ("id"))`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_9657d55550c3d37bfafaf7d4b0" ON "promo_read" ("userId") `, + undefined, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_2882b8a1a07c7d281a98b6db16" ON "promo_read" ("userId", "noteId") `, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "promo_note" ADD CONSTRAINT "FK_e263909ca4fe5d57f8d4230dd5c" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "promo_read" ADD CONSTRAINT "FK_9657d55550c3d37bfafaf7d4b05" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "promo_read" ADD CONSTRAINT "FK_a46a1a603ecee695d7db26da5f4" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "promo_read" DROP CONSTRAINT "FK_a46a1a603ecee695d7db26da5f4"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "promo_read" DROP CONSTRAINT "FK_9657d55550c3d37bfafaf7d4b05"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "promo_note" DROP CONSTRAINT "FK_e263909ca4fe5d57f8d4230dd5c"`, + undefined, + ); + await queryRunner.query( + `DROP INDEX "IDX_2882b8a1a07c7d281a98b6db16"`, + undefined, + ); + await queryRunner.query( + `DROP INDEX "IDX_9657d55550c3d37bfafaf7d4b0"`, + undefined, + ); + await queryRunner.query(`DROP TABLE "promo_read"`, undefined); + await queryRunner.query( + `DROP INDEX "IDX_83f0862e9bae44af52ced7099e"`, + undefined, + ); + await queryRunner.query(`DROP TABLE "promo_note"`, undefined); + } +} diff --git a/packages/backend/src/migration/1582019042083-featured-injecttion.ts b/packages/backend/src/migration/1582019042083-featured-injecttion.ts new file mode 100644 index 0000000..0fd69d2 --- /dev/null +++ b/packages/backend/src/migration/1582019042083-featured-injecttion.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class featuredInjecttion1582019042083 implements MigrationInterface { + constructor() { + this.name = "featuredInjecttion1582019042083"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "injectFeaturedNote" boolean NOT NULL DEFAULT true`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "injectFeaturedNote"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1582210532752-antenna-exclude.ts b/packages/backend/src/migration/1582210532752-antenna-exclude.ts new file mode 100644 index 0000000..ec647d7 --- /dev/null +++ b/packages/backend/src/migration/1582210532752-antenna-exclude.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class antennaExclude1582210532752 implements MigrationInterface { + constructor() { + this.name = "antennaExclude1582210532752"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "antenna" ADD "excludeKeywords" jsonb NOT NULL DEFAULT '[]'`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "antenna" DROP COLUMN "excludeKeywords"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1582875306439-note-reaction-length.ts b/packages/backend/src/migration/1582875306439-note-reaction-length.ts new file mode 100644 index 0000000..22c2452 --- /dev/null +++ b/packages/backend/src/migration/1582875306439-note-reaction-length.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class noteReactionLength1582875306439 implements MigrationInterface { + constructor() { + this.name = "noteReactionLength1582875306439"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "note_reaction" ALTER COLUMN "reaction" TYPE character varying(130)`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "note_reaction" ALTER COLUMN "reaction" TYPE character varying(128)`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1585361548360-miauth.ts b/packages/backend/src/migration/1585361548360-miauth.ts new file mode 100644 index 0000000..266f86d --- /dev/null +++ b/packages/backend/src/migration/1585361548360-miauth.ts @@ -0,0 +1,106 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class miauth1585361548360 implements MigrationInterface { + constructor() { + this.name = "miauth1585361548360"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "access_token" ADD "lastUsedAt" TIMESTAMP WITH TIME ZONE DEFAULT null`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" ADD "session" character varying(128) DEFAULT null`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" ADD "name" character varying(128) DEFAULT null`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" ADD "description" character varying(512) DEFAULT null`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" ADD "iconUrl" character varying(512) DEFAULT null`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" ADD "permission" character varying(64) array NOT NULL DEFAULT '{}'::varchar[]`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" ADD "fetched" boolean NOT NULL DEFAULT false`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" DROP CONSTRAINT "FK_a3ff16c90cc87a82a0b5959e560"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" ALTER COLUMN "appId" DROP NOT NULL`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" ALTER COLUMN "appId" SET DEFAULT null`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_bf3a053c07d9fb5d87317c56ee" ON "access_token" ("session") `, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" ADD CONSTRAINT "FK_a3ff16c90cc87a82a0b5959e560" FOREIGN KEY ("appId") REFERENCES "app"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "access_token" DROP CONSTRAINT "FK_a3ff16c90cc87a82a0b5959e560"`, + undefined, + ); + await queryRunner.query( + `DROP INDEX "IDX_bf3a053c07d9fb5d87317c56ee"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" ALTER COLUMN "appId" DROP DEFAULT`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" ALTER COLUMN "appId" SET NOT NULL`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" ADD CONSTRAINT "FK_a3ff16c90cc87a82a0b5959e560" FOREIGN KEY ("appId") REFERENCES "app"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" DROP COLUMN "fetched"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" DROP COLUMN "permission"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" DROP COLUMN "iconUrl"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" DROP COLUMN "description"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" DROP COLUMN "name"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" DROP COLUMN "session"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "access_token" DROP COLUMN "lastUsedAt"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1585385921215-custom-notification.ts b/packages/backend/src/migration/1585385921215-custom-notification.ts new file mode 100644 index 0000000..e7abcc7 --- /dev/null +++ b/packages/backend/src/migration/1585385921215-custom-notification.ts @@ -0,0 +1,151 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class customNotification1585385921215 implements MigrationInterface { + constructor() { + this.name = "customNotification1585385921215"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "notification" ADD "customBody" character varying(2048)`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" ADD "customHeader" character varying(256)`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" ADD "customIcon" character varying(1024)`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" ADD "appAccessTokenId" character varying(32)`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" DROP CONSTRAINT "FK_3b4e96eec8d36a8bbb9d02aa710"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" ALTER COLUMN "notifierId" DROP NOT NULL`, + undefined, + ); + await queryRunner.query( + `COMMENT ON COLUMN "notification"."notifierId" IS 'The ID of sender user of the Notification.'`, + undefined, + ); + await queryRunner.query( + `ALTER TYPE "public"."notification_type_enum" RENAME TO "notification_type_enum_old"`, + undefined, + ); + await queryRunner.query( + `CREATE TYPE "notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" ALTER COLUMN "type" TYPE "notification_type_enum" USING "type"::"text"::"notification_type_enum"`, + undefined, + ); + await queryRunner.query( + `DROP TYPE "notification_type_enum_old"`, + undefined, + ); + await queryRunner.query( + `COMMENT ON COLUMN "notification"."type" IS 'The type of the Notification.'`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_3b4e96eec8d36a8bbb9d02aa71" ON "notification" ("notifierId") `, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_33f33cc8ef29d805a97ff4628b" ON "notification" ("type") `, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_080ab397c379af09b9d2169e5b" ON "notification" ("isRead") `, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e22bf6bda77b6adc1fd9e75c8c" ON "notification" ("appAccessTokenId") `, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" ADD CONSTRAINT "FK_3b4e96eec8d36a8bbb9d02aa710" FOREIGN KEY ("notifierId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" ADD CONSTRAINT "FK_e22bf6bda77b6adc1fd9e75c8c9" FOREIGN KEY ("appAccessTokenId") REFERENCES "access_token"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "notification" DROP CONSTRAINT "FK_e22bf6bda77b6adc1fd9e75c8c9"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" DROP CONSTRAINT "FK_3b4e96eec8d36a8bbb9d02aa710"`, + undefined, + ); + await queryRunner.query( + `DROP INDEX "IDX_e22bf6bda77b6adc1fd9e75c8c"`, + undefined, + ); + await queryRunner.query( + `DROP INDEX "IDX_080ab397c379af09b9d2169e5b"`, + undefined, + ); + await queryRunner.query( + `DROP INDEX "IDX_33f33cc8ef29d805a97ff4628b"`, + undefined, + ); + await queryRunner.query( + `DROP INDEX "IDX_3b4e96eec8d36a8bbb9d02aa71"`, + undefined, + ); + await queryRunner.query( + `COMMENT ON COLUMN "notification"."type" IS ''`, + undefined, + ); + await queryRunner.query( + `CREATE TYPE "notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited')`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" ALTER COLUMN "type" TYPE "notification_type_enum_old" USING "type"::"text"::"notification_type_enum_old"`, + undefined, + ); + await queryRunner.query(`DROP TYPE "notification_type_enum"`, undefined); + await queryRunner.query( + `ALTER TYPE "notification_type_enum_old" RENAME TO "notification_type_enum"`, + undefined, + ); + await queryRunner.query( + `COMMENT ON COLUMN "notification"."notifierId" IS ''`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" ALTER COLUMN "notifierId" SET NOT NULL`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" ADD CONSTRAINT "FK_3b4e96eec8d36a8bbb9d02aa710" FOREIGN KEY ("notifierId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" DROP COLUMN "appAccessTokenId"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" DROP COLUMN "customIcon"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" DROP COLUMN "customHeader"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "notification" DROP COLUMN "customBody"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1585772678853-ap-url.ts b/packages/backend/src/migration/1585772678853-ap-url.ts new file mode 100644 index 0000000..a06a8f5 --- /dev/null +++ b/packages/backend/src/migration/1585772678853-ap-url.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class apUrl1585772678853 implements MigrationInterface { + constructor() { + this.name = "apUrl1585772678853"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "note" ADD "url" character varying(512)`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "url"`, undefined); + } +} diff --git a/packages/backend/src/migration/1586624197029-AddObjectStorageUseProxy.ts b/packages/backend/src/migration/1586624197029-AddObjectStorageUseProxy.ts new file mode 100644 index 0000000..aa1a090 --- /dev/null +++ b/packages/backend/src/migration/1586624197029-AddObjectStorageUseProxy.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class AddObjectStorageUseProxy1586624197029 implements MigrationInterface { + constructor() { + this.name = "AddObjectStorageUseProxy1586624197029"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "objectStorageUseProxy" boolean NOT NULL DEFAULT true`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "objectStorageUseProxy"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1586641139527-remote-reaction.ts b/packages/backend/src/migration/1586641139527-remote-reaction.ts new file mode 100644 index 0000000..7e8e8cc --- /dev/null +++ b/packages/backend/src/migration/1586641139527-remote-reaction.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class remoteReaction1586641139527 implements MigrationInterface { + constructor() { + this.name = "remoteReaction1586641139527"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "note_reaction" ALTER COLUMN "reaction" TYPE character varying(260)`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "note_reaction" ALTER COLUMN "reaction" TYPE character varying(130)`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1586708940386-pageAiScript.ts b/packages/backend/src/migration/1586708940386-pageAiScript.ts new file mode 100644 index 0000000..5ea3a03 --- /dev/null +++ b/packages/backend/src/migration/1586708940386-pageAiScript.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class pageAiScript1586708940386 implements MigrationInterface { + constructor() { + this.name = "pageAiScript1586708940386"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "page" ADD "script" character varying(16384) NOT NULL DEFAULT ''`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "page" DROP COLUMN "script"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1588044505511-hCaptcha.ts b/packages/backend/src/migration/1588044505511-hCaptcha.ts new file mode 100644 index 0000000..110ddd4 --- /dev/null +++ b/packages/backend/src/migration/1588044505511-hCaptcha.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class hCaptcha1588044505511 implements MigrationInterface { + constructor() { + this.name = "hCaptcha1588044505511"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "enableHcaptcha" boolean NOT NULL DEFAULT false`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "hcaptchaSiteKey" character varying(64)`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "hcaptchaSecretKey" character varying(64)`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "hcaptchaSecretKey"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "hcaptchaSiteKey"`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "enableHcaptcha"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1589023282116-pubRelay.ts b/packages/backend/src/migration/1589023282116-pubRelay.ts new file mode 100644 index 0000000..6c1b37f --- /dev/null +++ b/packages/backend/src/migration/1589023282116-pubRelay.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class pubRelay1589023282116 implements MigrationInterface { + constructor() { + this.name = "pubRelay1589023282116"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "relay_status_enum" AS ENUM('requesting', 'accepted', 'rejected')`, + undefined, + ); + await queryRunner.query( + `CREATE TABLE "relay" ("id" character varying(32) NOT NULL, "inbox" character varying(512) NOT NULL, "status" "relay_status_enum" NOT NULL, CONSTRAINT "PK_78ebc9cfddf4292633b7ba57aee" PRIMARY KEY ("id"))`, + undefined, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_0d9a1738f2cf7f3b1c3334dfab" ON "relay" ("inbox") `, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "IDX_0d9a1738f2cf7f3b1c3334dfab"`, + undefined, + ); + await queryRunner.query(`DROP TABLE "relay"`, undefined); + await queryRunner.query(`DROP TYPE "relay_status_enum"`, undefined); + } +} diff --git a/packages/backend/src/migration/1595075960584-blurhash.ts b/packages/backend/src/migration/1595075960584-blurhash.ts new file mode 100644 index 0000000..9b844e1 --- /dev/null +++ b/packages/backend/src/migration/1595075960584-blurhash.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class blurhash1595075960584 implements MigrationInterface { + constructor() { + this.name = "blurhash1595075960584"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "drive_file" ADD "blurhash" character varying(128)`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "blurhash"`); + } +} diff --git a/packages/backend/src/migration/1595077605646-blurhash-for-avatar-banner.ts b/packages/backend/src/migration/1595077605646-blurhash-for-avatar-banner.ts new file mode 100644 index 0000000..d67134f --- /dev/null +++ b/packages/backend/src/migration/1595077605646-blurhash-for-avatar-banner.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class blurhashForAvatarBanner1595077605646 implements MigrationInterface { + constructor() { + this.name = "blurhashForAvatarBanner1595077605646"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarColor"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerColor"`); + await queryRunner.query( + `ALTER TABLE "user" ADD "avatarBlurhash" character varying(128)`, + ); + await queryRunner.query( + `ALTER TABLE "user" ADD "bannerBlurhash" character varying(128)`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerBlurhash"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarBlurhash"`); + await queryRunner.query( + `ALTER TABLE "user" ADD "bannerColor" character varying(32)`, + ); + await queryRunner.query( + `ALTER TABLE "user" ADD "avatarColor" character varying(32)`, + ); + } +} diff --git a/packages/backend/src/migration/1595676934834-instance-icon-url.ts b/packages/backend/src/migration/1595676934834-instance-icon-url.ts new file mode 100644 index 0000000..7d147cf --- /dev/null +++ b/packages/backend/src/migration/1595676934834-instance-icon-url.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class instanceIconUrl1595676934834 implements MigrationInterface { + constructor() { + this.name = "instanceIconUrl1595676934834"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "instance" ADD "iconUrl" character varying(256) DEFAULT null`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "iconUrl"`); + } +} diff --git a/packages/backend/src/migration/1595771249699-word-mute.ts b/packages/backend/src/migration/1595771249699-word-mute.ts new file mode 100644 index 0000000..7255c45 --- /dev/null +++ b/packages/backend/src/migration/1595771249699-word-mute.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class wordMute1595771249699 implements MigrationInterface { + constructor() { + this.name = "wordMute1595771249699"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "muted_note" ("id" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "PK_897e2eff1c0b9b64e55ca1418a4" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_70ab9786313d78e4201d81cdb8" ON "muted_note" ("noteId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_d8e07aa18c2d64e86201601aec" ON "muted_note" ("userId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_a8c6bfd637d3f1d67a27c48e27" ON "muted_note" ("noteId", "userId") `, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "enableWordMute" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "mutedWords" jsonb NOT NULL DEFAULT '[]'`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_3befe6f999c86aff06eb0257b4" ON "user_profile" ("enableWordMute") `, + ); + await queryRunner.query( + `ALTER TABLE "muted_note" ADD CONSTRAINT "FK_70ab9786313d78e4201d81cdb89" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "muted_note" ADD CONSTRAINT "FK_d8e07aa18c2d64e86201601aec1" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "muted_note" DROP CONSTRAINT "FK_d8e07aa18c2d64e86201601aec1"`, + ); + await queryRunner.query( + `ALTER TABLE "muted_note" DROP CONSTRAINT "FK_70ab9786313d78e4201d81cdb89"`, + ); + await queryRunner.query(`DROP INDEX "IDX_3befe6f999c86aff06eb0257b4"`); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "mutedWords"`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "enableWordMute"`, + ); + await queryRunner.query(`DROP INDEX "IDX_a8c6bfd637d3f1d67a27c48e27"`); + await queryRunner.query(`DROP INDEX "IDX_d8e07aa18c2d64e86201601aec"`); + await queryRunner.query(`DROP INDEX "IDX_70ab9786313d78e4201d81cdb8"`); + await queryRunner.query(`DROP TABLE "muted_note"`); + } +} diff --git a/packages/backend/src/migration/1595782306083-word-mute2.ts b/packages/backend/src/migration/1595782306083-word-mute2.ts new file mode 100644 index 0000000..d8fe6da --- /dev/null +++ b/packages/backend/src/migration/1595782306083-word-mute2.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class wordMute21595782306083 implements MigrationInterface { + constructor() { + this.name = "wordMute21595782306083"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "muted_note_reason_enum" AS ENUM('word', 'manual', 'spam', 'other')`, + ); + await queryRunner.query( + `ALTER TABLE "muted_note" ADD "reason" "muted_note_reason_enum" NOT NULL`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_636e977ff90b23676fb5624b25" ON "muted_note" ("reason") `, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_636e977ff90b23676fb5624b25"`); + await queryRunner.query(`ALTER TABLE "muted_note" DROP COLUMN "reason"`); + await queryRunner.query(`DROP TYPE "muted_note_reason_enum"`); + } +} diff --git a/packages/backend/src/migration/1596548170836-channel.ts b/packages/backend/src/migration/1596548170836-channel.ts new file mode 100644 index 0000000..fe143a8 --- /dev/null +++ b/packages/backend/src/migration/1596548170836-channel.ts @@ -0,0 +1,116 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class channel1596548170836 implements MigrationInterface { + constructor() { + this.name = "channel1596548170836"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "channel" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "lastNotedAt" TIMESTAMP WITH TIME ZONE, "userId" character varying(32) NOT NULL, "name" character varying(128) NOT NULL, "description" character varying(2048), "bannerId" character varying(32), "notesCount" integer NOT NULL DEFAULT 0, "usersCount" integer NOT NULL DEFAULT 0, CONSTRAINT "PK_590f33ee6ee7d76437acf362e39" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_71cb7b435b7c0d4843317e7e16" ON "channel" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_29ef80c6f13bcea998447fce43" ON "channel" ("lastNotedAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_823bae55bd81b3be6e05cff438" ON "channel" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0f58c11241e649d2a638a8de94" ON "channel" ("notesCount") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_094b86cd36bb805d1aa1e8cc9a" ON "channel" ("usersCount") `, + ); + await queryRunner.query( + `CREATE TABLE "channel_following" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "followeeId" character varying(32) NOT NULL, "followerId" character varying(32) NOT NULL, CONSTRAINT "PK_8b104be7f7415113f2a02cd5bdd" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_11e71f2511589dcc8a4d3214f9" ON "channel_following" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0e43068c3f92cab197c3d3cd86" ON "channel_following" ("followeeId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_6d8084ec9496e7334a4602707e" ON "channel_following" ("followerId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_2e230dd45a10e671d781d99f3e" ON "channel_following" ("followerId", "followeeId") `, + ); + await queryRunner.query( + `CREATE TABLE "channel_note_pining" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "channelId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, CONSTRAINT "PK_44f7474496bcf2e4b741681146d" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_8125f950afd3093acb10d2db8a" ON "channel_note_pining" ("channelId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_f36fed37d6d4cdcc68c803cd9c" ON "channel_note_pining" ("channelId", "noteId") `, + ); + await queryRunner.query( + `ALTER TABLE "note" ADD "channelId" character varying(32) DEFAULT null`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f22169eb10657bded6d875ac8f" ON "note" ("channelId") `, + ); + await queryRunner.query( + `ALTER TABLE "channel" ADD CONSTRAINT "FK_823bae55bd81b3be6e05cff4383" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "channel" ADD CONSTRAINT "FK_999da2bcc7efadbfe0e92d3bc19" FOREIGN KEY ("bannerId") REFERENCES "drive_file"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "note" ADD CONSTRAINT "FK_f22169eb10657bded6d875ac8f9" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "channel_following" ADD CONSTRAINT "FK_0e43068c3f92cab197c3d3cd86e" FOREIGN KEY ("followeeId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "channel_following" ADD CONSTRAINT "FK_6d8084ec9496e7334a4602707e1" FOREIGN KEY ("followerId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "channel_note_pining" ADD CONSTRAINT "FK_8125f950afd3093acb10d2db8a8" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "channel_note_pining" ADD CONSTRAINT "FK_10b19ef67d297ea9de325cd4502" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "channel_note_pining" DROP CONSTRAINT "FK_10b19ef67d297ea9de325cd4502"`, + ); + await queryRunner.query( + `ALTER TABLE "channel_note_pining" DROP CONSTRAINT "FK_8125f950afd3093acb10d2db8a8"`, + ); + await queryRunner.query( + `ALTER TABLE "channel_following" DROP CONSTRAINT "FK_6d8084ec9496e7334a4602707e1"`, + ); + await queryRunner.query( + `ALTER TABLE "channel_following" DROP CONSTRAINT "FK_0e43068c3f92cab197c3d3cd86e"`, + ); + await queryRunner.query( + `ALTER TABLE "note" DROP CONSTRAINT "FK_f22169eb10657bded6d875ac8f9"`, + ); + await queryRunner.query( + `ALTER TABLE "channel" DROP CONSTRAINT "FK_999da2bcc7efadbfe0e92d3bc19"`, + ); + await queryRunner.query( + `ALTER TABLE "channel" DROP CONSTRAINT "FK_823bae55bd81b3be6e05cff4383"`, + ); + await queryRunner.query(`DROP INDEX "IDX_f22169eb10657bded6d875ac8f"`); + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "channelId"`); + await queryRunner.query(`DROP INDEX "IDX_f36fed37d6d4cdcc68c803cd9c"`); + await queryRunner.query(`DROP INDEX "IDX_8125f950afd3093acb10d2db8a"`); + await queryRunner.query(`DROP TABLE "channel_note_pining"`); + await queryRunner.query(`DROP INDEX "IDX_2e230dd45a10e671d781d99f3e"`); + await queryRunner.query(`DROP INDEX "IDX_6d8084ec9496e7334a4602707e"`); + await queryRunner.query(`DROP INDEX "IDX_0e43068c3f92cab197c3d3cd86"`); + await queryRunner.query(`DROP INDEX "IDX_11e71f2511589dcc8a4d3214f9"`); + await queryRunner.query(`DROP TABLE "channel_following"`); + await queryRunner.query(`DROP INDEX "IDX_094b86cd36bb805d1aa1e8cc9a"`); + await queryRunner.query(`DROP INDEX "IDX_0f58c11241e649d2a638a8de94"`); + await queryRunner.query(`DROP INDEX "IDX_823bae55bd81b3be6e05cff438"`); + await queryRunner.query(`DROP INDEX "IDX_29ef80c6f13bcea998447fce43"`); + await queryRunner.query(`DROP INDEX "IDX_71cb7b435b7c0d4843317e7e16"`); + await queryRunner.query(`DROP TABLE "channel"`); + } +} diff --git a/packages/backend/src/migration/1596786425167-channel2.ts b/packages/backend/src/migration/1596786425167-channel2.ts new file mode 100644 index 0000000..3bd35fd --- /dev/null +++ b/packages/backend/src/migration/1596786425167-channel2.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class channel21596786425167 implements MigrationInterface { + constructor() { + this.name = "channel21596786425167"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "channel_following" ADD "readCursor" TIMESTAMP WITH TIME ZONE NOT NULL`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "channel_following" DROP COLUMN "readCursor"`, + ); + } +} diff --git a/packages/backend/src/migration/1597230137744-objectStorageSetPublicRead.ts b/packages/backend/src/migration/1597230137744-objectStorageSetPublicRead.ts new file mode 100644 index 0000000..27610a0 --- /dev/null +++ b/packages/backend/src/migration/1597230137744-objectStorageSetPublicRead.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class objectStorageSetPublicRead1597230137744 implements MigrationInterface { + constructor() { + this.name = "objectStorageSetPublicRead1597230137744"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "objectStorageSetPublicRead" boolean NOT NULL DEFAULT false`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "objectStorageSetPublicRead"`, + ); + } +} diff --git a/packages/backend/src/migration/1597236229720-IncludingNotificationTypes.ts b/packages/backend/src/migration/1597236229720-IncludingNotificationTypes.ts new file mode 100644 index 0000000..3bc9b7d --- /dev/null +++ b/packages/backend/src/migration/1597236229720-IncludingNotificationTypes.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class IncludingNotificationTypes1597236229720 implements MigrationInterface { + constructor() { + this.name = "IncludingNotificationTypes1597236229720"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "user_profile_includingnotificationtypes_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "includingNotificationTypes" "user_profile_includingnotificationtypes_enum" array`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "includingNotificationTypes"`, + ); + await queryRunner.query( + `DROP TYPE "user_profile_includingnotificationtypes_enum"`, + ); + } +} diff --git a/packages/backend/src/migration/1597385880794-add-sensitive-index.ts b/packages/backend/src/migration/1597385880794-add-sensitive-index.ts new file mode 100644 index 0000000..8e14ac6 --- /dev/null +++ b/packages/backend/src/migration/1597385880794-add-sensitive-index.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class addSensitiveIndex1597385880794 implements MigrationInterface { + constructor() { + this.name = "addSensitiveIndex1597385880794"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX "IDX_a7eba67f8b3fa27271e85d2e26" ON "drive_file" ("isSensitive") `, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_a7eba67f8b3fa27271e85d2e26"`); + } +} diff --git a/packages/backend/src/migration/1597459042300-channel-unread.ts b/packages/backend/src/migration/1597459042300-channel-unread.ts new file mode 100644 index 0000000..64fd23f --- /dev/null +++ b/packages/backend/src/migration/1597459042300-channel-unread.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class channelUnread1597459042300 implements MigrationInterface { + constructor() { + this.name = "channelUnread1597459042300"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`TRUNCATE TABLE "note_unread"`, undefined); + await queryRunner.query( + `ALTER TABLE "channel_following" DROP COLUMN "readCursor"`, + ); + await queryRunner.query( + `ALTER TABLE "note_unread" ADD "isMentioned" boolean NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "note_unread" ADD "noteChannelId" character varying(32)`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_25b1dd384bec391b07b74b861c" ON "note_unread" ("isMentioned") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_89a29c9237b8c3b6b3cbb4cb30" ON "note_unread" ("isSpecified") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_29e8c1d579af54d4232939f994" ON "note_unread" ("noteUserId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_6a57f051d82c6d4036c141e107" ON "note_unread" ("noteChannelId") `, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_6a57f051d82c6d4036c141e107"`); + await queryRunner.query(`DROP INDEX "IDX_29e8c1d579af54d4232939f994"`); + await queryRunner.query(`DROP INDEX "IDX_89a29c9237b8c3b6b3cbb4cb30"`); + await queryRunner.query(`DROP INDEX "IDX_25b1dd384bec391b07b74b861c"`); + await queryRunner.query( + `ALTER TABLE "note_unread" DROP COLUMN "noteChannelId"`, + ); + await queryRunner.query( + `ALTER TABLE "note_unread" DROP COLUMN "isMentioned"`, + ); + await queryRunner.query( + `ALTER TABLE "channel_following" ADD "readCursor" TIMESTAMP WITH TIME ZONE NOT NULL`, + ); + } +} diff --git a/packages/backend/src/migration/1597893996136-ChannelNoteIdDescIndex.ts b/packages/backend/src/migration/1597893996136-ChannelNoteIdDescIndex.ts new file mode 100644 index 0000000..0ee24fe --- /dev/null +++ b/packages/backend/src/migration/1597893996136-ChannelNoteIdDescIndex.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class ChannelNoteIdDescIndex1597893996136 implements MigrationInterface { + constructor() { + this.name = "ChannelNoteIdDescIndex1597893996136"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_f22169eb10657bded6d875ac8f"`); + await queryRunner.query( + `CREATE INDEX "IDX_note_on_channelId_and_id_desc" ON "note" ("channelId", "id" desc)`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_note_on_channelId_and_id_desc"`); + await queryRunner.query( + `CREATE INDEX "IDX_f22169eb10657bded6d875ac8f" ON "note" ("channelId") `, + ); + } +} diff --git a/packages/backend/src/migration/1600353287890-mutingNotificationTypes.ts b/packages/backend/src/migration/1600353287890-mutingNotificationTypes.ts new file mode 100644 index 0000000..7663f12 --- /dev/null +++ b/packages/backend/src/migration/1600353287890-mutingNotificationTypes.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class mutingNotificationTypes1600353287890 implements MigrationInterface { + constructor() { + this.name = "mutingNotificationTypes1600353287890"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "includingNotificationTypes"`, + ); + await queryRunner.query( + `DROP TYPE "public"."user_profile_includingnotificationtypes_enum"`, + ); + await queryRunner.query( + `CREATE TYPE "user_profile_mutingnotificationtypes_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "mutingNotificationTypes" "user_profile_mutingnotificationtypes_enum" array NOT NULL DEFAULT '{}'`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "mutingNotificationTypes"`, + ); + await queryRunner.query( + `DROP TYPE "user_profile_mutingnotificationtypes_enum"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."user_profile_includingnotificationtypes_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "includingNotificationTypes" "user_profile_includingnotificationtypes_enum" array`, + ); + } +} diff --git a/packages/backend/src/migration/1603094348345-refine-abuse-user-report.ts b/packages/backend/src/migration/1603094348345-refine-abuse-user-report.ts new file mode 100644 index 0000000..5ba8e1e --- /dev/null +++ b/packages/backend/src/migration/1603094348345-refine-abuse-user-report.ts @@ -0,0 +1,64 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class refineAbuseUserReport1603094348345 implements MigrationInterface { + constructor() { + this.name = "refineAbuseUserReport1603094348345"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_d049123c413e68ca52abe734203"`, + ); + await queryRunner.query(`DROP INDEX "IDX_d049123c413e68ca52abe73420"`); + await queryRunner.query(`DROP INDEX "IDX_5cd442c3b2e74fdd99dae20243"`); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" RENAME COLUMN "userId" TO "targetUserId"`, + ); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" ADD "assigneeId" character varying(32)`, + ); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" ADD "resolved" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" DROP COLUMN "comment"`, + ); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" ADD "comment" character varying(2048) NOT NULL DEFAULT '{}'::varchar[]`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_2b15aaf4a0dc5be3499af7ab6a" ON "abuse_user_report" ("resolved") `, + ); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_08b883dd5fdd6f9c4c1572b36de" FOREIGN KEY ("assigneeId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_08b883dd5fdd6f9c4c1572b36de"`, + ); + await queryRunner.query(`DROP INDEX "IDX_2b15aaf4a0dc5be3499af7ab6a"`); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" DROP COLUMN "comment"`, + ); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" ADD "comment" character varying(512) NOT NULL DEFAULT '{}'::varchar[]`, + ); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" DROP COLUMN "resolved"`, + ); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" DROP COLUMN "assigneeId"`, + ); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" RENAME COLUMN "targetUserId" TO "userId"`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_5cd442c3b2e74fdd99dae20243" ON "abuse_user_report" ("userId", "reporterId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_d049123c413e68ca52abe73420" ON "abuse_user_report" ("userId") `, + ); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_d049123c413e68ca52abe734203" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } +} diff --git a/packages/backend/src/migration/1603095701770-refine-abuse-user-report2.ts b/packages/backend/src/migration/1603095701770-refine-abuse-user-report2.ts new file mode 100644 index 0000000..794f270 --- /dev/null +++ b/packages/backend/src/migration/1603095701770-refine-abuse-user-report2.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class refineAbuseUserReport21603095701770 implements MigrationInterface { + constructor() { + this.name = "refineAbuseUserReport21603095701770"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "abuse_user_report" ADD "targetUserHost" character varying(128)`, + ); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" ADD "reporterHost" character varying(128)`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_4ebbf7f93cdc10e8d1ef2fc6cd" ON "abuse_user_report" ("targetUserHost") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f8d8b93740ad12c4ce8213a199" ON "abuse_user_report" ("reporterHost") `, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_f8d8b93740ad12c4ce8213a199"`); + await queryRunner.query(`DROP INDEX "IDX_4ebbf7f93cdc10e8d1ef2fc6cd"`); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" DROP COLUMN "reporterHost"`, + ); + await queryRunner.query( + `ALTER TABLE "abuse_user_report" DROP COLUMN "targetUserHost"`, + ); + } +} diff --git a/packages/backend/src/migration/1603776877564-instance-theme-color.ts b/packages/backend/src/migration/1603776877564-instance-theme-color.ts new file mode 100644 index 0000000..c714670 --- /dev/null +++ b/packages/backend/src/migration/1603776877564-instance-theme-color.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class instanceThemeColor1603776877564 implements MigrationInterface { + constructor() { + this.name = "instanceThemeColor1603776877564"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "instance" ADD "themeColor" character varying(64) DEFAULT null`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "themeColor"`); + } +} diff --git a/packages/backend/src/migration/1603781553011-instance-favicon.ts b/packages/backend/src/migration/1603781553011-instance-favicon.ts new file mode 100644 index 0000000..c855a81 --- /dev/null +++ b/packages/backend/src/migration/1603781553011-instance-favicon.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class instanceFavicon1603781553011 implements MigrationInterface { + constructor() { + this.name = "instanceFavicon1603781553011"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "instance" ADD "faviconUrl" character varying(256) DEFAULT null`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "faviconUrl"`); + } +} diff --git a/packages/backend/src/migration/1604821689616-delete-auto-watch.ts b/packages/backend/src/migration/1604821689616-delete-auto-watch.ts new file mode 100644 index 0000000..f8577f4 --- /dev/null +++ b/packages/backend/src/migration/1604821689616-delete-auto-watch.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class deleteAutoWatch1604821689616 implements MigrationInterface { + constructor() { + this.name = "deleteAutoWatch1604821689616"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "autoWatch"`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "autoWatch" boolean NOT NULL DEFAULT false`, + ); + } +} diff --git a/packages/backend/src/migration/1605408848373-clip-description.ts b/packages/backend/src/migration/1605408848373-clip-description.ts new file mode 100644 index 0000000..2652fb2 --- /dev/null +++ b/packages/backend/src/migration/1605408848373-clip-description.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class clipDescription1605408848373 implements MigrationInterface { + constructor() { + this.name = "clipDescription1605408848373"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "clip" ADD "description" character varying(2048) DEFAULT null`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "clip" DROP COLUMN "description"`); + } +} diff --git a/packages/backend/src/migration/1605408971051-comments.ts b/packages/backend/src/migration/1605408971051-comments.ts new file mode 100644 index 0000000..7a3dd7e --- /dev/null +++ b/packages/backend/src/migration/1605408971051-comments.ts @@ -0,0 +1,1068 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class comments1605408971051 implements MigrationInterface { + constructor() { + this.name = "comments1605408971051"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `COMMENT ON COLUMN "log"."createdAt" IS 'The created date of the Log.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_folder"."createdAt" IS 'The created date of the DriveFolder.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_folder"."name" IS 'The name of the DriveFolder.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_folder"."userId" IS 'The owner ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_folder"."parentId" IS 'The parent folder ID. If null, it means the DriveFolder is located in root.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."createdAt" IS 'The created date of the DriveFile.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."userId" IS 'The owner ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."userHost" IS 'The host of owner. It will be null if the user in local.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."md5" IS 'The MD5 hash of the DriveFile.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."name" IS 'The file name of the DriveFile.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."type" IS 'The content type (MIME) of the DriveFile.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."size" IS 'The file size (bytes) of the DriveFile.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."comment" IS 'The comment of the DriveFile.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."blurhash" IS 'The BlurHash string.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."properties" IS 'The any properties of the DriveFile. For example, it includes image width/height.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."url" IS 'The URL of the DriveFile.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."thumbnailUrl" IS 'The URL of the thumbnail of the DriveFile.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."webpublicUrl" IS 'The URL of the webpublic of the DriveFile.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."uri" IS 'The URI of the DriveFile. it will be null when the DriveFile is local.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."folderId" IS 'The parent folder ID. If null, it means the DriveFile is located in root.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."isSensitive" IS 'Whether the DriveFile is NSFW.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."isLink" IS 'Whether the DriveFile is direct link to remote server.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."createdAt" IS 'The created date of the User.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."updatedAt" IS 'The updated date of the User.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."username" IS 'The username of the User.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."usernameLower" IS 'The username (lowercased) of the User.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."name" IS 'The name of the User.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."followersCount" IS 'The count of followers.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."followingCount" IS 'The count of following.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."notesCount" IS 'The count of notes.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."avatarId" IS 'The ID of avatar DriveFile.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."bannerId" IS 'The ID of banner DriveFile.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."isSuspended" IS 'Whether the User is suspended.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."isSilenced" IS 'Whether the User is silenced.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."isLocked" IS 'Whether the User is locked.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."isBot" IS 'Whether the User is a bot.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."isCat" IS 'Whether the User is a cat.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."isAdmin" IS 'Whether the User is the admin.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."isModerator" IS 'Whether the User is a moderator.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."host" IS 'The host of the User. It will be null if the origin of the user is local.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."inbox" IS 'The inbox URL of the User. It will be null if the origin of the user is local.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."sharedInbox" IS 'The sharedInbox URL of the User. It will be null if the origin of the user is local.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."featured" IS 'The featured URL of the User. It will be null if the origin of the user is local.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."uri" IS 'The URI of the User. It will be null if the origin of the user is local.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."token" IS 'The native access token of the User. It will be null if the origin of the user is local.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "app"."createdAt" IS 'The created date of the App.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "app"."userId" IS 'The owner ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "app"."secret" IS 'The secret key of the App.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "app"."name" IS 'The name of the App.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "app"."description" IS 'The description of the App.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "app"."permission" IS 'The permission of the App.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "app"."callbackUrl" IS 'The callbackUrl of the App.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "access_token"."createdAt" IS 'The created date of the AccessToken.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "access_token"."lastUsedAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "access_token"."session" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "access_token"."appId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "access_token"."name" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "access_token"."description" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "access_token"."iconUrl" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "channel"."createdAt" IS 'The created date of the Channel.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "channel"."userId" IS 'The owner ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "channel"."name" IS 'The name of the Channel.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "channel"."description" IS 'The description of the Channel.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "channel"."bannerId" IS 'The ID of banner Channel.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "channel"."notesCount" IS 'The count of notes.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "channel"."usersCount" IS 'The count of users.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note"."createdAt" IS 'The created date of the Note.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note"."replyId" IS 'The ID of reply target.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note"."renoteId" IS 'The ID of renote target.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note"."userId" IS 'The ID of author.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note"."uri" IS 'The URI of a note. it will be null when the note is local.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note"."url" IS 'The human readable url of a note. it will be null when the note is local.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note"."channelId" IS 'The ID of source channel.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note"."userHost" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note"."replyUserId" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note"."replyUserHost" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note"."renoteUserId" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note"."renoteUserHost" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "poll_vote"."createdAt" IS 'The created date of the PollVote.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note_reaction"."createdAt" IS 'The created date of the NoteReaction.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note_watching"."createdAt" IS 'The created date of the NoteWatching.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note_watching"."userId" IS 'The watcher ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note_watching"."noteId" IS 'The target Note ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note_watching"."noteUserId" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note_unread"."noteUserId" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note_unread"."noteChannelId" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."createdAt" IS 'The created date of the FollowRequest.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."followeeId" IS 'The followee user ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."followerId" IS 'The follower user ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."requestId" IS 'id of Follow Activity.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."followerHost" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."followerInbox" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."followerSharedInbox" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."followeeHost" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."followeeInbox" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."followeeSharedInbox" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_group"."createdAt" IS 'The created date of the UserGroup.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_group"."userId" IS 'The ID of owner.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_group_invitation"."createdAt" IS 'The created date of the UserGroupInvitation.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_group_invitation"."userId" IS 'The user ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_group_invitation"."userGroupId" IS 'The group ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "notification"."createdAt" IS 'The created date of the Notification.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "notification"."notifieeId" IS 'The ID of recipient user of the Notification.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "notification"."isRead" IS 'Whether the Notification is read.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "meta"."localDriveCapacityMb" IS 'Drive capacity of a local user (MB)'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "meta"."remoteDriveCapacityMb" IS 'Drive capacity of a remote user (MB)'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "meta"."maxNoteTextLength" IS 'Max allowed note text length in characters'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "following"."createdAt" IS 'The created date of the Following.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "following"."followeeId" IS 'The followee user ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "following"."followerId" IS 'The follower user ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "following"."followerHost" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "following"."followerInbox" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "following"."followerSharedInbox" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "following"."followeeHost" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "following"."followeeInbox" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "following"."followeeSharedInbox" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."caughtAt" IS 'The caught date of the Instance.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."host" IS 'The host of the Instance.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."usersCount" IS 'The count of the users of the Instance.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."notesCount" IS 'The count of the notes of the Instance.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."softwareName" IS 'The software of the Instance.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."softwareVersion" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."openRegistrations" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "instance"."name" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."description" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."maintainerName" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."maintainerEmail" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "instance"."iconUrl" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."faviconUrl" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."themeColor" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "muting"."createdAt" IS 'The created date of the Muting.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "muting"."muteeId" IS 'The mutee user ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "muting"."muterId" IS 'The muter user ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "blocking"."createdAt" IS 'The created date of the Blocking.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "blocking"."blockeeId" IS 'The blockee user ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "blocking"."blockerId" IS 'The blocker user ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_list"."createdAt" IS 'The created date of the UserList.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_list"."userId" IS 'The owner ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_list"."name" IS 'The name of the UserList.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_list_joining"."createdAt" IS 'The created date of the UserListJoining.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_list_joining"."userId" IS 'The user ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_list_joining"."userListId" IS 'The list ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_group_joining"."createdAt" IS 'The created date of the UserGroupJoining.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_group_joining"."userId" IS 'The user ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_group_joining"."userGroupId" IS 'The group ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note_favorite"."createdAt" IS 'The created date of the NoteFavorite.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "abuse_user_report"."createdAt" IS 'The created date of the AbuseUserReport.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "abuse_user_report"."targetUserHost" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "abuse_user_report"."reporterHost" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "messaging_message"."createdAt" IS 'The created date of the MessagingMessage.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "messaging_message"."userId" IS 'The sender user ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "messaging_message"."groupId" IS 'The recipient group ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "signin"."createdAt" IS 'The created date of the Signin.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "auth_session"."createdAt" IS 'The created date of the AuthSession.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "reversi_game"."createdAt" IS 'The created date of the ReversiGame.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "reversi_game"."startedAt" IS 'The started date of the ReversiGame.'`, + ); + await queryRunner.query(`COMMENT ON COLUMN "reversi_game"."form1" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "reversi_game"."form2" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "reversi_matching"."createdAt" IS 'The created date of the ReversiMatching.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_note_pining"."createdAt" IS 'The created date of the UserNotePinings.'`, + ); + await queryRunner.query(`COMMENT ON COLUMN "poll"."noteId" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "poll"."noteVisibility" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "poll"."userId" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "poll"."userHost" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_keypair"."userId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_publickey"."userId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "page"."createdAt" IS 'The created date of the Page.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "page"."updatedAt" IS 'The updated date of the Page.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "page"."userId" IS 'The ID of author.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."userId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."location" IS 'The location of the User.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."birthday" IS 'The birthday (YYYY-MM-DD) of the User.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."description" IS 'The description (bio) of the User.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."url" IS 'Remote URL of the user.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."email" IS 'The email address of the User.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."password" IS 'The password hash of the User. It will be null if the origin of the user is local.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."clientData" IS 'The client-specific data of the User.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."room" IS 'The room data of the User.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."userHost" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_security_key"."id" IS 'Variable-length id given to navigator.credentials.get()'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_security_key"."publicKey" IS 'Variable-length public key used to verify attestations (hex-encoded).'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_security_key"."lastUsed" IS 'The date of the last time the UserSecurityKey was successfully validated.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_security_key"."name" IS 'User-defined name for this key'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "attestation_challenge"."challenge" IS 'Hex-encoded sha256 hash of the challenge.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "attestation_challenge"."createdAt" IS 'The date challenge was created for expiry purposes.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "attestation_challenge"."registrationChallenge" IS 'Indicates that the challenge is only for registration purposes if true to prevent the challenge for being used as authentication.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "moderation_log"."createdAt" IS 'The created date of the ModerationLog.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "announcement"."createdAt" IS 'The created date of the Announcement.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "announcement"."updatedAt" IS 'The updated date of the Announcement.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "announcement_read"."createdAt" IS 'The created date of the AnnouncementRead.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "clip"."createdAt" IS 'The created date of the Clip.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "clip"."userId" IS 'The owner ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "clip"."name" IS 'The name of the Clip.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "clip"."description" IS 'The description of the Clip.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "clip_note"."noteId" IS 'The note ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "clip_note"."clipId" IS 'The clip ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "antenna"."createdAt" IS 'The created date of the Antenna.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "antenna"."userId" IS 'The owner ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "antenna"."name" IS 'The name of the Antenna.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "antenna_note"."noteId" IS 'The note ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "antenna_note"."antennaId" IS 'The antenna ID.'`, + ); + await queryRunner.query(`COMMENT ON COLUMN "promo_note"."noteId" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "promo_note"."userId" IS '[Denormalized]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "promo_read"."createdAt" IS 'The created date of the PromoRead.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "muted_note"."noteId" IS 'The note ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "muted_note"."userId" IS 'The user ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "muted_note"."reason" IS 'The reason of the MutedNote.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "channel_following"."createdAt" IS 'The created date of the ChannelFollowing.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "channel_following"."followeeId" IS 'The followee channel ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "channel_following"."followerId" IS 'The follower user ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "channel_note_pining"."createdAt" IS 'The created date of the ChannelNotePining.'`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `COMMENT ON COLUMN "channel_note_pining"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "channel_following"."followerId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "channel_following"."followeeId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "channel_following"."createdAt" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "muted_note"."reason" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "muted_note"."userId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "muted_note"."noteId" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "promo_read"."createdAt" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "promo_note"."userId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "promo_note"."noteId" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "antenna_note"."antennaId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "antenna_note"."noteId" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "antenna"."name" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "antenna"."userId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "antenna"."createdAt" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "clip_note"."clipId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "clip_note"."noteId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "clip"."description" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "clip"."name" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "clip"."userId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "clip"."createdAt" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "announcement_read"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "announcement"."updatedAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "announcement"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "moderation_log"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "attestation_challenge"."registrationChallenge" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "attestation_challenge"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "attestation_challenge"."challenge" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_security_key"."name" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_security_key"."lastUsed" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_security_key"."publicKey" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_security_key"."id" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."userHost" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "user_profile"."room" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."clientData" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."password" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "user_profile"."email" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user_profile"."url" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."description" IS 'The description (bio) of the User.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."birthday" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."location" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."userId" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "page"."userId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "page"."updatedAt" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "page"."createdAt" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "user_publickey"."userId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_keypair"."userId" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "poll"."userHost" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "poll"."userId" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "poll"."noteVisibility" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "poll"."noteId" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "user_note_pining"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "reversi_matching"."createdAt" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "reversi_game"."form2" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "reversi_game"."form1" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "reversi_game"."startedAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "reversi_game"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "auth_session"."createdAt" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "signin"."createdAt" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "messaging_message"."groupId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "messaging_message"."userId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "messaging_message"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "abuse_user_report"."reporterHost" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "abuse_user_report"."targetUserHost" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "abuse_user_report"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note_favorite"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_group_joining"."userGroupId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_group_joining"."userId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_group_joining"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_list_joining"."userListId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_list_joining"."userId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_list_joining"."createdAt" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "user_list"."name" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user_list"."userId" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "user_list"."createdAt" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "blocking"."blockerId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "blocking"."blockeeId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "blocking"."createdAt" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "muting"."muterId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "muting"."muteeId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "muting"."createdAt" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."themeColor" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."faviconUrl" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "instance"."iconUrl" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."maintainerEmail" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."maintainerName" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."description" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "instance"."name" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."openRegistrations" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."softwareVersion" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."softwareName" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."notesCount" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "instance"."usersCount" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "instance"."host" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "instance"."caughtAt" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "following"."followeeSharedInbox" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "following"."followeeInbox" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "following"."followeeHost" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "following"."followerSharedInbox" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "following"."followerInbox" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "following"."followerHost" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "following"."followerId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "following"."followeeId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "following"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "meta"."maxNoteTextLength" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "meta"."remoteDriveCapacityMb" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "meta"."localDriveCapacityMb" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "notification"."isRead" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "notification"."notifieeId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "notification"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_group_invitation"."userGroupId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_group_invitation"."userId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_group_invitation"."createdAt" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "user_group"."userId" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "user_group"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."followeeSharedInbox" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."followeeInbox" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."followeeHost" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."followerSharedInbox" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."followerInbox" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."followerHost" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."requestId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."followerId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."followeeId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "follow_request"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note_unread"."noteChannelId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note_unread"."noteUserId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note_watching"."noteUserId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note_watching"."noteId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note_watching"."userId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note_watching"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note_reaction"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "poll_vote"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "note"."renoteUserHost" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "note"."renoteUserId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "note"."replyUserHost" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "note"."replyUserId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "note"."userHost" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "note"."channelId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "note"."url" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "note"."uri" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "note"."userId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "note"."renoteId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "note"."replyId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "note"."createdAt" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "channel"."usersCount" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "channel"."notesCount" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "channel"."bannerId" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "channel"."description" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "channel"."name" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "channel"."userId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "channel"."createdAt" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "access_token"."iconUrl" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "access_token"."description" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "access_token"."name" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "access_token"."appId" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "access_token"."session" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "access_token"."lastUsedAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "access_token"."createdAt" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "app"."callbackUrl" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "app"."permission" IS 'The permission of the App.'`, + ); + await queryRunner.query(`COMMENT ON COLUMN "app"."description" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "app"."name" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "app"."secret" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "app"."userId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "app"."createdAt" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."token" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."uri" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."featured" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."sharedInbox" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."inbox" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."host" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."isModerator" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."isAdmin" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."isCat" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."isBot" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."isLocked" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."isSilenced" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."isSuspended" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."bannerId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."avatarId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."notesCount" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "user"."followingCount" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."followersCount" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "user"."name" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."usernameLower" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."username" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."updatedAt" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "user"."createdAt" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "drive_file"."isLink" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."isSensitive" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."folderId" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "drive_file"."uri" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."webpublicUrl" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."thumbnailUrl" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "drive_file"."url" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."properties" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."blurhash" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "drive_file"."comment" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "drive_file"."size" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "drive_file"."type" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "drive_file"."name" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "drive_file"."md5" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."userHost" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "drive_file"."userId" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."createdAt" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_folder"."parentId" IS NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_folder"."userId" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "drive_folder"."name" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "drive_folder"."createdAt" IS NULL`, + ); + await queryRunner.query(`COMMENT ON COLUMN "log"."createdAt" IS NULL`); + } +} diff --git a/packages/backend/src/migration/1605585339718-instance-pinned-pages.ts b/packages/backend/src/migration/1605585339718-instance-pinned-pages.ts new file mode 100644 index 0000000..4105b7c --- /dev/null +++ b/packages/backend/src/migration/1605585339718-instance-pinned-pages.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class instancePinnedPages1605585339718 implements MigrationInterface { + constructor() { + this.name = "instancePinnedPages1605585339718"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "pinnedPages" character varying(512) array NOT NULL DEFAULT '{"/featured", "/channels", "/explore", "/pages", "/about-misskey"}'::varchar[]`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "pinnedPages"`); + } +} diff --git a/packages/backend/src/migration/1605965516823-instance-images.ts b/packages/backend/src/migration/1605965516823-instance-images.ts new file mode 100644 index 0000000..24730d5 --- /dev/null +++ b/packages/backend/src/migration/1605965516823-instance-images.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class instanceImages1605965516823 implements MigrationInterface { + constructor() { + this.name = "instanceImages1605965516823"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "backgroundImageUrl" character varying(512)`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "logoImageUrl" character varying(512)`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "logoImageUrl"`); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "backgroundImageUrl"`, + ); + } +} diff --git a/packages/backend/src/migration/1606191203881-no-crawle.ts b/packages/backend/src/migration/1606191203881-no-crawle.ts new file mode 100644 index 0000000..1fd5c0e --- /dev/null +++ b/packages/backend/src/migration/1606191203881-no-crawle.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class noCrawle1606191203881 implements MigrationInterface { + constructor() { + this.name = "noCrawle1606191203881"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "noCrawle" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."noCrawle" IS 'Whether reject index by crawler.'`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."noCrawle" IS 'Whether reject index by crawler.'`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "noCrawle"`, + ); + } +} diff --git a/packages/backend/src/migration/1607151207216-instance-pinned-clip.ts b/packages/backend/src/migration/1607151207216-instance-pinned-clip.ts new file mode 100644 index 0000000..8e069c6 --- /dev/null +++ b/packages/backend/src/migration/1607151207216-instance-pinned-clip.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class instancePinnedClip1607151207216 implements MigrationInterface { + constructor() { + this.name = "instancePinnedClip1607151207216"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "pinnedClipId" character varying(32)`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "pinnedClipId"`); + } +} diff --git a/packages/backend/src/migration/1607353487793-isExplorable.ts b/packages/backend/src/migration/1607353487793-isExplorable.ts new file mode 100644 index 0000000..59ce435 --- /dev/null +++ b/packages/backend/src/migration/1607353487793-isExplorable.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class isExplorable1607353487793 implements MigrationInterface { + constructor() { + this.name = "isExplorable1607353487793"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD "isExplorable" boolean NOT NULL DEFAULT true`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."isExplorable" IS 'Whether the User is explorable.'`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_d5a1b83c7cab66f167e6888188" ON "user" ("isExplorable") `, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_d5a1b83c7cab66f167e6888188"`); + await queryRunner.query( + `COMMENT ON COLUMN "user"."isExplorable" IS 'Whether the User is explorable.'`, + ); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isExplorable"`); + } +} diff --git a/packages/backend/src/migration/1610277136869-registry.ts b/packages/backend/src/migration/1610277136869-registry.ts new file mode 100644 index 0000000..5393482 --- /dev/null +++ b/packages/backend/src/migration/1610277136869-registry.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class registry1610277136869 implements MigrationInterface { + constructor() { + this.name = "registry1610277136869"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "registry_item" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "key" character varying(1024) NOT NULL, "scope" character varying(1024) array NOT NULL DEFAULT '{}'::varchar[], "domain" character varying(512), CONSTRAINT "PK_64b3f7e6008b4d89b826cd3af95" PRIMARY KEY ("id")); COMMENT ON COLUMN "registry_item"."createdAt" IS 'The created date of the RegistryItem.'; COMMENT ON COLUMN "registry_item"."updatedAt" IS 'The updated date of the RegistryItem.'; COMMENT ON COLUMN "registry_item"."userId" IS 'The owner ID.'; COMMENT ON COLUMN "registry_item"."key" IS 'The key of the RegistryItem.'`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_fb9d21ba0abb83223263df6bcb" ON "registry_item" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_22baca135bb8a3ea1a83d13df3" ON "registry_item" ("scope") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0a72bdfcdb97c0eca11fe7ecad" ON "registry_item" ("domain") `, + ); + await queryRunner.query( + `ALTER TABLE "registry_item" ADD CONSTRAINT "FK_fb9d21ba0abb83223263df6bcb3" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "registry_item" DROP CONSTRAINT "FK_fb9d21ba0abb83223263df6bcb3"`, + ); + await queryRunner.query(`DROP INDEX "IDX_0a72bdfcdb97c0eca11fe7ecad"`); + await queryRunner.query(`DROP INDEX "IDX_22baca135bb8a3ea1a83d13df3"`); + await queryRunner.query(`DROP INDEX "IDX_fb9d21ba0abb83223263df6bcb"`); + await queryRunner.query(`DROP TABLE "registry_item"`); + } +} diff --git a/packages/backend/src/migration/1610277585759-registry2.ts b/packages/backend/src/migration/1610277585759-registry2.ts new file mode 100644 index 0000000..587c9da --- /dev/null +++ b/packages/backend/src/migration/1610277585759-registry2.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class registry21610277585759 implements MigrationInterface { + constructor() { + this.name = "registry21610277585759"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "registry_item" ADD "value" jsonb NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "registry_item"."value" IS 'The value of the RegistryItem.'`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `COMMENT ON COLUMN "registry_item"."value" IS 'The value of the RegistryItem.'`, + ); + await queryRunner.query(`ALTER TABLE "registry_item" DROP COLUMN "value"`); + } +} diff --git a/packages/backend/src/migration/1610283021566-registry3.ts b/packages/backend/src/migration/1610283021566-registry3.ts new file mode 100644 index 0000000..6f968bd --- /dev/null +++ b/packages/backend/src/migration/1610283021566-registry3.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class registry31610283021566 implements MigrationInterface { + constructor() { + this.name = "registry31610283021566"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "registry_item" ALTER COLUMN "value" DROP NOT NULL`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "registry_item" ALTER COLUMN "value" SET NOT NULL`, + ); + } +} diff --git a/packages/backend/src/migration/1611354329133-followersUri.ts b/packages/backend/src/migration/1611354329133-followersUri.ts new file mode 100644 index 0000000..c00da94 --- /dev/null +++ b/packages/backend/src/migration/1611354329133-followersUri.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class followersUri1611354329133 implements MigrationInterface { + constructor() { + this.name = "followersUri1611354329133"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD "followersUri" varchar(512) DEFAULT NULL`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."followersUri" IS 'The URI of the user Follower Collection. It will be null if the origin of the user is local.'`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `COMMENT ON COLUMN "user"."followersUri" IS 'The URI of the user Follower Collection. It will be null if the origin of the user is local.'`, + ); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "followersUri"`); + } +} diff --git a/packages/backend/src/migration/1611397665007-gallery.ts b/packages/backend/src/migration/1611397665007-gallery.ts new file mode 100644 index 0000000..dc5021a --- /dev/null +++ b/packages/backend/src/migration/1611397665007-gallery.ts @@ -0,0 +1,72 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class gallery1611397665007 implements MigrationInterface { + constructor() { + this.name = "gallery1611397665007"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "gallery_post" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "title" character varying(256) NOT NULL, "description" character varying(2048), "userId" character varying(32) NOT NULL, "fileIds" character varying(32) array NOT NULL DEFAULT '{}'::varchar[], "isSensitive" boolean NOT NULL DEFAULT false, "likedCount" integer NOT NULL DEFAULT '0', "tags" character varying(128) array NOT NULL DEFAULT '{}'::varchar[], CONSTRAINT "PK_8e90d7b6015f2c4518881b14753" PRIMARY KEY ("id")); COMMENT ON COLUMN "gallery_post"."createdAt" IS 'The created date of the GalleryPost.'; COMMENT ON COLUMN "gallery_post"."updatedAt" IS 'The updated date of the GalleryPost.'; COMMENT ON COLUMN "gallery_post"."userId" IS 'The ID of author.'; COMMENT ON COLUMN "gallery_post"."isSensitive" IS 'Whether the post is sensitive.'`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_8f1a239bd077c8864a20c62c2c" ON "gallery_post" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f631d37835adb04792e361807c" ON "gallery_post" ("updatedAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_985b836dddd8615e432d7043dd" ON "gallery_post" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_3ca50563facd913c425e7a89ee" ON "gallery_post" ("fileIds") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f2d744d9a14d0dfb8b96cb7fc5" ON "gallery_post" ("isSensitive") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_1a165c68a49d08f11caffbd206" ON "gallery_post" ("likedCount") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_05cca34b985d1b8edc1d1e28df" ON "gallery_post" ("tags") `, + ); + await queryRunner.query( + `CREATE TABLE "gallery_like" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "postId" character varying(32) NOT NULL, CONSTRAINT "PK_853ab02be39b8de45cd720cc15f" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_8fd5215095473061855ceb948c" ON "gallery_like" ("userId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_df1b5f4099e99fb0bc5eae53b6" ON "gallery_like" ("userId", "postId") `, + ); + await queryRunner.query( + `ALTER TABLE "gallery_post" ADD CONSTRAINT "FK_985b836dddd8615e432d7043ddb" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "gallery_like" ADD CONSTRAINT "FK_8fd5215095473061855ceb948cf" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "gallery_like" ADD CONSTRAINT "FK_b1cb568bfe569e47b7051699fc8" FOREIGN KEY ("postId") REFERENCES "gallery_post"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "gallery_like" DROP CONSTRAINT "FK_b1cb568bfe569e47b7051699fc8"`, + ); + await queryRunner.query( + `ALTER TABLE "gallery_like" DROP CONSTRAINT "FK_8fd5215095473061855ceb948cf"`, + ); + await queryRunner.query( + `ALTER TABLE "gallery_post" DROP CONSTRAINT "FK_985b836dddd8615e432d7043ddb"`, + ); + await queryRunner.query(`DROP INDEX "IDX_df1b5f4099e99fb0bc5eae53b6"`); + await queryRunner.query(`DROP INDEX "IDX_8fd5215095473061855ceb948c"`); + await queryRunner.query(`DROP TABLE "gallery_like"`); + await queryRunner.query(`DROP INDEX "IDX_05cca34b985d1b8edc1d1e28df"`); + await queryRunner.query(`DROP INDEX "IDX_1a165c68a49d08f11caffbd206"`); + await queryRunner.query(`DROP INDEX "IDX_f2d744d9a14d0dfb8b96cb7fc5"`); + await queryRunner.query(`DROP INDEX "IDX_3ca50563facd913c425e7a89ee"`); + await queryRunner.query(`DROP INDEX "IDX_985b836dddd8615e432d7043dd"`); + await queryRunner.query(`DROP INDEX "IDX_f631d37835adb04792e361807c"`); + await queryRunner.query(`DROP INDEX "IDX_8f1a239bd077c8864a20c62c2c"`); + await queryRunner.query(`DROP TABLE "gallery_post"`); + } +} diff --git a/packages/backend/src/migration/1611547387175-objectStorageS3ForcePathStyle.ts b/packages/backend/src/migration/1611547387175-objectStorageS3ForcePathStyle.ts new file mode 100644 index 0000000..20e2924 --- /dev/null +++ b/packages/backend/src/migration/1611547387175-objectStorageS3ForcePathStyle.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class objectStorageS3ForcePathStyle1611547387175 implements MigrationInterface { + constructor() { + this.name = "objectStorageS3ForcePathStyle1611547387175"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "objectStorageS3ForcePathStyle" boolean NOT NULL DEFAULT true`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "objectStorageS3ForcePathStyle"`, + ); + } +} diff --git a/packages/backend/src/migration/1612619156584-announcement-email.ts b/packages/backend/src/migration/1612619156584-announcement-email.ts new file mode 100644 index 0000000..6309a4a --- /dev/null +++ b/packages/backend/src/migration/1612619156584-announcement-email.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class announcementEmail1612619156584 implements MigrationInterface { + constructor() { + this.name = "announcementEmail1612619156584"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "receiveAnnouncementEmail" boolean NOT NULL DEFAULT true`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "receiveAnnouncementEmail"`, + ); + } +} diff --git a/packages/backend/src/migration/1613155914446-emailNotificationTypes.ts b/packages/backend/src/migration/1613155914446-emailNotificationTypes.ts new file mode 100644 index 0000000..ae8a42c --- /dev/null +++ b/packages/backend/src/migration/1613155914446-emailNotificationTypes.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class emailNotificationTypes1613155914446 implements MigrationInterface { + constructor() { + this.name = "emailNotificationTypes1613155914446"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "emailNotificationTypes" jsonb NOT NULL DEFAULT '["follow","receiveFollowRequest","groupInvited"]'`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "emailNotificationTypes"`, + ); + } +} diff --git a/packages/backend/src/migration/1613181457597-user-lang.ts b/packages/backend/src/migration/1613181457597-user-lang.ts new file mode 100644 index 0000000..a726226 --- /dev/null +++ b/packages/backend/src/migration/1613181457597-user-lang.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class userLang1613181457597 implements MigrationInterface { + constructor() { + this.name = "userLang1613181457597"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "lang" character varying(32)`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "lang"`); + } +} diff --git a/packages/backend/src/migration/1613503367223-use-bigint-for-driveUsage.ts b/packages/backend/src/migration/1613503367223-use-bigint-for-driveUsage.ts new file mode 100644 index 0000000..1da4bd1 --- /dev/null +++ b/packages/backend/src/migration/1613503367223-use-bigint-for-driveUsage.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class useBigintForDriveUsage1613503367223 implements MigrationInterface { + constructor() { + this.name = "useBigintForDriveUsage1613503367223"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "instance" ALTER COLUMN "driveUsage" TYPE bigint`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "driveUsage"`); + await queryRunner.query( + `ALTER TABLE "instance" ADD "driveUsage" integer NOT NULL DEFAULT 0`, + ); + } +} diff --git a/packages/backend/src/migration/1615965918224-chart-v2.ts b/packages/backend/src/migration/1615965918224-chart-v2.ts new file mode 100644 index 0000000..28e0f75 --- /dev/null +++ b/packages/backend/src/migration/1615965918224-chart-v2.ts @@ -0,0 +1,509 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartV21615965918224 implements MigrationInterface { + constructor() { + this.name = "chartV21615965918224"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "__chart__active_users" WHERE "span" = 'day'`, + ); + await queryRunner.query( + `DELETE FROM "__chart__drive" WHERE "span" = 'day'`, + ); + await queryRunner.query( + `DELETE FROM "__chart__federation" WHERE "span" = 'day'`, + ); + await queryRunner.query( + `DELETE FROM "__chart__hashtag" WHERE "span" = 'day'`, + ); + await queryRunner.query( + `DELETE FROM "__chart__instance" WHERE "span" = 'day'`, + ); + await queryRunner.query( + `DELETE FROM "__chart__network" WHERE "span" = 'day'`, + ); + await queryRunner.query( + `DELETE FROM "__chart__notes" WHERE "span" = 'day'`, + ); + await queryRunner.query( + `DELETE FROM "__chart__per_user_drive" WHERE "span" = 'day'`, + ); + await queryRunner.query( + `DELETE FROM "__chart__per_user_following" WHERE "span" = 'day'`, + ); + await queryRunner.query( + `DELETE FROM "__chart__per_user_notes" WHERE "span" = 'day'`, + ); + await queryRunner.query( + `DELETE FROM "__chart__per_user_reaction" WHERE "span" = 'day'`, + ); + await queryRunner.query(`DELETE FROM "__chart__test" WHERE "span" = 'day'`); + await queryRunner.query( + `DELETE FROM "__chart__test_grouped" WHERE "span" = 'day'`, + ); + await queryRunner.query( + `DELETE FROM "__chart__test_unique" WHERE "span" = 'day'`, + ); + await queryRunner.query( + `DELETE FROM "__chart__users" WHERE "span" = 'day'`, + ); + await queryRunner.query(`DROP INDEX "IDX_15e91a03aeeac9dbccdf43fc06"`); + await queryRunner.query(`DROP INDEX "IDX_20f57cc8f142c131340ee16742"`); + await queryRunner.query(`DROP INDEX "IDX_c26e2c1cbb6e911e0554b27416"`); + await queryRunner.query(`DROP INDEX "IDX_3fa0d0f17ca72e3dc80999a032"`); + await queryRunner.query(`DROP INDEX "IDX_6e1df243476e20cbf86572ecc0"`); + await queryRunner.query(`DROP INDEX "IDX_06690fc959f1c9fdaf21928222"`); + await queryRunner.query(`DROP INDEX "IDX_e447064455928cf627590ef527"`); + await queryRunner.query(`DROP INDEX "IDX_2d416e6af791a82e338c79d480"`); + await queryRunner.query(`DROP INDEX "IDX_e9cd07672b37d8966cf3709283"`); + await queryRunner.query(`DROP INDEX "IDX_fcc181fb8283009c61cc4083ef"`); + await queryRunner.query(`DROP INDEX "IDX_49975586f50ed7b800fdd88fbd"`); + await queryRunner.query(`DROP INDEX "IDX_6d6f156ceefc6bc5f273a0e370"`); + await queryRunner.query(`DROP INDEX "IDX_c12f0af4a66cdd30c2287ce8aa"`); + await queryRunner.query(`DROP INDEX "IDX_d0a4f79af5a97b08f37b547197"`); + await queryRunner.query(`DROP INDEX "IDX_f5448d9633cff74208d850aabe"`); + await queryRunner.query(`DROP INDEX "IDX_f8dd01baeded2ffa833e0a610a"`); + await queryRunner.query(`DROP INDEX "IDX_08fac0eb3b11f04c200c0b40dd"`); + await queryRunner.query(`DROP INDEX "IDX_9ff6944f01acb756fdc92d7563"`); + await queryRunner.query(`DROP INDEX "IDX_e69096589f11e3baa98ddd64d0"`); + await queryRunner.query(`DROP INDEX "IDX_0c9a159c5082cbeef3ca6706b5"`); + await queryRunner.query(`DROP INDEX "IDX_924fc196c80ca24bae01dd37e4"`); + await queryRunner.query(`DROP INDEX "IDX_328f259961e60c4fa0bfcf55ca"`); + await queryRunner.query(`DROP INDEX "IDX_42ea9381f0fda8dfe0fa1c8b53"`); + await queryRunner.query(`DROP INDEX "IDX_f2aeafde2ae6fbad38e857631b"`); + await queryRunner.query(`DROP INDEX "IDX_f92dd6d03f8d994f29987f6214"`); + await queryRunner.query(`DROP INDEX "IDX_57b5458d0d3d6d1e7f13d4e57f"`); + await queryRunner.query(`DROP INDEX "IDX_4db3b84c7be0d3464714f3e0b1"`); + await queryRunner.query(`DROP INDEX "IDX_8d2cbbc8114d90d19b44d626b6"`); + await queryRunner.query(`DROP INDEX "IDX_046feeb12e9ef5f783f409866a"`); + await queryRunner.query(`DROP INDEX "IDX_f68a5ab958f9f5fa17a32ac23b"`); + await queryRunner.query(`DROP INDEX "IDX_65633a106bce43fc7c5c30a5c7"`); + await queryRunner.query(`DROP INDEX "IDX_edeb73c09c3143a81bcb34d569"`); + await queryRunner.query(`DROP INDEX "IDX_e316f01a6d24eb31db27f88262"`); + await queryRunner.query(`DROP INDEX "IDX_2be7ec6cebddc14dc11e206686"`); + await queryRunner.query(`DROP INDEX "IDX_a5133470f4825902e170328ca5"`); + await queryRunner.query(`DROP INDEX "IDX_84e661abb7bd1e51b690d4b017"`); + await queryRunner.query(`DROP INDEX "IDX_5c73bf61da4f6e6f15bae88ed1"`); + await queryRunner.query(`DROP INDEX "IDX_d70c86baedc68326be11f9c0ce"`); + await queryRunner.query(`DROP INDEX "IDX_66e1e1ecd2f29e57778af35b59"`); + await queryRunner.query(`DROP INDEX "IDX_92255988735563f0fe4aba1f05"`); + await queryRunner.query(`DROP INDEX "IDX_c5870993e25c3d5771f91f5003"`); + await queryRunner.query(`DROP INDEX "IDX_f170de677ea75ad4533de2723e"`); + await queryRunner.query(`DROP INDEX "IDX_7c184198ecf66a8d3ecb253ab3"`); + await queryRunner.query(`DROP INDEX "IDX_f091abb24193d50c653c6b77fc"`); + await queryRunner.query(`DROP INDEX "IDX_a770a57c70e668cc61590c9161"`); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "span"`, + ); + await queryRunner.query( + `DROP TYPE "public"."__chart__active_users_span_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "unique"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___local_count"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___remote_count"`, + ); + await queryRunner.query(`ALTER TABLE "__chart__drive" DROP COLUMN "span"`); + await queryRunner.query(`DROP TYPE "public"."__chart__drive_span_enum"`); + await queryRunner.query( + `ALTER TABLE "__chart__drive" DROP COLUMN "unique"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "span"`, + ); + await queryRunner.query( + `DROP TYPE "public"."__chart__federation_span_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "unique"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" DROP COLUMN "span"`, + ); + await queryRunner.query(`DROP TYPE "public"."__chart__hashtag_span_enum"`); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" DROP COLUMN "unique"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" DROP COLUMN "___local_count"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" DROP COLUMN "___remote_count"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" DROP COLUMN "span"`, + ); + await queryRunner.query(`DROP TYPE "public"."__chart__instance_span_enum"`); + await queryRunner.query( + `ALTER TABLE "__chart__instance" DROP COLUMN "unique"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" DROP COLUMN "span"`, + ); + await queryRunner.query(`DROP TYPE "public"."__chart__network_span_enum"`); + await queryRunner.query( + `ALTER TABLE "__chart__network" DROP COLUMN "unique"`, + ); + await queryRunner.query(`ALTER TABLE "__chart__notes" DROP COLUMN "span"`); + await queryRunner.query(`DROP TYPE "public"."__chart__notes_span_enum"`); + await queryRunner.query( + `ALTER TABLE "__chart__notes" DROP COLUMN "unique"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" DROP COLUMN "span"`, + ); + await queryRunner.query( + `DROP TYPE "public"."__chart__per_user_drive_span_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" DROP COLUMN "unique"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" DROP COLUMN "span"`, + ); + await queryRunner.query( + `DROP TYPE "public"."__chart__per_user_following_span_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" DROP COLUMN "unique"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" DROP COLUMN "span"`, + ); + await queryRunner.query( + `DROP TYPE "public"."__chart__per_user_notes_span_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" DROP COLUMN "unique"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_reaction" DROP COLUMN "span"`, + ); + await queryRunner.query( + `DROP TYPE "public"."__chart__per_user_reaction_span_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_reaction" DROP COLUMN "unique"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__test_grouped" DROP COLUMN "span"`, + ); + await queryRunner.query( + `DROP TYPE "public"."__chart__test_grouped_span_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__test_grouped" DROP COLUMN "unique"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__test_unique" DROP COLUMN "span"`, + ); + await queryRunner.query( + `DROP TYPE "public"."__chart__test_unique_span_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__test_unique" DROP COLUMN "unique"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__test_unique" DROP COLUMN "___foo"`, + ); + await queryRunner.query(`ALTER TABLE "__chart__test" DROP COLUMN "span"`); + await queryRunner.query(`DROP TYPE "public"."__chart__test_span_enum"`); + await queryRunner.query(`ALTER TABLE "__chart__test" DROP COLUMN "unique"`); + await queryRunner.query(`ALTER TABLE "__chart__users" DROP COLUMN "span"`); + await queryRunner.query(`DROP TYPE "public"."__chart__users_span_enum"`); + await queryRunner.query( + `ALTER TABLE "__chart__users" DROP COLUMN "unique"`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__users" ADD "unique" jsonb NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."__chart__users_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ADD "span" "__chart__users_span_enum" NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__test" ADD "unique" jsonb NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."__chart__test_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__test" ADD "span" "__chart__test_span_enum" NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__test_unique" ADD "___foo" bigint NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__test_unique" ADD "unique" jsonb NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."__chart__test_unique_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__test_unique" ADD "span" "__chart__test_unique_span_enum" NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__test_grouped" ADD "unique" jsonb NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."__chart__test_grouped_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__test_grouped" ADD "span" "__chart__test_grouped_span_enum" NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_reaction" ADD "unique" jsonb NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."__chart__per_user_reaction_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_reaction" ADD "span" "__chart__per_user_reaction_span_enum" NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ADD "unique" jsonb NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."__chart__per_user_notes_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ADD "span" "__chart__per_user_notes_span_enum" NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ADD "unique" jsonb NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."__chart__per_user_following_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ADD "span" "__chart__per_user_following_span_enum" NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ADD "unique" jsonb NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."__chart__per_user_drive_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ADD "span" "__chart__per_user_drive_span_enum" NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ADD "unique" jsonb NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."__chart__notes_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ADD "span" "__chart__notes_span_enum" NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ADD "unique" jsonb NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."__chart__network_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ADD "span" "__chart__network_span_enum" NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ADD "unique" jsonb NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."__chart__instance_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ADD "span" "__chart__instance_span_enum" NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ADD "___remote_count" bigint NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ADD "___local_count" bigint NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ADD "unique" jsonb NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."__chart__hashtag_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ADD "span" "__chart__hashtag_span_enum" NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "unique" jsonb NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."__chart__federation_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "span" "__chart__federation_span_enum" NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ADD "unique" jsonb NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."__chart__drive_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ADD "span" "__chart__drive_span_enum" NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___remote_count" bigint NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___local_count" bigint NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "unique" jsonb NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."__chart__active_users_span_enum" AS ENUM('hour', 'day')`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "span" "__chart__active_users_span_enum" NOT NULL`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_a770a57c70e668cc61590c9161" ON "__chart__users" ("date", "group", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f091abb24193d50c653c6b77fc" ON "__chart__users" ("date", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_7c184198ecf66a8d3ecb253ab3" ON "__chart__users" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f170de677ea75ad4533de2723e" ON "__chart__test" ("date", "group", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_c5870993e25c3d5771f91f5003" ON "__chart__test" ("date", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_92255988735563f0fe4aba1f05" ON "__chart__test" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_66e1e1ecd2f29e57778af35b59" ON "__chart__test_unique" ("date", "group", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_d70c86baedc68326be11f9c0ce" ON "__chart__test_unique" ("date", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_5c73bf61da4f6e6f15bae88ed1" ON "__chart__test_unique" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_84e661abb7bd1e51b690d4b017" ON "__chart__test_grouped" ("date", "group", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_a5133470f4825902e170328ca5" ON "__chart__test_grouped" ("date", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_2be7ec6cebddc14dc11e206686" ON "__chart__test_grouped" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e316f01a6d24eb31db27f88262" ON "__chart__per_user_reaction" ("date", "group", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_edeb73c09c3143a81bcb34d569" ON "__chart__per_user_reaction" ("date", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_65633a106bce43fc7c5c30a5c7" ON "__chart__per_user_reaction" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f68a5ab958f9f5fa17a32ac23b" ON "__chart__per_user_notes" ("date", "group", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_046feeb12e9ef5f783f409866a" ON "__chart__per_user_notes" ("date", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_8d2cbbc8114d90d19b44d626b6" ON "__chart__per_user_notes" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_4db3b84c7be0d3464714f3e0b1" ON "__chart__per_user_following" ("date", "group", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_57b5458d0d3d6d1e7f13d4e57f" ON "__chart__per_user_following" ("date", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f92dd6d03f8d994f29987f6214" ON "__chart__per_user_following" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f2aeafde2ae6fbad38e857631b" ON "__chart__per_user_drive" ("date", "group", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_42ea9381f0fda8dfe0fa1c8b53" ON "__chart__per_user_drive" ("date", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_328f259961e60c4fa0bfcf55ca" ON "__chart__per_user_drive" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_924fc196c80ca24bae01dd37e4" ON "__chart__notes" ("date", "group", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0c9a159c5082cbeef3ca6706b5" ON "__chart__notes" ("date", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e69096589f11e3baa98ddd64d0" ON "__chart__notes" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_9ff6944f01acb756fdc92d7563" ON "__chart__network" ("date", "group", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_08fac0eb3b11f04c200c0b40dd" ON "__chart__network" ("date", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f8dd01baeded2ffa833e0a610a" ON "__chart__network" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f5448d9633cff74208d850aabe" ON "__chart__instance" ("date", "group", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_d0a4f79af5a97b08f37b547197" ON "__chart__instance" ("date", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_c12f0af4a66cdd30c2287ce8aa" ON "__chart__instance" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_6d6f156ceefc6bc5f273a0e370" ON "__chart__hashtag" ("date", "group", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_49975586f50ed7b800fdd88fbd" ON "__chart__hashtag" ("date", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_fcc181fb8283009c61cc4083ef" ON "__chart__hashtag" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e9cd07672b37d8966cf3709283" ON "__chart__federation" ("date", "group", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_2d416e6af791a82e338c79d480" ON "__chart__federation" ("date", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e447064455928cf627590ef527" ON "__chart__federation" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_06690fc959f1c9fdaf21928222" ON "__chart__drive" ("date", "group", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_6e1df243476e20cbf86572ecc0" ON "__chart__drive" ("date", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_3fa0d0f17ca72e3dc80999a032" ON "__chart__drive" ("span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_c26e2c1cbb6e911e0554b27416" ON "__chart__active_users" ("date", "group", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_20f57cc8f142c131340ee16742" ON "__chart__active_users" ("date", "span") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_15e91a03aeeac9dbccdf43fc06" ON "__chart__active_users" ("span") `, + ); + } +} diff --git a/packages/backend/src/migration/1615966519402-chart-v2-2.ts b/packages/backend/src/migration/1615966519402-chart-v2-2.ts new file mode 100644 index 0000000..0b1999c --- /dev/null +++ b/packages/backend/src/migration/1615966519402-chart-v2-2.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartV221615966519402 implements MigrationInterface { + constructor() { + this.name = "chartV221615966519402"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___local_users" character varying array NOT NULL DEFAULT '{}'::varchar[]`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___remote_users" character varying array NOT NULL DEFAULT '{}'::varchar[]`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ADD "___local_users" character varying array NOT NULL DEFAULT '{}'::varchar[]`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ADD "___remote_users" character varying array NOT NULL DEFAULT '{}'::varchar[]`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__test_unique" ADD "___foo" character varying array NOT NULL DEFAULT '{}'::varchar[]`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__test_unique" DROP COLUMN "___foo"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" DROP COLUMN "___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" DROP COLUMN "___local_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___local_users"`, + ); + } +} diff --git a/packages/backend/src/migration/1618637372000-user-last-active-date.ts b/packages/backend/src/migration/1618637372000-user-last-active-date.ts new file mode 100644 index 0000000..57c70c5 --- /dev/null +++ b/packages/backend/src/migration/1618637372000-user-last-active-date.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class userLastActiveDate1618637372000 implements MigrationInterface { + constructor() { + this.name = "userLastActiveDate1618637372000"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD "lastActiveDate" TIMESTAMP WITH TIME ZONE DEFAULT NULL`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_seoignmeoprigmkpodgrjmkpormg" ON "user" ("lastActiveDate") `, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_seoignmeoprigmkpodgrjmkpormg"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "lastActiveDate"`); + } +} diff --git a/packages/backend/src/migration/1618639857000-user-hide-online-status.ts b/packages/backend/src/migration/1618639857000-user-hide-online-status.ts new file mode 100644 index 0000000..f2c3bde --- /dev/null +++ b/packages/backend/src/migration/1618639857000-user-hide-online-status.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class userHideOnlineStatus1618639857000 implements MigrationInterface { + constructor() { + this.name = "userHideOnlineStatus1618639857000"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD "hideOnlineStatus" boolean NOT NULL DEFAULT false`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" DROP COLUMN "hideOnlineStatus"`, + ); + } +} diff --git a/packages/backend/src/migration/1619942102890-password-reset.ts b/packages/backend/src/migration/1619942102890-password-reset.ts new file mode 100644 index 0000000..3e60ee3 --- /dev/null +++ b/packages/backend/src/migration/1619942102890-password-reset.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class passwordReset1619942102890 implements MigrationInterface { + constructor() { + this.name = "passwordReset1619942102890"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "password_reset_request" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "token" character varying(256) NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "PK_fcf4b02eae1403a2edaf87fd074" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_0b575fa9a4cfe638a925949285" ON "password_reset_request" ("token") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_4bb7fd4a34492ae0e6cc8d30ac" ON "password_reset_request" ("userId") `, + ); + await queryRunner.query( + `ALTER TABLE "password_reset_request" ADD CONSTRAINT "FK_4bb7fd4a34492ae0e6cc8d30ac8" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "password_reset_request" DROP CONSTRAINT "FK_4bb7fd4a34492ae0e6cc8d30ac8"`, + ); + await queryRunner.query(`DROP INDEX "IDX_4bb7fd4a34492ae0e6cc8d30ac"`); + await queryRunner.query(`DROP INDEX "IDX_0b575fa9a4cfe638a925949285"`); + await queryRunner.query(`DROP TABLE "password_reset_request"`); + } +} diff --git a/packages/backend/src/migration/1620019354680-ad.ts b/packages/backend/src/migration/1620019354680-ad.ts new file mode 100644 index 0000000..2bb4ab2 --- /dev/null +++ b/packages/backend/src/migration/1620019354680-ad.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class ad1620019354680 implements MigrationInterface { + constructor() { + this.name = "ad1620019354680"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "ad" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "place" character varying(32) NOT NULL, "priority" character varying(32) NOT NULL, "url" character varying(1024) NOT NULL, "imageUrl" character varying(1024) NOT NULL, "memo" character varying(8192) NOT NULL, CONSTRAINT "PK_0193d5ef09746e88e9ea92c634d" PRIMARY KEY ("id")); COMMENT ON COLUMN "ad"."createdAt" IS 'The created date of the Ad.'; COMMENT ON COLUMN "ad"."expiresAt" IS 'The expired date of the Ad.'`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_1129c2ef687fc272df040bafaa" ON "ad" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_2da24ce20ad209f1d9dc032457" ON "ad" ("expiresAt") `, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_2da24ce20ad209f1d9dc032457"`); + await queryRunner.query(`DROP INDEX "IDX_1129c2ef687fc272df040bafaa"`); + await queryRunner.query(`DROP TABLE "ad"`); + } +} diff --git a/packages/backend/src/migration/1620364649428-ad2.ts b/packages/backend/src/migration/1620364649428-ad2.ts new file mode 100644 index 0000000..8e964d5 --- /dev/null +++ b/packages/backend/src/migration/1620364649428-ad2.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class ad21620364649428 implements MigrationInterface { + constructor() { + this.name = "ad21620364649428"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "ad" ADD "ratio" integer NOT NULL DEFAULT '1'`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "ad" DROP COLUMN "ratio"`); + } +} diff --git a/packages/backend/src/migration/1621479946000-add-note-indexes.ts b/packages/backend/src/migration/1621479946000-add-note-indexes.ts new file mode 100644 index 0000000..24e41bc --- /dev/null +++ b/packages/backend/src/migration/1621479946000-add-note-indexes.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class addNoteIndexes1621479946000 implements MigrationInterface { + constructor() { + this.name = "addNoteIndexes1621479946000"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX "IDX_NOTE_MENTIONS" ON "note" USING gin ("mentions")`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_NOTE_VISIBLE_USER_IDS" ON "note" USING gin ("visibleUserIds")`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_NOTE_MENTIONS"`, undefined); + await queryRunner.query( + `DROP INDEX "IDX_NOTE_VISIBLE_USER_IDS"`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1622679304522-user-profile-description-length.ts b/packages/backend/src/migration/1622679304522-user-profile-description-length.ts new file mode 100644 index 0000000..bbf038c --- /dev/null +++ b/packages/backend/src/migration/1622679304522-user-profile-description-length.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class userProfileDescriptionLength1622679304522 implements MigrationInterface { + constructor() { + this.name = "userProfileDescriptionLength1622679304522"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" ALTER COLUMN "description" TYPE character varying(2048)`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" ALTER COLUMN "description" TYPE character varying(1024)`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1622681548499-log-message-length.ts b/packages/backend/src/migration/1622681548499-log-message-length.ts new file mode 100644 index 0000000..693fd75 --- /dev/null +++ b/packages/backend/src/migration/1622681548499-log-message-length.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class logMessageLength1622681548499 implements MigrationInterface { + constructor() { + this.name = "logMessageLength1622681548499"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "log" ALTER COLUMN "message" TYPE character varying(2048)`, + undefined, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "log" ALTER COLUMN "message" TYPE character varying(1024)`, + undefined, + ); + } +} diff --git a/packages/backend/src/migration/1626509500668-fix-remote-file-proxy.ts b/packages/backend/src/migration/1626509500668-fix-remote-file-proxy.ts new file mode 100644 index 0000000..dbd6d4c --- /dev/null +++ b/packages/backend/src/migration/1626509500668-fix-remote-file-proxy.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class fixRemoteFileProxy1626509500668 implements MigrationInterface { + constructor() { + this.name = "fixRemoteFileProxy1626509500668"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarUrl"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerUrl"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarBlurhash"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerBlurhash"`); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "proxyRemoteFiles"`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "proxyRemoteFiles" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "user" ADD "bannerBlurhash" character varying(128)`, + ); + await queryRunner.query( + `ALTER TABLE "user" ADD "avatarBlurhash" character varying(128)`, + ); + await queryRunner.query( + `ALTER TABLE "user" ADD "bannerUrl" character varying(512)`, + ); + await queryRunner.query( + `ALTER TABLE "user" ADD "avatarUrl" character varying(512)`, + ); + } +} diff --git a/packages/backend/src/migration/1626733991004-allowlist-secure-mode.ts b/packages/backend/src/migration/1626733991004-allowlist-secure-mode.ts new file mode 100644 index 0000000..8fd3000 --- /dev/null +++ b/packages/backend/src/migration/1626733991004-allowlist-secure-mode.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class allowlistSecureMode1626733991004 implements MigrationInterface { + name = "allowlistSecureMode1626733991004"; + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "allowedHosts" character varying(256) [] default '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "secureMode" bool default false`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "privateMode" bool default false`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "allowedHosts"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "secureMode"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "privateMode"`); + } +} diff --git a/packages/backend/src/migration/1629004542760-chart-reindex.ts b/packages/backend/src/migration/1629004542760-chart-reindex.ts new file mode 100644 index 0000000..ad49e08 --- /dev/null +++ b/packages/backend/src/migration/1629004542760-chart-reindex.ts @@ -0,0 +1,358 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartReindex1629004542760 implements MigrationInterface { + constructor() { + this.name = "chartReindex1629004542760"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "__chart__active_users" a USING "__chart__active_users" b WHERE a.id < b.id AND ((a.group IS NULL AND b.group IS NULL) OR a.group = b.group) AND a.date = b.date;`, + ); + await queryRunner.query( + `DELETE FROM "__chart__drive" a USING "__chart__drive" b WHERE a.id < b.id AND ((a.group IS NULL AND b.group IS NULL) OR a.group = b.group) AND a.date = b.date;`, + ); + await queryRunner.query( + `DELETE FROM "__chart__federation" a USING "__chart__federation" b WHERE a.id < b.id AND ((a.group IS NULL AND b.group IS NULL) OR a.group = b.group) AND a.date = b.date;`, + ); + await queryRunner.query( + `DELETE FROM "__chart__hashtag" a USING "__chart__hashtag" b WHERE a.id < b.id AND ((a.group IS NULL AND b.group IS NULL) OR a.group = b.group) AND a.date = b.date;`, + ); + await queryRunner.query( + `DELETE FROM "__chart__instance" a USING "__chart__instance" b WHERE a.id < b.id AND ((a.group IS NULL AND b.group IS NULL) OR a.group = b.group) AND a.date = b.date;`, + ); + await queryRunner.query( + `DELETE FROM "__chart__network" a USING "__chart__network" b WHERE a.id < b.id AND ((a.group IS NULL AND b.group IS NULL) OR a.group = b.group) AND a.date = b.date;`, + ); + await queryRunner.query( + `DELETE FROM "__chart__notes" a USING "__chart__notes" b WHERE a.id < b.id AND ((a.group IS NULL AND b.group IS NULL) OR a.group = b.group) AND a.date = b.date;`, + ); + await queryRunner.query( + `DELETE FROM "__chart__per_user_drive" a USING "__chart__per_user_drive" b WHERE a.id < b.id AND ((a.group IS NULL AND b.group IS NULL) OR a.group = b.group) AND a.date = b.date;`, + ); + await queryRunner.query( + `DELETE FROM "__chart__per_user_following" a USING "__chart__per_user_following" b WHERE a.id < b.id AND ((a.group IS NULL AND b.group IS NULL) OR a.group = b.group) AND a.date = b.date;`, + ); + await queryRunner.query( + `DELETE FROM "__chart__per_user_notes" a USING "__chart__per_user_notes" b WHERE a.id < b.id AND ((a.group IS NULL AND b.group IS NULL) OR a.group = b.group) AND a.date = b.date;`, + ); + await queryRunner.query( + `DELETE FROM "__chart__per_user_reaction" a USING "__chart__per_user_reaction" b WHERE a.id < b.id AND ((a.group IS NULL AND b.group IS NULL) OR a.group = b.group) AND a.date = b.date;`, + ); + await queryRunner.query( + `DELETE FROM "__chart__test_grouped" a USING "__chart__test_grouped" b WHERE a.id < b.id AND ((a.group IS NULL AND b.group IS NULL) OR a.group = b.group) AND a.date = b.date;`, + ); + await queryRunner.query( + `DELETE FROM "__chart__test_unique" a USING "__chart__test_unique" b WHERE a.id < b.id AND ((a.group IS NULL AND b.group IS NULL) OR a.group = b.group) AND a.date = b.date;`, + ); + await queryRunner.query( + `DELETE FROM "__chart__users" a USING "__chart__users" b WHERE a.id < b.id AND ((a.group IS NULL AND b.group IS NULL) OR a.group = b.group) AND a.date = b.date;`, + ); + await queryRunner.query(`DROP INDEX "IDX_0ad37b7ef50f4ddc84363d7ccc"`); + await queryRunner.query(`DROP INDEX "IDX_00ed5f86db1f7efafb1978bf21"`); + await queryRunner.query(`DROP INDEX "IDX_9a3ed15a30ab7e3a37702e6e08"`); + await queryRunner.query(`DROP INDEX "IDX_13565815f618a1ff53886c5b28"`); + await queryRunner.query(`DROP INDEX "IDX_7a170f67425e62a8fabb76c872"`); + await queryRunner.query(`DROP INDEX "IDX_3313d7288855ec105b5bbf6c21"`); + await queryRunner.query(`DROP INDEX "IDX_36cb699c49580d4e6c2e6159f9"`); + await queryRunner.query(`DROP INDEX "IDX_76e87c7bfc5d925fcbba405d84"`); + await queryRunner.query(`DROP INDEX "IDX_dd907becf76104e4b656659e6b"`); + await queryRunner.query(`DROP INDEX "IDX_07747a1038c05f532a718fe1de"`); + await queryRunner.query(`DROP INDEX "IDX_99a7d2faaef84a6f728d714ad6"`); + await queryRunner.query(`DROP INDEX "IDX_25a97c02003338124b2b75fdbc"`); + await queryRunner.query(`DROP INDEX "IDX_6b8f34a1a64b06014b6fb66824"`); + await queryRunner.query(`DROP INDEX "IDX_da8a46ba84ca1d8bb5a29bfb63"`); + await queryRunner.query(`DROP INDEX "IDX_39ee857ab2f23493037c6b6631"`); + await queryRunner.query(`DROP INDEX "IDX_a1efd3e0048a5f2793a47360dc"`); + await queryRunner.query(`DROP INDEX "IDX_7b5da130992ec9df96712d4290"`); + await queryRunner.query(`DROP INDEX "IDX_0a905b992fecd2b5c3fb98759e"`); + await queryRunner.query(`DROP INDEX "IDX_42eb716a37d381cdf566192b2b"`); + await queryRunner.query(`DROP INDEX "IDX_7036f2957151588b813185c794"`); + await queryRunner.query(`DROP INDEX "IDX_f09d543e3acb16c5976bdb31fa"`); + await queryRunner.query(`DROP INDEX "IDX_5f86db6492274e07c1a3cdf286"`); + await queryRunner.query(`DROP INDEX "IDX_e496ca8096d28f6b9b509264dc"`); + await queryRunner.query(`DROP INDEX "IDX_30bf67687f483ace115c5ca642"`); + await queryRunner.query(`DROP INDEX "IDX_7af07790712aa3438ff6773f3b"`); + await queryRunner.query(`DROP INDEX "IDX_4b3593098b6edc9c5afe36b18b"`); + await queryRunner.query(`DROP INDEX "IDX_b77d4dd9562c3a899d9a286fcd"`); + await queryRunner.query(`DROP INDEX "IDX_84234bd1abb873f07329681c83"`); + await queryRunner.query(`DROP INDEX "IDX_55bf20f366979f2436de99206b"`); + await queryRunner.query(`DROP INDEX "IDX_5048e9daccbbbc6d567bb142d3"`); + await queryRunner.query(`DROP INDEX "IDX_f7bf4c62059764c2c2bb40fdab"`); + await queryRunner.query(`DROP INDEX "IDX_8cf3156fd7a6b15c43459c6e3b"`); + await queryRunner.query(`DROP INDEX "IDX_229a41ad465f9205f1f5703291"`); + await queryRunner.query(`DROP INDEX "IDX_0c641990ecf47d2545df4edb75"`); + await queryRunner.query(`DROP INDEX "IDX_234dff3c0b56a6150b95431ab9"`); + await queryRunner.query(`DROP INDEX "IDX_b14489029e4b3aaf4bba5fb524"`); + await queryRunner.query(`DROP INDEX "IDX_437bab3c6061d90f6bb65fd2cc"`); + await queryRunner.query(`DROP INDEX "IDX_bbfa573a8181018851ed0b6357"`); + await queryRunner.query(`DROP INDEX "IDX_a0cd75442dd10d0643a17c4a49"`); + await queryRunner.query(`DROP INDEX "IDX_b070a906db04b44c67c6c2144d"`); + await queryRunner.query(`DROP INDEX "IDX_d41cce6aee1a50bfc062038f9b"`); + await queryRunner.query(`DROP INDEX "IDX_a319e5dbf47e8a17497623beae"`); + await queryRunner.query(`DROP INDEX "IDX_845254b3eaf708ae8a6cac3026"`); + await queryRunner.query(`DROP INDEX "IDX_ed9b95919c672a13008e9487ee"`); + await queryRunner.query(`DROP INDEX "IDX_337e9599f278bd7537fe30876f"`); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_9a3ed15a30ab7e3a37702e6e08" ON "__chart__active_users" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_60c5c6e7e538c09aa274ecd1cf" ON "__chart__active_users" ("date") WHERE "group" IS NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_3313d7288855ec105b5bbf6c21" ON "__chart__drive" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_ceab80a6729f8e2e6f5b8a1a3d" ON "__chart__drive" ("date") WHERE "group" IS NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_dd907becf76104e4b656659e6b" ON "__chart__federation" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_eddfed8fb40305a04c6f941050" ON "__chart__federation" ("date") WHERE "group" IS NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_25a97c02003338124b2b75fdbc" ON "__chart__hashtag" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_53a3604b939e2b479eb2cfaac8" ON "__chart__hashtag" ("date") WHERE "group" IS NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_39ee857ab2f23493037c6b6631" ON "__chart__instance" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_8111b817b9818c04d7eb8475b1" ON "__chart__instance" ("date") WHERE "group" IS NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_0a905b992fecd2b5c3fb98759e" ON "__chart__network" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_2082327b2699ce924fa654afc5" ON "__chart__network" ("date") WHERE "group" IS NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_f09d543e3acb16c5976bdb31fa" ON "__chart__notes" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_e60c358aaced5aab8900a4af31" ON "__chart__notes" ("date") WHERE "group" IS NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_30bf67687f483ace115c5ca642" ON "__chart__per_user_drive" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_a9a806d466b314f253a1a611c4" ON "__chart__per_user_drive" ("date") WHERE "group" IS NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_b77d4dd9562c3a899d9a286fcd" ON "__chart__per_user_following" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_dabbb38a51ab86ee3cab291326" ON "__chart__per_user_following" ("date") WHERE "group" IS NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_5048e9daccbbbc6d567bb142d3" ON "__chart__per_user_notes" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_583a157ed0cf0ed1b5ec2a833f" ON "__chart__per_user_notes" ("date") WHERE "group" IS NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_229a41ad465f9205f1f5703291" ON "__chart__per_user_reaction" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_3b7697a96f522d0478972e6d6f" ON "__chart__per_user_reaction" ("date") WHERE "group" IS NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_b14489029e4b3aaf4bba5fb524" ON "__chart__test_grouped" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_da522b4008a9f5d7743b87ad55" ON "__chart__test_grouped" ("date") WHERE "group" IS NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_a0cd75442dd10d0643a17c4a49" ON "__chart__test_unique" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_16effb2e888f6763673b579f80" ON "__chart__test_unique" ("date") WHERE "group" IS NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_a319e5dbf47e8a17497623beae" ON "__chart__test" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_dab383a36f3c9db4a0c9b02cf3" ON "__chart__test" ("date") WHERE "group" IS NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_337e9599f278bd7537fe30876f" ON "__chart__users" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_66feba81e1795d176d06c0b1e6" ON "__chart__users" ("date") WHERE "group" IS NULL`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_66feba81e1795d176d06c0b1e6"`); + await queryRunner.query(`DROP INDEX "IDX_337e9599f278bd7537fe30876f"`); + await queryRunner.query(`DROP INDEX "IDX_dab383a36f3c9db4a0c9b02cf3"`); + await queryRunner.query(`DROP INDEX "IDX_a319e5dbf47e8a17497623beae"`); + await queryRunner.query(`DROP INDEX "IDX_16effb2e888f6763673b579f80"`); + await queryRunner.query(`DROP INDEX "IDX_a0cd75442dd10d0643a17c4a49"`); + await queryRunner.query(`DROP INDEX "IDX_da522b4008a9f5d7743b87ad55"`); + await queryRunner.query(`DROP INDEX "IDX_b14489029e4b3aaf4bba5fb524"`); + await queryRunner.query(`DROP INDEX "IDX_3b7697a96f522d0478972e6d6f"`); + await queryRunner.query(`DROP INDEX "IDX_229a41ad465f9205f1f5703291"`); + await queryRunner.query(`DROP INDEX "IDX_583a157ed0cf0ed1b5ec2a833f"`); + await queryRunner.query(`DROP INDEX "IDX_5048e9daccbbbc6d567bb142d3"`); + await queryRunner.query(`DROP INDEX "IDX_dabbb38a51ab86ee3cab291326"`); + await queryRunner.query(`DROP INDEX "IDX_b77d4dd9562c3a899d9a286fcd"`); + await queryRunner.query(`DROP INDEX "IDX_a9a806d466b314f253a1a611c4"`); + await queryRunner.query(`DROP INDEX "IDX_30bf67687f483ace115c5ca642"`); + await queryRunner.query(`DROP INDEX "IDX_e60c358aaced5aab8900a4af31"`); + await queryRunner.query(`DROP INDEX "IDX_f09d543e3acb16c5976bdb31fa"`); + await queryRunner.query(`DROP INDEX "IDX_2082327b2699ce924fa654afc5"`); + await queryRunner.query(`DROP INDEX "IDX_0a905b992fecd2b5c3fb98759e"`); + await queryRunner.query(`DROP INDEX "IDX_8111b817b9818c04d7eb8475b1"`); + await queryRunner.query(`DROP INDEX "IDX_39ee857ab2f23493037c6b6631"`); + await queryRunner.query(`DROP INDEX "IDX_53a3604b939e2b479eb2cfaac8"`); + await queryRunner.query(`DROP INDEX "IDX_25a97c02003338124b2b75fdbc"`); + await queryRunner.query(`DROP INDEX "IDX_eddfed8fb40305a04c6f941050"`); + await queryRunner.query(`DROP INDEX "IDX_dd907becf76104e4b656659e6b"`); + await queryRunner.query(`DROP INDEX "IDX_ceab80a6729f8e2e6f5b8a1a3d"`); + await queryRunner.query(`DROP INDEX "IDX_3313d7288855ec105b5bbf6c21"`); + await queryRunner.query(`DROP INDEX "IDX_60c5c6e7e538c09aa274ecd1cf"`); + await queryRunner.query(`DROP INDEX "IDX_9a3ed15a30ab7e3a37702e6e08"`); + await queryRunner.query(`DROP INDEX "IDX_a9021cc2e1feb5f72d3db6e9f5"`); + await queryRunner.query(`DROP INDEX "IDX_f22169eb10657bded6d875ac8f"`); + await queryRunner.query(`DROP INDEX "IDX_c8cc87bd0f2f4487d17c651fbf"`); + await queryRunner.query(`DROP INDEX "IDX_754499f9b2642336433769518d"`); + await queryRunner.query(`DROP INDEX "IDX_315c779174fe8247ab324f036e"`); + await queryRunner.query(`DROP INDEX "IDX_c5d46cbfda48b1c33ed852e21b"`); + await queryRunner.query( + `CREATE INDEX "IDX_337e9599f278bd7537fe30876f" ON "__chart__users" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_ed9b95919c672a13008e9487ee" ON "__chart__users" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_845254b3eaf708ae8a6cac3026" ON "__chart__users" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_a319e5dbf47e8a17497623beae" ON "__chart__test" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_d41cce6aee1a50bfc062038f9b" ON "__chart__test" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b070a906db04b44c67c6c2144d" ON "__chart__test" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_a0cd75442dd10d0643a17c4a49" ON "__chart__test_unique" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_bbfa573a8181018851ed0b6357" ON "__chart__test_unique" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_437bab3c6061d90f6bb65fd2cc" ON "__chart__test_unique" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b14489029e4b3aaf4bba5fb524" ON "__chart__test_grouped" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_234dff3c0b56a6150b95431ab9" ON "__chart__test_grouped" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0c641990ecf47d2545df4edb75" ON "__chart__test_grouped" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_229a41ad465f9205f1f5703291" ON "__chart__per_user_reaction" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_8cf3156fd7a6b15c43459c6e3b" ON "__chart__per_user_reaction" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f7bf4c62059764c2c2bb40fdab" ON "__chart__per_user_reaction" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_5048e9daccbbbc6d567bb142d3" ON "__chart__per_user_notes" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_55bf20f366979f2436de99206b" ON "__chart__per_user_notes" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_84234bd1abb873f07329681c83" ON "__chart__per_user_notes" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b77d4dd9562c3a899d9a286fcd" ON "__chart__per_user_following" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_4b3593098b6edc9c5afe36b18b" ON "__chart__per_user_following" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_7af07790712aa3438ff6773f3b" ON "__chart__per_user_following" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_30bf67687f483ace115c5ca642" ON "__chart__per_user_drive" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e496ca8096d28f6b9b509264dc" ON "__chart__per_user_drive" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_5f86db6492274e07c1a3cdf286" ON "__chart__per_user_drive" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f09d543e3acb16c5976bdb31fa" ON "__chart__notes" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_7036f2957151588b813185c794" ON "__chart__notes" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_42eb716a37d381cdf566192b2b" ON "__chart__notes" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0a905b992fecd2b5c3fb98759e" ON "__chart__network" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_7b5da130992ec9df96712d4290" ON "__chart__network" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_a1efd3e0048a5f2793a47360dc" ON "__chart__network" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_39ee857ab2f23493037c6b6631" ON "__chart__instance" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_da8a46ba84ca1d8bb5a29bfb63" ON "__chart__instance" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_6b8f34a1a64b06014b6fb66824" ON "__chart__instance" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_25a97c02003338124b2b75fdbc" ON "__chart__hashtag" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_99a7d2faaef84a6f728d714ad6" ON "__chart__hashtag" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_07747a1038c05f532a718fe1de" ON "__chart__hashtag" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_dd907becf76104e4b656659e6b" ON "__chart__federation" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_76e87c7bfc5d925fcbba405d84" ON "__chart__federation" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_36cb699c49580d4e6c2e6159f9" ON "__chart__federation" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_3313d7288855ec105b5bbf6c21" ON "__chart__drive" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_7a170f67425e62a8fabb76c872" ON "__chart__drive" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_13565815f618a1ff53886c5b28" ON "__chart__drive" ("date") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_9a3ed15a30ab7e3a37702e6e08" ON "__chart__active_users" ("date", "group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_00ed5f86db1f7efafb1978bf21" ON "__chart__active_users" ("group") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0ad37b7ef50f4ddc84363d7ccc" ON "__chart__active_users" ("date") `, + ); + } +} diff --git a/packages/backend/src/migration/1629024377804-deepl-integration.ts b/packages/backend/src/migration/1629024377804-deepl-integration.ts new file mode 100644 index 0000000..7b05f71 --- /dev/null +++ b/packages/backend/src/migration/1629024377804-deepl-integration.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class deeplIntegration1629024377804 implements MigrationInterface { + constructor() { + this.name = "deeplIntegration1629024377804"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "deeplAuthKey" character varying(128)`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "deeplAuthKey"`); + } +} diff --git a/packages/backend/src/migration/1629288472000-fix-channel-userId.ts b/packages/backend/src/migration/1629288472000-fix-channel-userId.ts new file mode 100644 index 0000000..15b32cd --- /dev/null +++ b/packages/backend/src/migration/1629288472000-fix-channel-userId.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class fixChannelUserId1629288472000 implements MigrationInterface { + constructor() { + this.name = "fixChannelUserId1629288472000"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "channel" ALTER COLUMN "userId" DROP NOT NULL;`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "channel" ALTER COLUMN "userId" SET NOT NULL;`, + ); + } +} diff --git a/packages/backend/src/migration/1629512953000-user-is-deleted.ts b/packages/backend/src/migration/1629512953000-user-is-deleted.ts new file mode 100644 index 0000000..f47fdee --- /dev/null +++ b/packages/backend/src/migration/1629512953000-user-is-deleted.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class isUserDeleted1629512953000 implements MigrationInterface { + constructor() { + this.name = "isUserDeleted1629512953000"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD "isDeleted" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."isDeleted" IS 'Whether the User is deleted.'`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "isDeleted"`); + } +} diff --git a/packages/backend/src/migration/1629778475000-deepl-integration2.ts b/packages/backend/src/migration/1629778475000-deepl-integration2.ts new file mode 100644 index 0000000..309f841 --- /dev/null +++ b/packages/backend/src/migration/1629778475000-deepl-integration2.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class deeplIntegration21629778475000 implements MigrationInterface { + constructor() { + this.name = "deeplIntegration21629778475000"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "deeplIsPro" boolean NOT NULL DEFAULT false`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "deeplIsPro"`); + } +} diff --git a/packages/backend/src/migration/1629833361000-AddShowTLReplies.ts b/packages/backend/src/migration/1629833361000-AddShowTLReplies.ts new file mode 100644 index 0000000..6d8b249 --- /dev/null +++ b/packages/backend/src/migration/1629833361000-AddShowTLReplies.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class addShowTLReplies1629833361000 implements MigrationInterface { + constructor() { + this.name = "addShowTLReplies1629833361000"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD "showTimelineReplies" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."showTimelineReplies" IS 'Whether to show users replying to other users in the timeline.'`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" DROP COLUMN "showTimelineReplies"`, + ); + } +} diff --git a/packages/backend/src/migration/1629968054000_userInstanceBlocks.ts b/packages/backend/src/migration/1629968054000_userInstanceBlocks.ts new file mode 100644 index 0000000..60ad2b9 --- /dev/null +++ b/packages/backend/src/migration/1629968054000_userInstanceBlocks.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class userInstanceBlocks1629968054000 implements MigrationInterface { + constructor() { + this.name = "userInstanceBlocks1629968054000"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "mutedInstances" jsonb NOT NULL DEFAULT '[]'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user_profile"."mutedInstances" IS 'List of instances muted by the user.'`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "mutedInstances"`, + ); + } +} diff --git a/packages/backend/src/migration/1633068642000-email-required-for-signup.ts b/packages/backend/src/migration/1633068642000-email-required-for-signup.ts new file mode 100644 index 0000000..140c8fb --- /dev/null +++ b/packages/backend/src/migration/1633068642000-email-required-for-signup.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class emailRequiredForSignup1633068642000 implements MigrationInterface { + constructor() { + this.name = "emailRequiredForSignup1633068642000"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "emailRequiredForSignup" boolean NOT NULL DEFAULT false`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "emailRequiredForSignup"`, + ); + } +} diff --git a/packages/backend/src/migration/1633071909016-user-pending.ts b/packages/backend/src/migration/1633071909016-user-pending.ts new file mode 100644 index 0000000..8da5a75 --- /dev/null +++ b/packages/backend/src/migration/1633071909016-user-pending.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class userPending1633071909016 implements MigrationInterface { + constructor() { + this.name = "userPending1633071909016"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_pending" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "code" character varying(128) NOT NULL, "username" character varying(128) NOT NULL, "email" character varying(128) NOT NULL, "password" character varying(128) NOT NULL, CONSTRAINT "PK_d4c84e013c98ec02d19b8fbbafa" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_4e5c4c99175638ec0761714ab0" ON "user_pending" ("code") `, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_4e5c4c99175638ec0761714ab0"`); + await queryRunner.query(`DROP TABLE "user_pending"`); + } +} diff --git a/packages/backend/src/migration/1634486652000-user-public-reactions.ts b/packages/backend/src/migration/1634486652000-user-public-reactions.ts new file mode 100644 index 0000000..42401f1 --- /dev/null +++ b/packages/backend/src/migration/1634486652000-user-public-reactions.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class userPublicReactions1634486652000 implements MigrationInterface { + constructor() { + this.name = "userPublicReactions1634486652000"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "publicReactions" boolean NOT NULL DEFAULT false`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "publicReactions"`, + ); + } +} diff --git a/packages/backend/src/migration/1634902659689-delete-log.ts b/packages/backend/src/migration/1634902659689-delete-log.ts new file mode 100644 index 0000000..b965aca --- /dev/null +++ b/packages/backend/src/migration/1634902659689-delete-log.ts @@ -0,0 +1,10 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class deleteLog1634902659689 implements MigrationInterface { + constructor() { + this.name = "deleteLog1634902659689"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "log"`); + } + async down(queryRunner: QueryRunner): Promise {} +} diff --git a/packages/backend/src/migration/1635500777168-note-thread-mute.ts b/packages/backend/src/migration/1635500777168-note-thread-mute.ts new file mode 100644 index 0000000..a08d0d2 --- /dev/null +++ b/packages/backend/src/migration/1635500777168-note-thread-mute.ts @@ -0,0 +1,48 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class noteThreadMute1635500777168 implements MigrationInterface { + constructor() { + this.name = "noteThreadMute1635500777168"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "note_thread_muting" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "threadId" character varying(256) NOT NULL, CONSTRAINT "PK_ec5936d94d1a0369646d12a3a47" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_29c11c7deb06615076f8c95b80" ON "note_thread_muting" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_c426394644267453e76f036926" ON "note_thread_muting" ("threadId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_ae7aab18a2641d3e5f25e0c4ea" ON "note_thread_muting" ("userId", "threadId") `, + ); + await queryRunner.query( + `ALTER TABLE "note" ADD "threadId" character varying(256)`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_d4ebdef929896d6dc4a3c5bb48" ON "note" ("threadId") `, + ); + await queryRunner.query( + `ALTER TABLE "note_thread_muting" ADD CONSTRAINT "FK_29c11c7deb06615076f8c95b80a" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "note_thread_muting" DROP CONSTRAINT "FK_29c11c7deb06615076f8c95b80a"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_d4ebdef929896d6dc4a3c5bb48"`, + ); + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "threadId"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_ae7aab18a2641d3e5f25e0c4ea"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_c426394644267453e76f036926"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_29c11c7deb06615076f8c95b80"`, + ); + await queryRunner.query(`DROP TABLE "note_thread_muting"`); + } +} diff --git a/packages/backend/src/migration/1636197624383-ff-visibility.ts b/packages/backend/src/migration/1636197624383-ff-visibility.ts new file mode 100644 index 0000000..539a9d8 --- /dev/null +++ b/packages/backend/src/migration/1636197624383-ff-visibility.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class ffVisibility1636197624383 implements MigrationInterface { + constructor() { + this.name = "ffVisibility1636197624383"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."user_profile_ffvisibility_enum" AS ENUM('public', 'followers', 'private')`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "ffVisibility" "public"."user_profile_ffvisibility_enum" NOT NULL DEFAULT 'public'`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "ffVisibility"`, + ); + await queryRunner.query( + `DROP TYPE "public"."user_profile_ffvisibility_enum"`, + ); + } +} diff --git a/packages/backend/src/migration/1636697408073-remove-via-mobile.ts b/packages/backend/src/migration/1636697408073-remove-via-mobile.ts new file mode 100644 index 0000000..775fcc9 --- /dev/null +++ b/packages/backend/src/migration/1636697408073-remove-via-mobile.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class removeViaMobile1636697408073 implements MigrationInterface { + name = "removeViaMobile1636697408073"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "viaMobile"`); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "note" ADD "viaMobile" boolean NOT NULL DEFAULT false`, + ); + } +} diff --git a/packages/backend/src/migration/1637320813000-forwarded-report.ts b/packages/backend/src/migration/1637320813000-forwarded-report.ts new file mode 100644 index 0000000..2a2f80c --- /dev/null +++ b/packages/backend/src/migration/1637320813000-forwarded-report.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class forwardedReport1637320813000 implements MigrationInterface { + name = "forwardedReport1637320813000"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "abuse_user_report" ADD "forwarded" boolean NOT NULL DEFAULT false`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "abuse_user_report" DROP COLUMN "forwarded"`, + ); + } +} diff --git a/packages/backend/src/migration/1639325650583-chart-v3.ts b/packages/backend/src/migration/1639325650583-chart-v3.ts new file mode 100644 index 0000000..a2d0da7 --- /dev/null +++ b/packages/backend/src/migration/1639325650583-chart-v3.ts @@ -0,0 +1,512 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartV31639325650583 implements MigrationInterface { + name = "chartV31639325650583"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "__chart__per_user_drive" WHERE "group" IS NULL`, + ); + + await queryRunner.query( + `DROP INDEX "public"."IDX_dd907becf76104e4b656659e6b"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_eddfed8fb40305a04c6f941050"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_f09d543e3acb16c5976bdb31fa"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_e60c358aaced5aab8900a4af31"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_337e9599f278bd7537fe30876f"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_66feba81e1795d176d06c0b1e6"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_0a905b992fecd2b5c3fb98759e"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_2082327b2699ce924fa654afc5"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_9a3ed15a30ab7e3a37702e6e08"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_60c5c6e7e538c09aa274ecd1cf"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_8111b817b9818c04d7eb8475b1"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_583a157ed0cf0ed1b5ec2a833f"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_3313d7288855ec105b5bbf6c21"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_ceab80a6729f8e2e6f5b8a1a3d"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_3b7697a96f522d0478972e6d6f"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_53a3604b939e2b479eb2cfaac8"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_dabbb38a51ab86ee3cab291326"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_a9a806d466b314f253a1a611c4"`, + ); + await queryRunner.query( + `CREATE TABLE "__chart_day__federation" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "___instance_total" bigint NOT NULL, "___instance_inc" bigint NOT NULL, "___instance_dec" bigint NOT NULL, CONSTRAINT "UQ_617a8fe225a6e701d89e02d2c74" UNIQUE ("date"), CONSTRAINT "PK_7ca721c769f31698e0e1331e8e6" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_617a8fe225a6e701d89e02d2c7" ON "__chart_day__federation" ("date") `, + ); + await queryRunner.query( + `CREATE TABLE "__chart_day__notes" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "___local_total" bigint NOT NULL, "___local_inc" bigint NOT NULL, "___local_dec" bigint NOT NULL, "___local_diffs_normal" bigint NOT NULL, "___local_diffs_reply" bigint NOT NULL, "___local_diffs_renote" bigint NOT NULL, "___remote_total" bigint NOT NULL, "___remote_inc" bigint NOT NULL, "___remote_dec" bigint NOT NULL, "___remote_diffs_normal" bigint NOT NULL, "___remote_diffs_reply" bigint NOT NULL, "___remote_diffs_renote" bigint NOT NULL, CONSTRAINT "UQ_1a527b423ad0858a1af5a056d43" UNIQUE ("date"), CONSTRAINT "PK_1fa4139e1f338272b758d05e090" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_1a527b423ad0858a1af5a056d4" ON "__chart_day__notes" ("date") `, + ); + await queryRunner.query( + `CREATE TABLE "__chart_day__users" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "___local_total" bigint NOT NULL, "___local_inc" bigint NOT NULL, "___local_dec" bigint NOT NULL, "___remote_total" bigint NOT NULL, "___remote_inc" bigint NOT NULL, "___remote_dec" bigint NOT NULL, CONSTRAINT "UQ_cad6e07c20037f31cdba8a350c3" UNIQUE ("date"), CONSTRAINT "PK_d7f7185abb9851f70c4726c54bd" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_cad6e07c20037f31cdba8a350c" ON "__chart_day__users" ("date") `, + ); + await queryRunner.query( + `CREATE TABLE "__chart_day__network" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "___incomingRequests" bigint NOT NULL, "___outgoingRequests" bigint NOT NULL, "___totalTime" bigint NOT NULL, "___incomingBytes" bigint NOT NULL, "___outgoingBytes" bigint NOT NULL, CONSTRAINT "UQ_8bfa548c2b31f9e07db113773ee" UNIQUE ("date"), CONSTRAINT "PK_cac499d6f471042dfed1e7e0132" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_8bfa548c2b31f9e07db113773e" ON "__chart_day__network" ("date") `, + ); + await queryRunner.query( + `CREATE TABLE "__chart_day__active_users" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "___local_users" character varying array NOT NULL, "___remote_users" character varying array NOT NULL, CONSTRAINT "UQ_d5954f3df5e5e3bdfc3c03f3906" UNIQUE ("date"), CONSTRAINT "PK_b1790489b14f005ae8f404f5795" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_d5954f3df5e5e3bdfc3c03f390" ON "__chart_day__active_users" ("date") `, + ); + await queryRunner.query( + `CREATE TABLE "__chart_day__instance" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128) NOT NULL, "___requests_failed" bigint NOT NULL, "___requests_succeeded" bigint NOT NULL, "___requests_received" bigint NOT NULL, "___notes_total" bigint NOT NULL, "___notes_inc" bigint NOT NULL, "___notes_dec" bigint NOT NULL, "___notes_diffs_normal" bigint NOT NULL, "___notes_diffs_reply" bigint NOT NULL, "___notes_diffs_renote" bigint NOT NULL, "___users_total" bigint NOT NULL, "___users_inc" bigint NOT NULL, "___users_dec" bigint NOT NULL, "___following_total" bigint NOT NULL, "___following_inc" bigint NOT NULL, "___following_dec" bigint NOT NULL, "___followers_total" bigint NOT NULL, "___followers_inc" bigint NOT NULL, "___followers_dec" bigint NOT NULL, "___drive_totalFiles" bigint NOT NULL, "___drive_totalUsage" bigint NOT NULL, "___drive_incFiles" bigint NOT NULL, "___drive_incUsage" bigint NOT NULL, "___drive_decFiles" bigint NOT NULL, "___drive_decUsage" bigint NOT NULL, CONSTRAINT "UQ_fea7c0278325a1a2492f2d6acbf" UNIQUE ("date", "group"), CONSTRAINT "PK_479a8ff9d959274981087043023" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_fea7c0278325a1a2492f2d6acb" ON "__chart_day__instance" ("date", "group") `, + ); + await queryRunner.query( + `CREATE TABLE "__chart_day__per_user_notes" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128) NOT NULL, "___total" bigint NOT NULL, "___inc" bigint NOT NULL, "___dec" bigint NOT NULL, "___diffs_normal" bigint NOT NULL, "___diffs_reply" bigint NOT NULL, "___diffs_renote" bigint NOT NULL, CONSTRAINT "UQ_c5545d4b31cdc684034e33b81c3" UNIQUE ("date", "group"), CONSTRAINT "PK_58bab6b6d3ad9310cbc7460fd28" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_c5545d4b31cdc684034e33b81c" ON "__chart_day__per_user_notes" ("date", "group") `, + ); + await queryRunner.query( + `CREATE TABLE "__chart_day__drive" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "___local_totalCount" bigint NOT NULL, "___local_totalSize" bigint NOT NULL, "___local_incCount" bigint NOT NULL, "___local_incSize" bigint NOT NULL, "___local_decCount" bigint NOT NULL, "___local_decSize" bigint NOT NULL, "___remote_totalCount" bigint NOT NULL, "___remote_totalSize" bigint NOT NULL, "___remote_incCount" bigint NOT NULL, "___remote_incSize" bigint NOT NULL, "___remote_decCount" bigint NOT NULL, "___remote_decSize" bigint NOT NULL, CONSTRAINT "UQ_0b60ebb3aa0065f10b0616c1171" UNIQUE ("date"), CONSTRAINT "PK_e7ec0de057c77c40fc8d8b62151" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_0b60ebb3aa0065f10b0616c117" ON "__chart_day__drive" ("date") `, + ); + await queryRunner.query( + `CREATE TABLE "__chart_day__per_user_reaction" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128) NOT NULL, "___local_count" bigint NOT NULL, "___remote_count" bigint NOT NULL, CONSTRAINT "UQ_d54b653660d808b118e36c184c0" UNIQUE ("date", "group"), CONSTRAINT "PK_8af24e2d51ff781a354fe595eda" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_d54b653660d808b118e36c184c" ON "__chart_day__per_user_reaction" ("date", "group") `, + ); + await queryRunner.query( + `CREATE TABLE "__chart_day__hashtag" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128) NOT NULL, "___local_users" character varying array NOT NULL, "___remote_users" character varying array NOT NULL, CONSTRAINT "UQ_8f589cf056ff51f09d6096f6450" UNIQUE ("date", "group"), CONSTRAINT "PK_13d5a3b089344e5557f8e0980b4" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_8f589cf056ff51f09d6096f645" ON "__chart_day__hashtag" ("date", "group") `, + ); + await queryRunner.query( + `CREATE TABLE "__chart_day__per_user_following" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128) NOT NULL, "___local_followings_total" bigint NOT NULL, "___local_followings_inc" bigint NOT NULL, "___local_followings_dec" bigint NOT NULL, "___local_followers_total" bigint NOT NULL, "___local_followers_inc" bigint NOT NULL, "___local_followers_dec" bigint NOT NULL, "___remote_followings_total" bigint NOT NULL, "___remote_followings_inc" bigint NOT NULL, "___remote_followings_dec" bigint NOT NULL, "___remote_followers_total" bigint NOT NULL, "___remote_followers_inc" bigint NOT NULL, "___remote_followers_dec" bigint NOT NULL, CONSTRAINT "UQ_e4849a3231f38281280ea4c0eee" UNIQUE ("date", "group"), CONSTRAINT "PK_68ce6b67da57166da66fc8fb27e" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_e4849a3231f38281280ea4c0ee" ON "__chart_day__per_user_following" ("date", "group") `, + ); + await queryRunner.query( + `CREATE TABLE "__chart_day__per_user_drive" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "group" character varying(128) NOT NULL, "___totalCount" bigint NOT NULL, "___totalSize" bigint NOT NULL, "___incCount" bigint NOT NULL, "___incSize" bigint NOT NULL, "___decCount" bigint NOT NULL, "___decSize" bigint NOT NULL, CONSTRAINT "UQ_62aa5047b5aec92524f24c701d7" UNIQUE ("date", "group"), CONSTRAINT "PK_1ae135254c137011645da7f4045" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_62aa5047b5aec92524f24c701d" ON "__chart_day__per_user_drive" ("date", "group") `, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "group"`, + ); + await queryRunner.query(`ALTER TABLE "__chart__notes" DROP COLUMN "group"`); + await queryRunner.query(`ALTER TABLE "__chart__users" DROP COLUMN "group"`); + await queryRunner.query( + `ALTER TABLE "__chart__network" DROP COLUMN "group"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "group"`, + ); + await queryRunner.query(`ALTER TABLE "__chart__drive" DROP COLUMN "group"`); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD CONSTRAINT "UQ_36cb699c49580d4e6c2e6159f97" UNIQUE ("date")`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ADD CONSTRAINT "UQ_42eb716a37d381cdf566192b2be" UNIQUE ("date")`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ADD CONSTRAINT "UQ_845254b3eaf708ae8a6cac30265" UNIQUE ("date")`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ADD CONSTRAINT "UQ_a1efd3e0048a5f2793a47360dc6" UNIQUE ("date")`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD CONSTRAINT "UQ_0ad37b7ef50f4ddc84363d7ccca" UNIQUE ("date")`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ALTER COLUMN "___local_users" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ALTER COLUMN "___remote_users" DROP DEFAULT`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_39ee857ab2f23493037c6b6631"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "group" SET NOT NULL`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_5048e9daccbbbc6d567bb142d3"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "group" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ADD CONSTRAINT "UQ_13565815f618a1ff53886c5b28a" UNIQUE ("date")`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_229a41ad465f9205f1f5703291"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_reaction" ALTER COLUMN "group" SET NOT NULL`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_25a97c02003338124b2b75fdbc"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ALTER COLUMN "group" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ALTER COLUMN "___local_users" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ALTER COLUMN "___remote_users" DROP DEFAULT`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_b77d4dd9562c3a899d9a286fcd"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "group" SET NOT NULL`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_30bf67687f483ace115c5ca642"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "group" SET NOT NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_36cb699c49580d4e6c2e6159f9" ON "__chart__federation" ("date") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_42eb716a37d381cdf566192b2b" ON "__chart__notes" ("date") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_845254b3eaf708ae8a6cac3026" ON "__chart__users" ("date") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_a1efd3e0048a5f2793a47360dc" ON "__chart__network" ("date") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_0ad37b7ef50f4ddc84363d7ccc" ON "__chart__active_users" ("date") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_39ee857ab2f23493037c6b6631" ON "__chart__instance" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_5048e9daccbbbc6d567bb142d3" ON "__chart__per_user_notes" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_13565815f618a1ff53886c5b28" ON "__chart__drive" ("date") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_229a41ad465f9205f1f5703291" ON "__chart__per_user_reaction" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_25a97c02003338124b2b75fdbc" ON "__chart__hashtag" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_b77d4dd9562c3a899d9a286fcd" ON "__chart__per_user_following" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_30bf67687f483ace115c5ca642" ON "__chart__per_user_drive" ("date", "group") `, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ADD CONSTRAINT "UQ_39ee857ab2f23493037c6b66311" UNIQUE ("date", "group")`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ADD CONSTRAINT "UQ_5048e9daccbbbc6d567bb142d34" UNIQUE ("date", "group")`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_reaction" ADD CONSTRAINT "UQ_229a41ad465f9205f1f57032910" UNIQUE ("date", "group")`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ADD CONSTRAINT "UQ_25a97c02003338124b2b75fdbc8" UNIQUE ("date", "group")`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ADD CONSTRAINT "UQ_b77d4dd9562c3a899d9a286fcd7" UNIQUE ("date", "group")`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ADD CONSTRAINT "UQ_30bf67687f483ace115c5ca6429" UNIQUE ("date", "group")`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" DROP CONSTRAINT "UQ_30bf67687f483ace115c5ca6429"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" DROP CONSTRAINT "UQ_b77d4dd9562c3a899d9a286fcd7"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" DROP CONSTRAINT "UQ_25a97c02003338124b2b75fdbc8"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_reaction" DROP CONSTRAINT "UQ_229a41ad465f9205f1f57032910"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" DROP CONSTRAINT "UQ_5048e9daccbbbc6d567bb142d34"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" DROP CONSTRAINT "UQ_39ee857ab2f23493037c6b66311"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_30bf67687f483ace115c5ca642"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_b77d4dd9562c3a899d9a286fcd"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_25a97c02003338124b2b75fdbc"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_229a41ad465f9205f1f5703291"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_13565815f618a1ff53886c5b28"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_5048e9daccbbbc6d567bb142d3"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_39ee857ab2f23493037c6b6631"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_0ad37b7ef50f4ddc84363d7ccc"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_a1efd3e0048a5f2793a47360dc"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_845254b3eaf708ae8a6cac3026"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_42eb716a37d381cdf566192b2b"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_36cb699c49580d4e6c2e6159f9"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "group" DROP NOT NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_30bf67687f483ace115c5ca642" ON "__chart__per_user_drive" ("date", "group") `, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "group" DROP NOT NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_b77d4dd9562c3a899d9a286fcd" ON "__chart__per_user_following" ("date", "group") `, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ALTER COLUMN "___remote_users" SET DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ALTER COLUMN "___local_users" SET DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ALTER COLUMN "group" DROP NOT NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_25a97c02003338124b2b75fdbc" ON "__chart__hashtag" ("date", "group") `, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_reaction" ALTER COLUMN "group" DROP NOT NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_229a41ad465f9205f1f5703291" ON "__chart__per_user_reaction" ("date", "group") `, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" DROP CONSTRAINT "UQ_13565815f618a1ff53886c5b28a"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "group" DROP NOT NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_5048e9daccbbbc6d567bb142d3" ON "__chart__per_user_notes" ("date", "group") `, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "group" DROP NOT NULL`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_39ee857ab2f23493037c6b6631" ON "__chart__instance" ("date", "group") `, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ALTER COLUMN "___remote_users" SET DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ALTER COLUMN "___local_users" SET DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP CONSTRAINT "UQ_0ad37b7ef50f4ddc84363d7ccca"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" DROP CONSTRAINT "UQ_a1efd3e0048a5f2793a47360dc6"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" DROP CONSTRAINT "UQ_845254b3eaf708ae8a6cac30265"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" DROP CONSTRAINT "UQ_42eb716a37d381cdf566192b2be"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP CONSTRAINT "UQ_36cb699c49580d4e6c2e6159f97"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ADD "group" character varying(128)`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "group" character varying(128)`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ADD "group" character varying(128)`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ADD "group" character varying(128)`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ADD "group" character varying(128)`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "group" character varying(128)`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_62aa5047b5aec92524f24c701d"`, + ); + await queryRunner.query(`DROP TABLE "__chart_day__per_user_drive"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_e4849a3231f38281280ea4c0ee"`, + ); + await queryRunner.query(`DROP TABLE "__chart_day__per_user_following"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_8f589cf056ff51f09d6096f645"`, + ); + await queryRunner.query(`DROP TABLE "__chart_day__hashtag"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_d54b653660d808b118e36c184c"`, + ); + await queryRunner.query(`DROP TABLE "__chart_day__per_user_reaction"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_0b60ebb3aa0065f10b0616c117"`, + ); + await queryRunner.query(`DROP TABLE "__chart_day__drive"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_c5545d4b31cdc684034e33b81c"`, + ); + await queryRunner.query(`DROP TABLE "__chart_day__per_user_notes"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_fea7c0278325a1a2492f2d6acb"`, + ); + await queryRunner.query(`DROP TABLE "__chart_day__instance"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_d5954f3df5e5e3bdfc3c03f390"`, + ); + await queryRunner.query(`DROP TABLE "__chart_day__active_users"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_8bfa548c2b31f9e07db113773e"`, + ); + await queryRunner.query(`DROP TABLE "__chart_day__network"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_cad6e07c20037f31cdba8a350c"`, + ); + await queryRunner.query(`DROP TABLE "__chart_day__users"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_1a527b423ad0858a1af5a056d4"`, + ); + await queryRunner.query(`DROP TABLE "__chart_day__notes"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_617a8fe225a6e701d89e02d2c7"`, + ); + await queryRunner.query(`DROP TABLE "__chart_day__federation"`); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_a9a806d466b314f253a1a611c4" ON "__chart__per_user_drive" ("date") WHERE ("group" IS NULL)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_dabbb38a51ab86ee3cab291326" ON "__chart__per_user_following" ("date") WHERE ("group" IS NULL)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_53a3604b939e2b479eb2cfaac8" ON "__chart__hashtag" ("date") WHERE ("group" IS NULL)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_3b7697a96f522d0478972e6d6f" ON "__chart__per_user_reaction" ("date") WHERE ("group" IS NULL)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_ceab80a6729f8e2e6f5b8a1a3d" ON "__chart__drive" ("date") WHERE ("group" IS NULL)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_3313d7288855ec105b5bbf6c21" ON "__chart__drive" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_583a157ed0cf0ed1b5ec2a833f" ON "__chart__per_user_notes" ("date") WHERE ("group" IS NULL)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_8111b817b9818c04d7eb8475b1" ON "__chart__instance" ("date") WHERE ("group" IS NULL)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_60c5c6e7e538c09aa274ecd1cf" ON "__chart__active_users" ("date") WHERE ("group" IS NULL)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_9a3ed15a30ab7e3a37702e6e08" ON "__chart__active_users" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_2082327b2699ce924fa654afc5" ON "__chart__network" ("date") WHERE ("group" IS NULL)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_0a905b992fecd2b5c3fb98759e" ON "__chart__network" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_66feba81e1795d176d06c0b1e6" ON "__chart__users" ("date") WHERE ("group" IS NULL)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_337e9599f278bd7537fe30876f" ON "__chart__users" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_e60c358aaced5aab8900a4af31" ON "__chart__notes" ("date") WHERE ("group" IS NULL)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_f09d543e3acb16c5976bdb31fa" ON "__chart__notes" ("date", "group") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_eddfed8fb40305a04c6f941050" ON "__chart__federation" ("date") WHERE ("group" IS NULL)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_dd907becf76104e4b656659e6b" ON "__chart__federation" ("date", "group") `, + ); + } +} diff --git a/packages/backend/src/migration/1642611822809-emoji-url.ts b/packages/backend/src/migration/1642611822809-emoji-url.ts new file mode 100644 index 0000000..e0279b9 --- /dev/null +++ b/packages/backend/src/migration/1642611822809-emoji-url.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class emojiUrl1642611822809 implements MigrationInterface { + name = "emojiUrl1642611822809"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "emoji" RENAME COLUMN "url" TO "originalUrl"`, + ); + await queryRunner.query( + `ALTER TABLE "emoji" ADD "publicUrl" character varying(512) NOT NULL DEFAULT ''`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "publicUrl"`); + await queryRunner.query( + `ALTER TABLE "emoji" RENAME COLUMN "originalUrl" TO "url"`, + ); + } +} diff --git a/packages/backend/src/migration/1642613870898-drive-file-webpublic-type.ts b/packages/backend/src/migration/1642613870898-drive-file-webpublic-type.ts new file mode 100644 index 0000000..feaa354 --- /dev/null +++ b/packages/backend/src/migration/1642613870898-drive-file-webpublic-type.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class driveFileWebpublicType1642613870898 implements MigrationInterface { + name = "driveFileWebpublicType1642613870898"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "drive_file" ADD "webpublicType" character varying(128)`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "drive_file" DROP COLUMN "webpublicType"`, + ); + } +} diff --git a/packages/backend/src/migration/1643963705770-chart-v4.ts b/packages/backend/src/migration/1643963705770-chart-v4.ts new file mode 100644 index 0000000..59f1441 --- /dev/null +++ b/packages/backend/src/migration/1643963705770-chart-v4.ts @@ -0,0 +1,166 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartV41643963705770 implements MigrationInterface { + name = "chartV41643963705770"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__instance" DROP COLUMN "___drive_totalUsage"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" DROP COLUMN "___drive_totalUsage"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" DROP COLUMN "___local_totalCount"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" DROP COLUMN "___local_totalSize"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" DROP COLUMN "___remote_totalCount"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" DROP COLUMN "___remote_totalSize"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" DROP COLUMN "___local_totalCount"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" DROP COLUMN "___local_totalSize"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" DROP COLUMN "___remote_totalCount"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" DROP COLUMN "___remote_totalSize"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___local_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___local_users" bigint NOT NULL DEFAULT 0`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___remote_users" bigint NOT NULL DEFAULT 0`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___local_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___local_users" bigint NOT NULL DEFAULT 0`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___remote_users" bigint NOT NULL DEFAULT 0`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" DROP COLUMN "___local_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ADD "___local_users" bigint NOT NULL DEFAULT 0`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" DROP COLUMN "___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ADD "___remote_users" bigint NOT NULL DEFAULT 0`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__hashtag" DROP COLUMN "___local_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__hashtag" ADD "___local_users" bigint NOT NULL DEFAULT 0`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__hashtag" DROP COLUMN "___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__hashtag" ADD "___remote_users" bigint NOT NULL DEFAULT 0`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart_day__hashtag" DROP COLUMN "___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__hashtag" ADD "___remote_users" character varying array NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__hashtag" DROP COLUMN "___local_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__hashtag" ADD "___local_users" character varying array NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" DROP COLUMN "___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ADD "___remote_users" character varying array NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" DROP COLUMN "___local_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ADD "___local_users" character varying array NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___remote_users" character varying array NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___local_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___local_users" character varying array NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___remote_users" character varying array NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___local_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___local_users" character varying array NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ADD "___remote_totalSize" bigint NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ADD "___remote_totalCount" bigint NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ADD "___local_totalSize" bigint NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ADD "___local_totalCount" bigint NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ADD "___remote_totalSize" bigint NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ADD "___remote_totalCount" bigint NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ADD "___local_totalSize" bigint NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ADD "___local_totalCount" bigint NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ADD "___drive_totalUsage" bigint NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ADD "___drive_totalUsage" bigint NOT NULL`, + ); + } +} diff --git a/packages/backend/src/migration/1643966656277-chart-v5.ts b/packages/backend/src/migration/1643966656277-chart-v5.ts new file mode 100644 index 0000000..28647fa --- /dev/null +++ b/packages/backend/src/migration/1643966656277-chart-v5.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartV51643966656277 implements MigrationInterface { + name = "chartV51643966656277"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "unique_temp___local_users" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "unique_temp___remote_users" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "unique_temp___local_users" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "unique_temp___remote_users" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ADD "unique_temp___local_users" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ADD "unique_temp___remote_users" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__hashtag" ADD "unique_temp___local_users" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__hashtag" ADD "unique_temp___remote_users" character varying array NOT NULL DEFAULT '{}'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart_day__hashtag" DROP COLUMN "unique_temp___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__hashtag" DROP COLUMN "unique_temp___local_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" DROP COLUMN "unique_temp___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" DROP COLUMN "unique_temp___local_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "unique_temp___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "unique_temp___local_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "unique_temp___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "unique_temp___local_users"`, + ); + } +} diff --git a/packages/backend/src/migration/1643967331284-chart-v6.ts b/packages/backend/src/migration/1643967331284-chart-v6.ts new file mode 100644 index 0000000..6945ebb --- /dev/null +++ b/packages/backend/src/migration/1643967331284-chart-v6.ts @@ -0,0 +1,1006 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartV61643967331284 implements MigrationInterface { + name = "chartV61643967331284"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__federation" ALTER COLUMN "___instance_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ALTER COLUMN "___instance_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ALTER COLUMN "___instance_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ALTER COLUMN "___instance_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ALTER COLUMN "___instance_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ALTER COLUMN "___instance_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_diffs_normal" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_diffs_reply" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_diffs_renote" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_diffs_normal" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_diffs_reply" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_diffs_renote" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_diffs_normal" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_diffs_reply" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_diffs_renote" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_diffs_normal" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_diffs_reply" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_diffs_renote" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___local_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___local_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___local_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___remote_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___remote_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___remote_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___local_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___local_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___local_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___remote_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___remote_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___remote_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___incomingRequests" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___outgoingRequests" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___totalTime" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___incomingBytes" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___outgoingBytes" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___incomingRequests" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___outgoingRequests" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___totalTime" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___incomingBytes" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___outgoingBytes" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___requests_failed" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___requests_succeeded" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___requests_received" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_diffs_normal" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_diffs_reply" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_diffs_renote" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___users_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___users_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___users_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___following_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___following_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___following_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___followers_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___followers_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___followers_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_totalFiles" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_incFiles" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_decFiles" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_incUsage" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_decUsage" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___requests_failed" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___requests_succeeded" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___requests_received" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_diffs_normal" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_diffs_reply" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_diffs_renote" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___users_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___users_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___users_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___following_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___following_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___following_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___followers_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___followers_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___followers_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_totalFiles" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_incFiles" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_decFiles" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_incUsage" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_decUsage" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___diffs_normal" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___diffs_reply" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___diffs_renote" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___diffs_normal" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___diffs_reply" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___diffs_renote" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___local_incCount" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___local_incSize" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___local_decCount" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___local_decSize" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___remote_incCount" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___remote_incSize" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___remote_decCount" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___remote_decSize" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___local_incCount" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___local_incSize" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___local_decCount" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___local_decSize" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___remote_incCount" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___remote_incSize" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___remote_decCount" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___remote_decSize" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_reaction" ALTER COLUMN "___local_count" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_reaction" ALTER COLUMN "___remote_count" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_reaction" ALTER COLUMN "___local_count" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_reaction" ALTER COLUMN "___remote_count" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followings_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followings_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followings_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followers_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followers_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followers_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followings_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followings_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followings_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followers_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followers_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followers_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followings_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followings_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followings_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followers_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followers_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followers_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followings_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followings_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followings_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followers_total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followers_inc" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followers_dec" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___totalCount" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___totalSize" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___incCount" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___incSize" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___decCount" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___decSize" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___totalCount" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___totalSize" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___incCount" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___incSize" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___decCount" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___decSize" SET DEFAULT '0'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___decSize" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___decCount" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___incSize" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___incCount" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___totalSize" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___totalCount" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___decSize" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___decCount" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___incSize" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___incCount" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___totalSize" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___totalCount" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followers_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followers_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followers_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followings_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followings_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followings_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followers_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followers_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followers_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followings_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followings_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followings_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followers_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followers_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followers_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followings_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followings_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followings_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followers_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followers_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followers_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followings_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followings_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followings_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_reaction" ALTER COLUMN "___remote_count" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_reaction" ALTER COLUMN "___local_count" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_reaction" ALTER COLUMN "___remote_count" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_reaction" ALTER COLUMN "___local_count" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___remote_decSize" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___remote_decCount" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___remote_incSize" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___remote_incCount" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___local_decSize" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___local_decCount" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___local_incSize" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___local_incCount" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___remote_decSize" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___remote_decCount" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___remote_incSize" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___remote_incCount" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___local_decSize" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___local_decCount" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___local_incSize" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___local_incCount" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___diffs_renote" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___diffs_reply" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___diffs_normal" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___diffs_renote" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___diffs_reply" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___diffs_normal" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_decUsage" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_incUsage" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_decFiles" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_incFiles" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_totalFiles" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___followers_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___followers_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___followers_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___following_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___following_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___following_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___users_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___users_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___users_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_diffs_renote" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_diffs_reply" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_diffs_normal" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___requests_received" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___requests_succeeded" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___requests_failed" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_decUsage" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_incUsage" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_decFiles" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_incFiles" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_totalFiles" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___followers_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___followers_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___followers_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___following_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___following_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___following_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___users_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___users_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___users_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_diffs_renote" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_diffs_reply" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_diffs_normal" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___requests_received" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___requests_succeeded" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___requests_failed" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___outgoingBytes" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___incomingBytes" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___totalTime" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___outgoingRequests" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___incomingRequests" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___outgoingBytes" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___incomingBytes" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___totalTime" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___outgoingRequests" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___incomingRequests" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___remote_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___remote_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___remote_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___local_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___local_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___local_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___remote_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___remote_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___remote_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___local_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___local_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___local_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_diffs_renote" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_diffs_reply" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_diffs_normal" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_diffs_renote" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_diffs_reply" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_diffs_normal" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_diffs_renote" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_diffs_reply" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_diffs_normal" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_diffs_renote" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_diffs_reply" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_diffs_normal" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ALTER COLUMN "___instance_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ALTER COLUMN "___instance_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ALTER COLUMN "___instance_total" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ALTER COLUMN "___instance_dec" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ALTER COLUMN "___instance_inc" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ALTER COLUMN "___instance_total" DROP DEFAULT`, + ); + } +} diff --git a/packages/backend/src/migration/1644010796173-convert-hard-mutes.ts b/packages/backend/src/migration/1644010796173-convert-hard-mutes.ts new file mode 100644 index 0000000..0ddd2bd --- /dev/null +++ b/packages/backend/src/migration/1644010796173-convert-hard-mutes.ts @@ -0,0 +1,71 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +import RE2 from "re2"; + +export class convertHardMutes1644010796173 implements MigrationInterface { + name = "convertHardMutes1644010796173"; + + async up(queryRunner: QueryRunner): Promise { + let entries = await queryRunner.query( + `SELECT "userId", "mutedWords" FROM "user_profile" WHERE "userHost" IS NULL`, + ); + for (let i = 0; i < entries.length; i++) { + let words = entries[i].mutedWords + .map((line) => { + if (typeof line === "string") return []; + const regexp = line.join(" ").match(/^\/(.+)\/(.*)$/); + if (regexp) { + // convert regexp's + try { + new RE2(regexp[1], regexp[2]); + return `/${regexp[1]}/${regexp[2]}`; + } catch (err) { + // invalid regex, ignore it + return []; + } + } else { + // remove empty segments + return line.filter((x) => x !== ""); + } + }) + // remove empty lines + .filter((x) => !(Array.isArray(x) && x.length === 0)); + + await queryRunner.connection + .createQueryBuilder() + .update("user_profile") + .set({ + mutedWords: words, + }) + .where("userId = :id", { id: entries[i].userId }) + .execute(); + } + } + + async down(queryRunner: QueryRunner): Promise { + let entries = await queryRunner.query( + `SELECT "userId", "mutedWords" FROM "user_profile"`, + ); + for (let i = 0; i < entries.length; i++) { + let words = entries[i].mutedWords + .map((line) => { + if (Array.isArray(line)) { + return line; + } else { + // do not split regex at spaces again + return [line]; + } + }) + // remove empty lines + .filter((x) => !(Array.isArray(x) && x.length === 0)); + + await queryRunner.connection + .createQueryBuilder() + .update("user_profile") + .set({ + mutedWords: words, + }) + .where("userId = :id", { id: entries[i].userId }) + .execute(); + } + } +} diff --git a/packages/backend/src/migration/1644058404077-chart-v7.ts b/packages/backend/src/migration/1644058404077-chart-v7.ts new file mode 100644 index 0000000..420f38b --- /dev/null +++ b/packages/backend/src/migration/1644058404077-chart-v7.ts @@ -0,0 +1,1471 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartV71644058404077 implements MigrationInterface { + name = "chartV71644058404077"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "__chart__federation" SET "___instance_total"=2147483647 WHERE "___instance_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__federation" SET "___instance_inc"=32767 WHERE "___instance_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__federation" SET "___instance_dec"=32767 WHERE "___instance_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__federation" SET "___instance_total"=2147483647 WHERE "___instance_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__federation" SET "___instance_inc"=32767 WHERE "___instance_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__federation" SET "___instance_dec"=32767 WHERE "___instance_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__notes" SET "___local_total"=2147483647 WHERE "___local_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__notes" SET "___local_inc"=2147483647 WHERE "___local_inc" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__notes" SET "___local_dec"=2147483647 WHERE "___local_dec" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__notes" SET "___local_diffs_normal"=2147483647 WHERE "___local_diffs_normal" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__notes" SET "___local_diffs_reply"=2147483647 WHERE "___local_diffs_reply" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__notes" SET "___local_diffs_renote"=2147483647 WHERE "___local_diffs_renote" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__notes" SET "___remote_total"=2147483647 WHERE "___remote_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__notes" SET "___remote_inc"=2147483647 WHERE "___remote_inc" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__notes" SET "___remote_dec"=2147483647 WHERE "___remote_dec" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__notes" SET "___remote_diffs_normal"=2147483647 WHERE "___remote_diffs_normal" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__notes" SET "___remote_diffs_reply"=2147483647 WHERE "___remote_diffs_reply" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__notes" SET "___remote_diffs_renote"=2147483647 WHERE "___remote_diffs_renote" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__notes" SET "___local_total"=2147483647 WHERE "___local_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__notes" SET "___local_inc"=2147483647 WHERE "___local_inc" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__notes" SET "___local_dec"=2147483647 WHERE "___local_dec" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__notes" SET "___local_diffs_normal"=2147483647 WHERE "___local_diffs_normal" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__notes" SET "___local_diffs_reply"=2147483647 WHERE "___local_diffs_reply" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__notes" SET "___local_diffs_renote"=2147483647 WHERE "___local_diffs_renote" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__notes" SET "___remote_total"=2147483647 WHERE "___remote_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__notes" SET "___remote_inc"=2147483647 WHERE "___remote_inc" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__notes" SET "___remote_dec"=2147483647 WHERE "___remote_dec" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__notes" SET "___remote_diffs_normal"=2147483647 WHERE "___remote_diffs_normal" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__notes" SET "___remote_diffs_reply"=2147483647 WHERE "___remote_diffs_reply" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__notes" SET "___remote_diffs_renote"=2147483647 WHERE "___remote_diffs_renote" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__users" SET "___local_total"=2147483647 WHERE "___local_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__users" SET "___local_inc"=32767 WHERE "___local_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__users" SET "___local_dec"=32767 WHERE "___local_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__users" SET "___remote_total"=2147483647 WHERE "___remote_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__users" SET "___remote_inc"=32767 WHERE "___remote_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__users" SET "___remote_dec"=32767 WHERE "___remote_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__users" SET "___local_total"=2147483647 WHERE "___local_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__users" SET "___local_inc"=32767 WHERE "___local_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__users" SET "___local_dec"=32767 WHERE "___local_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__users" SET "___remote_total"=2147483647 WHERE "___remote_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__users" SET "___remote_inc"=32767 WHERE "___remote_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__users" SET "___remote_dec"=32767 WHERE "___remote_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__network" SET "___incomingRequests"=2147483647 WHERE "___incomingRequests" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__network" SET "___outgoingRequests"=2147483647 WHERE "___outgoingRequests" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__network" SET "___totalTime"=2147483647 WHERE "___totalTime" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__network" SET "___incomingBytes"=2147483647 WHERE "___incomingBytes" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__network" SET "___outgoingBytes"=2147483647 WHERE "___outgoingBytes" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__network" SET "___incomingRequests"=2147483647 WHERE "___incomingRequests" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__network" SET "___outgoingRequests"=2147483647 WHERE "___outgoingRequests" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__network" SET "___totalTime"=2147483647 WHERE "___totalTime" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__network" SET "___incomingBytes"=2147483647 WHERE "___incomingBytes" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__network" SET "___outgoingBytes"=2147483647 WHERE "___outgoingBytes" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___requests_failed"=32767 WHERE "___requests_failed" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___requests_succeeded"=32767 WHERE "___requests_succeeded" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___requests_received"=32767 WHERE "___requests_received" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___notes_total"=2147483647 WHERE "___notes_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___notes_inc"=2147483647 WHERE "___notes_inc" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___notes_dec"=2147483647 WHERE "___notes_dec" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___notes_diffs_normal"=2147483647 WHERE "___notes_diffs_normal" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___notes_diffs_reply"=2147483647 WHERE "___notes_diffs_reply" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___notes_diffs_renote"=2147483647 WHERE "___notes_diffs_renote" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___users_total"=2147483647 WHERE "___users_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___users_inc"=32767 WHERE "___users_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___users_dec"=32767 WHERE "___users_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___following_total"=2147483647 WHERE "___following_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___following_inc"=32767 WHERE "___following_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___following_dec"=32767 WHERE "___following_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___followers_total"=2147483647 WHERE "___followers_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___followers_inc"=32767 WHERE "___followers_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___followers_dec"=32767 WHERE "___followers_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___drive_totalFiles"=2147483647 WHERE "___drive_totalFiles" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___drive_incFiles"=2147483647 WHERE "___drive_incFiles" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___drive_decFiles"=2147483647 WHERE "___drive_decFiles" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___drive_incUsage"=2147483647 WHERE "___drive_incUsage" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__instance" SET "___drive_decUsage"=2147483647 WHERE "___drive_decUsage" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___requests_failed"=32767 WHERE "___requests_failed" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___requests_succeeded"=32767 WHERE "___requests_succeeded" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___requests_received"=32767 WHERE "___requests_received" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___notes_total"=2147483647 WHERE "___notes_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___notes_inc"=2147483647 WHERE "___notes_inc" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___notes_dec"=2147483647 WHERE "___notes_dec" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___notes_diffs_normal"=2147483647 WHERE "___notes_diffs_normal" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___notes_diffs_reply"=2147483647 WHERE "___notes_diffs_reply" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___notes_diffs_renote"=2147483647 WHERE "___notes_diffs_renote" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___users_total"=2147483647 WHERE "___users_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___users_inc"=32767 WHERE "___users_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___users_dec"=32767 WHERE "___users_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___following_total"=2147483647 WHERE "___following_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___following_inc"=32767 WHERE "___following_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___following_dec"=32767 WHERE "___following_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___followers_total"=2147483647 WHERE "___followers_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___followers_inc"=32767 WHERE "___followers_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___followers_dec"=32767 WHERE "___followers_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___drive_totalFiles"=2147483647 WHERE "___drive_totalFiles" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___drive_incFiles"=2147483647 WHERE "___drive_incFiles" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___drive_decFiles"=2147483647 WHERE "___drive_decFiles" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___drive_incUsage"=2147483647 WHERE "___drive_incUsage" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__instance" SET "___drive_decUsage"=2147483647 WHERE "___drive_decUsage" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_notes" SET "___total"=2147483647 WHERE "___total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_notes" SET "___inc"=32767 WHERE "___inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_notes" SET "___dec"=32767 WHERE "___dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_notes" SET "___diffs_normal"=32767 WHERE "___diffs_normal" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_notes" SET "___diffs_reply"=32767 WHERE "___diffs_reply" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_notes" SET "___diffs_renote"=32767 WHERE "___diffs_renote" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_notes" SET "___total"=2147483647 WHERE "___total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_notes" SET "___inc"=32767 WHERE "___inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_notes" SET "___dec"=32767 WHERE "___dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_notes" SET "___diffs_normal"=32767 WHERE "___diffs_normal" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_notes" SET "___diffs_reply"=32767 WHERE "___diffs_reply" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_notes" SET "___diffs_renote"=32767 WHERE "___diffs_renote" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__drive" SET "___local_incCount"=2147483647 WHERE "___local_incCount" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__drive" SET "___local_incSize"=2147483647 WHERE "___local_incSize" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__drive" SET "___local_decCount"=2147483647 WHERE "___local_decCount" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__drive" SET "___local_decSize"=2147483647 WHERE "___local_decSize" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__drive" SET "___remote_incCount"=2147483647 WHERE "___remote_incCount" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__drive" SET "___remote_incSize"=2147483647 WHERE "___remote_incSize" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__drive" SET "___remote_decCount"=2147483647 WHERE "___remote_decCount" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__drive" SET "___remote_decSize"=2147483647 WHERE "___remote_decSize" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__drive" SET "___local_incCount"=2147483647 WHERE "___local_incCount" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__drive" SET "___local_incSize"=2147483647 WHERE "___local_incSize" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__drive" SET "___local_decCount"=2147483647 WHERE "___local_decCount" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__drive" SET "___local_decSize"=2147483647 WHERE "___local_decSize" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__drive" SET "___remote_incCount"=2147483647 WHERE "___remote_incCount" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__drive" SET "___remote_incSize"=2147483647 WHERE "___remote_incSize" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__drive" SET "___remote_decCount"=2147483647 WHERE "___remote_decCount" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__drive" SET "___remote_decSize"=2147483647 WHERE "___remote_decSize" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_reaction" SET "___local_count"=32767 WHERE "___local_count" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_reaction" SET "___remote_count"=32767 WHERE "___remote_count" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_reaction" SET "___local_count"=32767 WHERE "___local_count" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_reaction" SET "___remote_count"=32767 WHERE "___remote_count" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_following" SET "___local_followings_total"=2147483647 WHERE "___local_followings_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_following" SET "___local_followings_inc"=32767 WHERE "___local_followings_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_following" SET "___local_followings_dec"=32767 WHERE "___local_followings_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_following" SET "___local_followers_total"=2147483647 WHERE "___local_followers_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_following" SET "___local_followers_inc"=32767 WHERE "___local_followers_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_following" SET "___local_followers_dec"=32767 WHERE "___local_followers_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_following" SET "___remote_followings_total"=2147483647 WHERE "___remote_followings_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_following" SET "___remote_followings_inc"=32767 WHERE "___remote_followings_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_following" SET "___remote_followings_dec"=32767 WHERE "___remote_followings_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_following" SET "___remote_followers_total"=2147483647 WHERE "___remote_followers_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_following" SET "___remote_followers_inc"=32767 WHERE "___remote_followers_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart__per_user_following" SET "___remote_followers_dec"=32767 WHERE "___remote_followers_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_following" SET "___local_followings_total"=2147483647 WHERE "___local_followings_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_following" SET "___local_followings_inc"=32767 WHERE "___local_followings_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_following" SET "___local_followings_dec"=32767 WHERE "___local_followings_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_following" SET "___local_followers_total"=2147483647 WHERE "___local_followers_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_following" SET "___local_followers_inc"=32767 WHERE "___local_followers_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_following" SET "___local_followers_dec"=32767 WHERE "___local_followers_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_following" SET "___remote_followings_total"=2147483647 WHERE "___remote_followings_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_following" SET "___remote_followings_inc"=32767 WHERE "___remote_followings_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_following" SET "___remote_followings_dec"=32767 WHERE "___remote_followings_dec" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_following" SET "___remote_followers_total"=2147483647 WHERE "___remote_followers_total" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_following" SET "___remote_followers_inc"=32767 WHERE "___remote_followers_inc" > 32767`, + ); + await queryRunner.query( + `UPDATE "__chart_day__per_user_following" SET "___remote_followers_dec"=32767 WHERE "___remote_followers_dec" > 32767`, + ); + await queryRunner.query(`TRUNCATE TABLE "__chart__per_user_drive"`); + await queryRunner.query(`TRUNCATE TABLE "__chart_day__per_user_drive"`); + + await queryRunner.query( + `ALTER TABLE "__chart__federation" ALTER COLUMN "___instance_total" TYPE integer USING "___instance_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ALTER COLUMN "___instance_inc" TYPE smallint USING "___instance_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ALTER COLUMN "___instance_dec" TYPE smallint USING "___instance_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ALTER COLUMN "___instance_total" TYPE integer USING "___instance_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ALTER COLUMN "___instance_inc" TYPE smallint USING "___instance_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ALTER COLUMN "___instance_dec" TYPE smallint USING "___instance_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_total" TYPE integer USING "___local_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_inc" TYPE integer USING "___local_inc"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_dec" TYPE integer USING "___local_dec"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_diffs_normal" TYPE integer USING "___local_diffs_normal"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_diffs_reply" TYPE integer USING "___local_diffs_reply"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_diffs_renote" TYPE integer USING "___local_diffs_renote"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_total" TYPE integer USING "___remote_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_inc" TYPE integer USING "___remote_inc"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_dec" TYPE integer USING "___remote_dec"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_diffs_normal" TYPE integer USING "___remote_diffs_normal"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_diffs_reply" TYPE integer USING "___remote_diffs_reply"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_diffs_renote" TYPE integer USING "___remote_diffs_renote"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_total" TYPE integer USING "___local_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_inc" TYPE integer USING "___local_inc"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_dec" TYPE integer USING "___local_dec"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_diffs_normal" TYPE integer USING "___local_diffs_normal"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_diffs_reply" TYPE integer USING "___local_diffs_reply"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_diffs_renote" TYPE integer USING "___local_diffs_renote"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_total" TYPE integer USING "___remote_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_inc" TYPE integer USING "___remote_inc"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_dec" TYPE integer USING "___remote_dec"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_diffs_normal" TYPE integer USING "___remote_diffs_normal"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_diffs_reply" TYPE integer USING "___remote_diffs_reply"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_diffs_renote" TYPE integer USING "___remote_diffs_renote"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___local_total" TYPE integer USING "___local_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___local_inc" TYPE smallint USING "___local_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___local_dec" TYPE smallint USING "___local_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___remote_total" TYPE integer USING "___remote_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___remote_inc" TYPE smallint USING "___remote_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___remote_dec" TYPE smallint USING "___remote_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___local_total" TYPE integer USING "___local_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___local_inc" TYPE smallint USING "___local_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___local_dec" TYPE smallint USING "___local_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___remote_total" TYPE integer USING "___remote_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___remote_inc" TYPE smallint USING "___remote_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___remote_dec" TYPE smallint USING "___remote_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___incomingRequests" TYPE integer USING "___incomingRequests"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___outgoingRequests" TYPE integer USING "___outgoingRequests"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___totalTime" TYPE integer USING "___totalTime"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___incomingBytes" TYPE integer USING "___incomingBytes"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___outgoingBytes" TYPE integer USING "___outgoingBytes"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___incomingRequests" TYPE integer USING "___incomingRequests"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___outgoingRequests" TYPE integer USING "___outgoingRequests"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___totalTime" TYPE integer USING "___totalTime"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___incomingBytes" TYPE integer USING "___incomingBytes"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___outgoingBytes" TYPE integer USING "___outgoingBytes"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___requests_failed" TYPE smallint USING "___requests_failed"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___requests_succeeded" TYPE smallint USING "___requests_succeeded"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___requests_received" TYPE smallint USING "___requests_received"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_total" TYPE integer USING "___notes_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_inc" TYPE integer USING "___notes_inc"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_dec" TYPE integer USING "___notes_dec"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_diffs_normal" TYPE integer USING "___notes_diffs_normal"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_diffs_reply" TYPE integer USING "___notes_diffs_reply"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_diffs_renote" TYPE integer USING "___notes_diffs_renote"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___users_total" TYPE integer USING "___users_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___users_inc" TYPE smallint USING "___users_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___users_dec" TYPE smallint USING "___users_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___following_total" TYPE integer USING "___following_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___following_inc" TYPE smallint USING "___following_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___following_dec" TYPE smallint USING "___following_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___followers_total" TYPE integer USING "___followers_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___followers_inc" TYPE smallint USING "___followers_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___followers_dec" TYPE smallint USING "___followers_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_totalFiles" TYPE integer USING "___drive_totalFiles"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_incFiles" TYPE integer USING "___drive_incFiles"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_decFiles" TYPE integer USING "___drive_decFiles"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_incUsage" TYPE integer USING "___drive_incUsage"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_decUsage" TYPE integer USING "___drive_decUsage"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___requests_failed" TYPE smallint USING "___requests_failed"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___requests_succeeded" TYPE smallint USING "___requests_succeeded"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___requests_received" TYPE smallint USING "___requests_received"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_total" TYPE integer USING "___notes_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_inc" TYPE integer USING "___notes_inc"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_dec" TYPE integer USING "___notes_dec"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_diffs_normal" TYPE integer USING "___notes_diffs_normal"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_diffs_reply" TYPE integer USING "___notes_diffs_reply"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_diffs_renote" TYPE integer USING "___notes_diffs_renote"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___users_total" TYPE integer USING "___users_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___users_inc" TYPE smallint USING "___users_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___users_dec" TYPE smallint USING "___users_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___following_total" TYPE integer USING "___following_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___following_inc" TYPE smallint USING "___following_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___following_dec" TYPE smallint USING "___following_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___followers_total" TYPE integer USING "___followers_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___followers_inc" TYPE smallint USING "___followers_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___followers_dec" TYPE smallint USING "___followers_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_totalFiles" TYPE integer USING "___drive_totalFiles"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_incFiles" TYPE integer USING "___drive_incFiles"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_decFiles" TYPE integer USING "___drive_decFiles"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_incUsage" TYPE integer USING "___drive_incUsage"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_decUsage" TYPE integer USING "___drive_decUsage"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___total" TYPE integer USING "___total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___inc" TYPE smallint USING "___inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___dec" TYPE smallint USING "___dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___diffs_normal" TYPE smallint USING "___diffs_normal"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___diffs_reply" TYPE smallint USING "___diffs_reply"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___diffs_renote" TYPE smallint USING "___diffs_renote"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___total" TYPE integer USING "___total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___inc" TYPE smallint USING "___inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___dec" TYPE smallint USING "___dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___diffs_normal" TYPE smallint USING "___diffs_normal"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___diffs_reply" TYPE smallint USING "___diffs_reply"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___diffs_renote" TYPE smallint USING "___diffs_renote"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___local_incCount" TYPE integer USING "___local_incCount"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___local_incSize" TYPE integer USING "___local_incSize"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___local_decCount" TYPE integer USING "___local_decCount"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___local_decSize" TYPE integer USING "___local_decSize"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___remote_incCount" TYPE integer USING "___remote_incCount"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___remote_incSize" TYPE integer USING "___remote_incSize"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___remote_decCount" TYPE integer USING "___remote_decCount"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___remote_decSize" TYPE integer USING "___remote_decSize"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___local_incCount" TYPE integer USING "___local_incCount"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___local_incSize" TYPE integer USING "___local_incSize"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___local_decCount" TYPE integer USING "___local_decCount"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___local_decSize" TYPE integer USING "___local_decSize"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___remote_incCount" TYPE integer USING "___remote_incCount"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___remote_incSize" TYPE integer USING "___remote_incSize"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___remote_decCount" TYPE integer USING "___remote_decCount"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___remote_decSize" TYPE integer USING "___remote_decSize"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_reaction" ALTER COLUMN "___local_count" TYPE smallint USING "___local_count"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_reaction" ALTER COLUMN "___remote_count" TYPE smallint USING "___remote_count"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_reaction" ALTER COLUMN "___local_count" TYPE smallint USING "___local_count"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_reaction" ALTER COLUMN "___remote_count" TYPE smallint USING "___remote_count"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followings_total" TYPE integer USING "___local_followings_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followings_inc" TYPE smallint USING "___local_followings_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followings_dec" TYPE smallint USING "___local_followings_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followers_total" TYPE integer USING "___local_followers_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followers_inc" TYPE smallint USING "___local_followers_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followers_dec" TYPE smallint USING "___local_followers_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followings_total" TYPE integer USING "___remote_followings_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followings_inc" TYPE smallint USING "___remote_followings_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followings_dec" TYPE smallint USING "___remote_followings_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followers_total" TYPE integer USING "___remote_followers_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followers_inc" TYPE smallint USING "___remote_followers_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followers_dec" TYPE smallint USING "___remote_followers_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followings_total" TYPE integer USING "___local_followings_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followings_inc" TYPE smallint USING "___local_followings_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followings_dec" TYPE smallint USING "___local_followings_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followers_total" TYPE integer USING "___local_followers_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followers_inc" TYPE smallint USING "___local_followers_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followers_dec" TYPE smallint USING "___local_followers_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followings_total" TYPE integer USING "___remote_followings_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followings_inc" TYPE smallint USING "___remote_followings_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followings_dec" TYPE smallint USING "___remote_followings_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followers_total" TYPE integer USING "___remote_followers_total"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followers_inc" TYPE smallint USING "___remote_followers_inc"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followers_dec" TYPE smallint USING "___remote_followers_dec"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___totalCount" TYPE integer USING "___totalCount"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___totalSize" TYPE integer USING "___totalSize"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___incCount" TYPE smallint USING "___incCount"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___incSize" TYPE integer USING "___incSize"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___decCount" TYPE smallint USING "___decCount"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___decSize" TYPE integer USING "___decSize"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___totalCount" TYPE integer USING "___totalCount"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___totalSize" TYPE integer USING "___totalSize"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___incCount" TYPE smallint USING "___incCount"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___incSize" TYPE integer USING "___incSize"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___decCount" TYPE smallint USING "___decCount"::smallint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___decSize" TYPE integer USING "___decSize"::integer`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__federation" ALTER COLUMN "___instance_total" TYPE bigint USING "___instance_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ALTER COLUMN "___instance_inc" TYPE bigint USING "___instance_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ALTER COLUMN "___instance_dec" TYPE bigint USING "___instance_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ALTER COLUMN "___instance_total" TYPE bigint USING "___instance_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ALTER COLUMN "___instance_inc" TYPE bigint USING "___instance_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ALTER COLUMN "___instance_dec" TYPE bigint USING "___instance_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_total" TYPE bigint USING "___local_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_inc" TYPE bigint USING "___local_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_dec" TYPE bigint USING "___local_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_diffs_normal" TYPE bigint USING "___local_diffs_normal"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_diffs_reply" TYPE bigint USING "___local_diffs_reply"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___local_diffs_renote" TYPE bigint USING "___local_diffs_renote"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_total" TYPE bigint USING "___remote_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_inc" TYPE bigint USING "___remote_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_dec" TYPE bigint USING "___remote_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_diffs_normal" TYPE bigint USING "___remote_diffs_normal"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_diffs_reply" TYPE bigint USING "___remote_diffs_reply"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ALTER COLUMN "___remote_diffs_renote" TYPE bigint USING "___remote_diffs_renote"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_total" TYPE bigint USING "___local_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_inc" TYPE bigint USING "___local_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_dec" TYPE bigint USING "___local_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_diffs_normal" TYPE bigint USING "___local_diffs_normal"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_diffs_reply" TYPE bigint USING "___local_diffs_reply"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___local_diffs_renote" TYPE bigint USING "___local_diffs_renote"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_total" TYPE bigint USING "___remote_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_inc" TYPE bigint USING "___remote_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_dec" TYPE bigint USING "___remote_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_diffs_normal" TYPE bigint USING "___remote_diffs_normal"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_diffs_reply" TYPE bigint USING "___remote_diffs_reply"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ALTER COLUMN "___remote_diffs_renote" TYPE bigint USING "___remote_diffs_renote"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___local_total" TYPE bigint USING "___local_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___local_inc" TYPE bigint USING "___local_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___local_dec" TYPE bigint USING "___local_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___remote_total" TYPE bigint USING "___remote_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___remote_inc" TYPE bigint USING "___remote_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__users" ALTER COLUMN "___remote_dec" TYPE bigint USING "___remote_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___local_total" TYPE bigint USING "___local_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___local_inc" TYPE bigint USING "___local_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___local_dec" TYPE bigint USING "___local_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___remote_total" TYPE bigint USING "___remote_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___remote_inc" TYPE bigint USING "___remote_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__users" ALTER COLUMN "___remote_dec" TYPE bigint USING "___remote_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___incomingRequests" TYPE bigint USING "___incomingRequests"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___outgoingRequests" TYPE bigint USING "___outgoingRequests"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___totalTime" TYPE bigint USING "___totalTime"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___incomingBytes" TYPE bigint USING "___incomingBytes"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__network" ALTER COLUMN "___outgoingBytes" TYPE bigint USING "___outgoingBytes"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___incomingRequests" TYPE bigint USING "___incomingRequests"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___outgoingRequests" TYPE bigint USING "___outgoingRequests"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___totalTime" TYPE bigint USING "___totalTime"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___incomingBytes" TYPE bigint USING "___incomingBytes"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__network" ALTER COLUMN "___outgoingBytes" TYPE bigint USING "___outgoingBytes"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___requests_failed" TYPE bigint USING "___requests_failed"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___requests_succeeded" TYPE bigint USING "___requests_succeeded"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___requests_received" TYPE bigint USING "___requests_received"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_total" TYPE bigint USING "___notes_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_inc" TYPE bigint USING "___notes_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_dec" TYPE bigint USING "___notes_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_diffs_normal" TYPE bigint USING "___notes_diffs_normal"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_diffs_reply" TYPE bigint USING "___notes_diffs_reply"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___notes_diffs_renote" TYPE bigint USING "___notes_diffs_renote"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___users_total" TYPE bigint USING "___users_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___users_inc" TYPE bigint USING "___users_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___users_dec" TYPE bigint USING "___users_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___following_total" TYPE bigint USING "___following_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___following_inc" TYPE bigint USING "___following_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___following_dec" TYPE bigint USING "___following_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___followers_total" TYPE bigint USING "___followers_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___followers_inc" TYPE bigint USING "___followers_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___followers_dec" TYPE bigint USING "___followers_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_totalFiles" TYPE bigint USING "___drive_totalFiles"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_incFiles" TYPE bigint USING "___drive_incFiles"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_decFiles" TYPE bigint USING "___drive_decFiles"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_incUsage" TYPE bigint USING "___drive_incUsage"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ALTER COLUMN "___drive_decUsage" TYPE bigint USING "___drive_decUsage"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___requests_failed" TYPE bigint USING "___requests_failed"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___requests_succeeded" TYPE bigint USING "___requests_succeeded"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___requests_received" TYPE bigint USING "___requests_received"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_total" TYPE bigint USING "___notes_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_inc" TYPE bigint USING "___notes_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_dec" TYPE bigint USING "___notes_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_diffs_normal" TYPE bigint USING "___notes_diffs_normal"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_diffs_reply" TYPE bigint USING "___notes_diffs_reply"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___notes_diffs_renote" TYPE bigint USING "___notes_diffs_renote"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___users_total" TYPE bigint USING "___users_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___users_inc" TYPE bigint USING "___users_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___users_dec" TYPE bigint USING "___users_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___following_total" TYPE bigint USING "___following_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___following_inc" TYPE bigint USING "___following_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___following_dec" TYPE bigint USING "___following_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___followers_total" TYPE bigint USING "___followers_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___followers_inc" TYPE bigint USING "___followers_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___followers_dec" TYPE bigint USING "___followers_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_totalFiles" TYPE bigint USING "___drive_totalFiles"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_incFiles" TYPE bigint USING "___drive_incFiles"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_decFiles" TYPE bigint USING "___drive_decFiles"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_incUsage" TYPE bigint USING "___drive_incUsage"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ALTER COLUMN "___drive_decUsage" TYPE bigint USING "___drive_decUsage"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___total" TYPE bigint USING "___total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___inc" TYPE bigint USING "___inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___dec" TYPE bigint USING "___dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___diffs_normal" TYPE bigint USING "___diffs_normal"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___diffs_reply" TYPE bigint USING "___diffs_reply"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ALTER COLUMN "___diffs_renote" TYPE bigint USING "___diffs_renote"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___total" TYPE bigint USING "___total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___inc" TYPE bigint USING "___inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___dec" TYPE bigint USING "___dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___diffs_normal" TYPE bigint USING "___diffs_normal"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___diffs_reply" TYPE bigint USING "___diffs_reply"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ALTER COLUMN "___diffs_renote" TYPE bigint USING "___diffs_renote"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___local_incCount" TYPE bigint USING "___local_incCount"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___local_incSize" TYPE bigint USING "___local_incSize"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___local_decCount" TYPE bigint USING "___local_decCount"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___local_decSize" TYPE bigint USING "___local_decSize"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___remote_incCount" TYPE bigint USING "___remote_incCount"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___remote_incSize" TYPE bigint USING "___remote_incSize"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___remote_decCount" TYPE bigint USING "___remote_decCount"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__drive" ALTER COLUMN "___remote_decSize" TYPE bigint USING "___remote_decSize"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___local_incCount" TYPE bigint USING "___local_incCount"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___local_incSize" TYPE bigint USING "___local_incSize"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___local_decCount" TYPE bigint USING "___local_decCount"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___local_decSize" TYPE bigint USING "___local_decSize"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___remote_incCount" TYPE bigint USING "___remote_incCount"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___remote_incSize" TYPE bigint USING "___remote_incSize"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___remote_decCount" TYPE bigint USING "___remote_decCount"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__drive" ALTER COLUMN "___remote_decSize" TYPE bigint USING "___remote_decSize"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_reaction" ALTER COLUMN "___local_count" TYPE bigint USING "___local_count"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_reaction" ALTER COLUMN "___remote_count" TYPE bigint USING "___remote_count"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_reaction" ALTER COLUMN "___local_count" TYPE bigint USING "___local_count"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_reaction" ALTER COLUMN "___remote_count" TYPE bigint USING "___remote_count"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followings_total" TYPE bigint USING "___local_followings_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followings_inc" TYPE bigint USING "___local_followings_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followings_dec" TYPE bigint USING "___local_followings_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followers_total" TYPE bigint USING "___local_followers_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followers_inc" TYPE bigint USING "___local_followers_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___local_followers_dec" TYPE bigint USING "___local_followers_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followings_total" TYPE bigint USING "___remote_followings_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followings_inc" TYPE bigint USING "___remote_followings_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followings_dec" TYPE bigint USING "___remote_followings_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followers_total" TYPE bigint USING "___remote_followers_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followers_inc" TYPE bigint USING "___remote_followers_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_following" ALTER COLUMN "___remote_followers_dec" TYPE bigint USING "___remote_followers_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followings_total" TYPE bigint USING "___local_followings_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followings_inc" TYPE bigint USING "___local_followings_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followings_dec" TYPE bigint USING "___local_followings_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followers_total" TYPE bigint USING "___local_followers_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followers_inc" TYPE bigint USING "___local_followers_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___local_followers_dec" TYPE bigint USING "___local_followers_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followings_total" TYPE bigint USING "___remote_followings_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followings_inc" TYPE bigint USING "___remote_followings_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followings_dec" TYPE bigint USING "___remote_followings_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followers_total" TYPE bigint USING "___remote_followers_total"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followers_inc" TYPE bigint USING "___remote_followers_inc"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_following" ALTER COLUMN "___remote_followers_dec" TYPE bigint USING "___remote_followers_dec"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___totalCount" TYPE bigint USING "___totalCount"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___totalSize" TYPE bigint USING "___totalSize"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___incCount" TYPE bigint USING "___incCount"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___incSize" TYPE bigint USING "___incSize"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___decCount" TYPE bigint USING "___decCount"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_drive" ALTER COLUMN "___decSize" TYPE bigint USING "___decSize"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___totalCount" TYPE bigint USING "___totalCount"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___totalSize" TYPE bigint USING "___totalSize"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___incCount" TYPE bigint USING "___incCount"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___incSize" TYPE bigint USING "___incSize"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___decCount" TYPE bigint USING "___decCount"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_drive" ALTER COLUMN "___decSize" TYPE bigint USING "___decSize"::bigint`, + ); + } +} diff --git a/packages/backend/src/migration/1644059847460-chart-v8.ts b/packages/backend/src/migration/1644059847460-chart-v8.ts new file mode 100644 index 0000000..5e9d185 --- /dev/null +++ b/packages/backend/src/migration/1644059847460-chart-v8.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartV81644059847460 implements MigrationInterface { + name = "chartV81644059847460"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "__chart__active_users" SET "___local_users"=2147483647 WHERE "___local_users" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__active_users" SET "___remote_users"=2147483647 WHERE "___remote_users" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__active_users" SET "___local_users"=2147483647 WHERE "___local_users" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__active_users" SET "___remote_users"=2147483647 WHERE "___remote_users" > 2147483647`, + ); + + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ALTER COLUMN "___local_users" TYPE integer USING "___local_users"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ALTER COLUMN "___remote_users" TYPE integer USING "___remote_users"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___local_users" TYPE integer USING "___local_users"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___remote_users" TYPE integer USING "___remote_users"::integer`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ALTER COLUMN "___local_users" TYPE bigint USING "___local_users"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ALTER COLUMN "___remote_users" TYPE bigint USING "___remote_users"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___local_users" TYPE bigint USING "___local_users"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ALTER COLUMN "___remote_users" TYPE bigint USING "___remote_users"::bigint`, + ); + } +} diff --git a/packages/backend/src/migration/1644060125705-chart-v9.ts b/packages/backend/src/migration/1644060125705-chart-v9.ts new file mode 100644 index 0000000..b8bc3d4 --- /dev/null +++ b/packages/backend/src/migration/1644060125705-chart-v9.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartV91644060125705 implements MigrationInterface { + name = "chartV91644060125705"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "__chart__hashtag" SET "___local_users"=2147483647 WHERE "___local_users" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart__hashtag" SET "___remote_users"=2147483647 WHERE "___remote_users" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__hashtag" SET "___local_users"=2147483647 WHERE "___local_users" > 2147483647`, + ); + await queryRunner.query( + `UPDATE "__chart_day__hashtag" SET "___remote_users"=2147483647 WHERE "___remote_users" > 2147483647`, + ); + + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ALTER COLUMN "___local_users" TYPE integer USING "___local_users"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ALTER COLUMN "___remote_users" TYPE integer USING "___remote_users"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__hashtag" ALTER COLUMN "___local_users" TYPE integer USING "___local_users"::integer`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__hashtag" ALTER COLUMN "___remote_users" TYPE integer USING "___remote_users"::integer`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ALTER COLUMN "___local_users" TYPE bigint USING "___local_users"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__hashtag" ALTER COLUMN "___remote_users" TYPE bigint USING "___remote_users"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__hashtag" ALTER COLUMN "___local_users" TYPE bigint USING "___local_users"::bigint`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__hashtag" ALTER COLUMN "___remote_users" TYPE bigint USING "___remote_users"::bigint`, + ); + } +} diff --git a/packages/backend/src/migration/1644073149413-chart-v10.ts b/packages/backend/src/migration/1644073149413-chart-v10.ts new file mode 100644 index 0000000..9bd0a10 --- /dev/null +++ b/packages/backend/src/migration/1644073149413-chart-v10.ts @@ -0,0 +1,78 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartV101644073149413 implements MigrationInterface { + name = "chartV101644073149413"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "__chart__ap_request" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "___deliverFailed" integer NOT NULL DEFAULT '0', "___deliverSucceeded" integer NOT NULL DEFAULT '0', "___inboxReceived" integer NOT NULL DEFAULT '0', CONSTRAINT "UQ_e56f4beac5746d44bc3e19c80d0" UNIQUE ("date"), CONSTRAINT "PK_56a25cd447c7ee08876b3baf8d8" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_e56f4beac5746d44bc3e19c80d" ON "__chart__ap_request" ("date") `, + ); + await queryRunner.query( + `CREATE TABLE "__chart_day__ap_request" ("id" SERIAL NOT NULL, "date" integer NOT NULL, "___deliverFailed" integer NOT NULL DEFAULT '0', "___deliverSucceeded" integer NOT NULL DEFAULT '0', "___inboxReceived" integer NOT NULL DEFAULT '0', CONSTRAINT "UQ_a848f66d6cec11980a5dd595822" UNIQUE ("date"), CONSTRAINT "PK_9318b49daee320194e23f712e69" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_a848f66d6cec11980a5dd59582" ON "__chart_day__ap_request" ("date") `, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "unique_temp___deliveredInstances" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "___deliveredInstances" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "unique_temp___inboxInstances" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "___inboxInstances" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ADD "unique_temp___deliveredInstances" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ADD "___deliveredInstances" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ADD "unique_temp___inboxInstances" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ADD "___inboxInstances" smallint NOT NULL DEFAULT '0'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" DROP COLUMN "___inboxInstances"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" DROP COLUMN "unique_temp___inboxInstances"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" DROP COLUMN "___deliveredInstances"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" DROP COLUMN "unique_temp___deliveredInstances"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "___inboxInstances"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "unique_temp___inboxInstances"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "___deliveredInstances"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "unique_temp___deliveredInstances"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_a848f66d6cec11980a5dd59582"`, + ); + await queryRunner.query(`DROP TABLE "__chart_day__ap_request"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_e56f4beac5746d44bc3e19c80d"`, + ); + await queryRunner.query(`DROP TABLE "__chart__ap_request"`); + } +} diff --git a/packages/backend/src/migration/1644095659741-chart-v11.ts b/packages/backend/src/migration/1644095659741-chart-v11.ts new file mode 100644 index 0000000..1e7b560 --- /dev/null +++ b/packages/backend/src/migration/1644095659741-chart-v11.ts @@ -0,0 +1,250 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartV111644095659741 implements MigrationInterface { + name = "chartV111644095659741"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___local_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "unique_temp___local_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "unique_temp___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___local_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "unique_temp___local_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "unique_temp___remote_users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "unique_temp___users" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___users" integer NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "unique_temp___notedUsers" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___notedUsers" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "unique_temp___registeredWithinWeek" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___registeredWithinWeek" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "unique_temp___registeredWithinMonth" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___registeredWithinMonth" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "unique_temp___registeredWithinYear" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___registeredWithinYear" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "unique_temp___registeredOutsideWeek" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___registeredOutsideWeek" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "unique_temp___registeredOutsideMonth" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___registeredOutsideMonth" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "unique_temp___registeredOutsideYear" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___registeredOutsideYear" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "unique_temp___users" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___users" integer NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "unique_temp___notedUsers" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___notedUsers" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "unique_temp___registeredWithinWeek" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___registeredWithinWeek" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "unique_temp___registeredWithinMonth" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___registeredWithinMonth" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "unique_temp___registeredWithinYear" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___registeredWithinYear" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "unique_temp___registeredOutsideWeek" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___registeredOutsideWeek" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "unique_temp___registeredOutsideMonth" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___registeredOutsideMonth" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "unique_temp___registeredOutsideYear" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___registeredOutsideYear" smallint NOT NULL DEFAULT '0'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___registeredOutsideYear"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "unique_temp___registeredOutsideYear"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___registeredOutsideMonth"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "unique_temp___registeredOutsideMonth"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___registeredOutsideWeek"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "unique_temp___registeredOutsideWeek"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___registeredWithinYear"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "unique_temp___registeredWithinYear"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___registeredWithinMonth"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "unique_temp___registeredWithinMonth"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___registeredWithinWeek"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "unique_temp___registeredWithinWeek"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___notedUsers"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "unique_temp___notedUsers"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "unique_temp___users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___registeredOutsideYear"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "unique_temp___registeredOutsideYear"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___registeredOutsideMonth"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "unique_temp___registeredOutsideMonth"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___registeredOutsideWeek"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "unique_temp___registeredOutsideWeek"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___registeredWithinYear"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "unique_temp___registeredWithinYear"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___registeredWithinMonth"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "unique_temp___registeredWithinMonth"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___registeredWithinWeek"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "unique_temp___registeredWithinWeek"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___notedUsers"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "unique_temp___notedUsers"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "unique_temp___users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "unique_temp___remote_users" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "unique_temp___local_users" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___remote_users" integer NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___local_users" integer NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "unique_temp___remote_users" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "unique_temp___local_users" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___remote_users" integer NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___local_users" integer NOT NULL DEFAULT '0'`, + ); + } +} diff --git a/packages/backend/src/migration/1644328606241-chart-v12.ts b/packages/backend/src/migration/1644328606241-chart-v12.ts new file mode 100644 index 0000000..d3d6439 --- /dev/null +++ b/packages/backend/src/migration/1644328606241-chart-v12.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartV121644328606241 implements MigrationInterface { + name = "chartV121644328606241"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__notes" ADD "___local_diffs_withFile" integer NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" ADD "___remote_diffs_withFile" integer NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ADD "___local_diffs_withFile" integer NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" ADD "___remote_diffs_withFile" integer NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" ADD "___notes_diffs_withFile" integer NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" ADD "___notes_diffs_withFile" integer NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" ADD "___diffs_withFile" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" ADD "___diffs_withFile" smallint NOT NULL DEFAULT '0'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart_day__per_user_notes" DROP COLUMN "___diffs_withFile"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__per_user_notes" DROP COLUMN "___diffs_withFile"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__instance" DROP COLUMN "___notes_diffs_withFile"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__instance" DROP COLUMN "___notes_diffs_withFile"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" DROP COLUMN "___remote_diffs_withFile"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__notes" DROP COLUMN "___local_diffs_withFile"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" DROP COLUMN "___remote_diffs_withFile"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__notes" DROP COLUMN "___local_diffs_withFile"`, + ); + } +} diff --git a/packages/backend/src/migration/1644331238153-chart-v13.ts b/packages/backend/src/migration/1644331238153-chart-v13.ts new file mode 100644 index 0000000..bbcab69 --- /dev/null +++ b/packages/backend/src/migration/1644331238153-chart-v13.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartV131644331238153 implements MigrationInterface { + name = "chartV131644331238153"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "unique_temp___stalled" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "___stalled" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ADD "unique_temp___stalled" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ADD "___stalled" smallint NOT NULL DEFAULT '0'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" DROP COLUMN "___stalled"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" DROP COLUMN "unique_temp___stalled"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "___stalled"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "unique_temp___stalled"`, + ); + } +} diff --git a/packages/backend/src/migration/1644344266289-chart-v14.ts b/packages/backend/src/migration/1644344266289-chart-v14.ts new file mode 100644 index 0000000..8bbc190 --- /dev/null +++ b/packages/backend/src/migration/1644344266289-chart-v14.ts @@ -0,0 +1,118 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartV141644344266289 implements MigrationInterface { + name = "chartV141644344266289"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "unique_temp___users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "unique_temp___notedUsers"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___notedUsers"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "unique_temp___users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___users"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "unique_temp___notedUsers"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___notedUsers"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___readWrite" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "unique_temp___read" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___read" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "unique_temp___write" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___write" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___readWrite" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "unique_temp___read" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___read" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "unique_temp___write" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___write" smallint NOT NULL DEFAULT '0'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___write"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "unique_temp___write"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___read"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "unique_temp___read"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" DROP COLUMN "___readWrite"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___write"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "unique_temp___write"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___read"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "unique_temp___read"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" DROP COLUMN "___readWrite"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___notedUsers" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "unique_temp___notedUsers" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "___users" integer NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__active_users" ADD "unique_temp___users" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___notedUsers" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "unique_temp___notedUsers" character varying array NOT NULL DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "___users" integer NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__active_users" ADD "unique_temp___users" character varying array NOT NULL DEFAULT '{}'`, + ); + } +} diff --git a/packages/backend/src/migration/1644395759931-instance-theme-color.ts b/packages/backend/src/migration/1644395759931-instance-theme-color.ts new file mode 100644 index 0000000..9b27263 --- /dev/null +++ b/packages/backend/src/migration/1644395759931-instance-theme-color.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class instanceThemeColor1644395759931 implements MigrationInterface { + name = "instanceThemeColor1644395759931"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "themeColor" character varying(512)`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "themeColor"`); + } +} diff --git a/packages/backend/src/migration/1644481657998-chart-v15.ts b/packages/backend/src/migration/1644481657998-chart-v15.ts new file mode 100644 index 0000000..6cfd3c3 --- /dev/null +++ b/packages/backend/src/migration/1644481657998-chart-v15.ts @@ -0,0 +1,70 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartV151644481657998 implements MigrationInterface { + name = "chartV151644481657998"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "___instance_total"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "___instance_inc"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "___instance_dec"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" DROP COLUMN "___instance_total"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" DROP COLUMN "___instance_inc"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" DROP COLUMN "___instance_dec"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "___sub" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "___pub" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ADD "___sub" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ADD "___pub" smallint NOT NULL DEFAULT '0'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" DROP COLUMN "___pub"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" DROP COLUMN "___sub"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "___pub"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "___sub"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ADD "___instance_dec" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ADD "___instance_inc" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ADD "___instance_total" integer NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "___instance_dec" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "___instance_inc" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "___instance_total" integer NOT NULL DEFAULT '0'`, + ); + } +} diff --git a/packages/backend/src/migration/1644551208096-following-indexes.ts b/packages/backend/src/migration/1644551208096-following-indexes.ts new file mode 100644 index 0000000..7566860 --- /dev/null +++ b/packages/backend/src/migration/1644551208096-following-indexes.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class followingIndexes1644551208096 implements MigrationInterface { + name = "followingIndexes1644551208096"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX "IDX_4ccd2239268ebbd1b35e318754" ON "following" ("followerHost") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_fcdafee716dfe9c3b5fde90f30" ON "following" ("followeeHost") `, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_fcdafee716dfe9c3b5fde90f30"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_4ccd2239268ebbd1b35e318754"`, + ); + } +} diff --git a/packages/backend/src/migration/1645340161439-remove-max-note-text-length.ts b/packages/backend/src/migration/1645340161439-remove-max-note-text-length.ts new file mode 100644 index 0000000..9c7f9df --- /dev/null +++ b/packages/backend/src/migration/1645340161439-remove-max-note-text-length.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class removeMaxNoteTextLength1645340161439 implements MigrationInterface { + name = "removeMaxNoteTextLength1645340161439"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "maxNoteTextLength"`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "maxNoteTextLength" integer NOT NULL DEFAULT '500'`, + ); + } +} diff --git a/packages/backend/src/migration/1645599900873-federation-chart-pubsub.ts b/packages/backend/src/migration/1645599900873-federation-chart-pubsub.ts new file mode 100644 index 0000000..9a31c4f --- /dev/null +++ b/packages/backend/src/migration/1645599900873-federation-chart-pubsub.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class federationChartPubsub1645599900873 implements MigrationInterface { + name = "federationChartPubsub1645599900873"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "___pubsub" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ADD "___pubsub" smallint NOT NULL DEFAULT '0'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" DROP COLUMN "___pubsub"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "___pubsub"`, + ); + } +} diff --git a/packages/backend/src/migration/1646143552768-instance-default-theme.ts b/packages/backend/src/migration/1646143552768-instance-default-theme.ts new file mode 100644 index 0000000..89a002e --- /dev/null +++ b/packages/backend/src/migration/1646143552768-instance-default-theme.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class instanceDefaultTheme1646143552768 implements MigrationInterface { + name = "instanceDefaultTheme1646143552768"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "defaultLightTheme" character varying(8192)`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "defaultDarkTheme" character varying(8192)`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "defaultDarkTheme"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "defaultLightTheme"`, + ); + } +} diff --git a/packages/backend/src/migration/1646387162108-mute-expires-at.ts b/packages/backend/src/migration/1646387162108-mute-expires-at.ts new file mode 100644 index 0000000..c1cda2e --- /dev/null +++ b/packages/backend/src/migration/1646387162108-mute-expires-at.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class muteExpiresAt1646387162108 implements MigrationInterface { + name = "muteExpiresAt1646387162108"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "muting" ADD "expiresAt" TIMESTAMP WITH TIME ZONE`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_c1fd1c3dfb0627aa36c253fd14" ON "muting" ("expiresAt") `, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_c1fd1c3dfb0627aa36c253fd14"`, + ); + await queryRunner.query(`ALTER TABLE "muting" DROP COLUMN "expiresAt"`); + } +} diff --git a/packages/backend/src/migration/1646549089451-poll-ended-notification.ts b/packages/backend/src/migration/1646549089451-poll-ended-notification.ts new file mode 100644 index 0000000..1ef2f58 --- /dev/null +++ b/packages/backend/src/migration/1646549089451-poll-ended-notification.ts @@ -0,0 +1,30 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class pollEndedNotification1646549089451 implements MigrationInterface { + name = "pollEndedNotification1646549089451"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "public"."notification_type_enum" RENAME TO "notification_type_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`, + ); + await queryRunner.query( + `ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum" USING "type"::"text"::"public"."notification_type_enum"`, + ); + await queryRunner.query(`DROP TYPE "public"."notification_type_enum_old"`); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`, + ); + await queryRunner.query( + `ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum_old" USING "type"::"text"::"public"."notification_type_enum_old"`, + ); + await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`); + await queryRunner.query( + `ALTER TYPE "public"."notification_type_enum_old" RENAME TO "notification_type_enum"`, + ); + } +} diff --git a/packages/backend/src/migration/1646633030285-chart-federation-active.ts b/packages/backend/src/migration/1646633030285-chart-federation-active.ts new file mode 100644 index 0000000..64ea0d6 --- /dev/null +++ b/packages/backend/src/migration/1646633030285-chart-federation-active.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartFederationActive1646633030285 implements MigrationInterface { + name = "chartFederationActive1646633030285"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "___active" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ADD "___active" smallint NOT NULL DEFAULT '0'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" DROP COLUMN "___active"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "___active"`, + ); + } +} diff --git a/packages/backend/src/migration/1646655454495-remove-instance-drive-columns.ts b/packages/backend/src/migration/1646655454495-remove-instance-drive-columns.ts new file mode 100644 index 0000000..e6976a6 --- /dev/null +++ b/packages/backend/src/migration/1646655454495-remove-instance-drive-columns.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class removeInstanceDriveColumns1646655454495 implements MigrationInterface { + name = "removeInstanceDriveColumns1646655454495"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "driveUsage"`); + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "driveFiles"`); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "instance" ADD "driveFiles" integer NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "instance" ADD "driveUsage" bigint NOT NULL DEFAULT '0'`, + ); + } +} diff --git a/packages/backend/src/migration/1646732390560-chart-federation-active-sub-pub.ts b/packages/backend/src/migration/1646732390560-chart-federation-active-sub-pub.ts new file mode 100644 index 0000000..253da27 --- /dev/null +++ b/packages/backend/src/migration/1646732390560-chart-federation-active-sub-pub.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class chartFederationActiveSubPub1646732390560 implements MigrationInterface { + name = "chartFederationActiveSubPub1646732390560"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "___active"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" DROP COLUMN "___active"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "___subActive" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "___pubActive" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ADD "___subActive" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ADD "___pubActive" smallint NOT NULL DEFAULT '0'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" DROP COLUMN "___pubActive"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" DROP COLUMN "___subActive"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "___pubActive"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" DROP COLUMN "___subActive"`, + ); + await queryRunner.query( + `ALTER TABLE "__chart_day__federation" ADD "___active" smallint NOT NULL DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "__chart__federation" ADD "___active" smallint NOT NULL DEFAULT '0'`, + ); + } +} diff --git a/packages/backend/src/migration/1648548247382-webhook.ts b/packages/backend/src/migration/1648548247382-webhook.ts new file mode 100644 index 0000000..1a5b9c7 --- /dev/null +++ b/packages/backend/src/migration/1648548247382-webhook.ts @@ -0,0 +1,38 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class webhook1648548247382 implements MigrationInterface { + name = "webhook1648548247382"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "webhook" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "name" character varying(128) NOT NULL, "on" character varying(128) array NOT NULL DEFAULT '{}', "url" character varying(1024) NOT NULL, "secret" character varying(1024) NOT NULL, "active" boolean NOT NULL DEFAULT true, CONSTRAINT "PK_e6765510c2d078db49632b59020" PRIMARY KEY ("id")); COMMENT ON COLUMN "webhook"."createdAt" IS 'The created date of the Antenna.'; COMMENT ON COLUMN "webhook"."userId" IS 'The owner ID.'; COMMENT ON COLUMN "webhook"."name" IS 'The name of the Antenna.'`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f272c8c8805969e6a6449c77b3" ON "webhook" ("userId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_8063a0586ed1dfbe86e982d961" ON "webhook" ("on") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_5a056076f76b2efe08216ba655" ON "webhook" ("active") `, + ); + await queryRunner.query( + `ALTER TABLE "webhook" ADD CONSTRAINT "FK_f272c8c8805969e6a6449c77b3c" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "webhook" DROP CONSTRAINT "FK_f272c8c8805969e6a6449c77b3c"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_5a056076f76b2efe08216ba655"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_8063a0586ed1dfbe86e982d961"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_f272c8c8805969e6a6449c77b3"`, + ); + await queryRunner.query(`DROP TABLE "webhook"`); + } +} diff --git a/packages/backend/src/migration/1648816172177-webhook-2.ts b/packages/backend/src/migration/1648816172177-webhook-2.ts new file mode 100644 index 0000000..3cff6fb --- /dev/null +++ b/packages/backend/src/migration/1648816172177-webhook-2.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class webhook21648816172177 implements MigrationInterface { + name = "webhook21648816172177"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "webhook" ADD "latestSentAt" TIMESTAMP WITH TIME ZONE`, + ); + await queryRunner.query(`ALTER TABLE "webhook" ADD "latestStatus" integer`); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "webhook" DROP COLUMN "latestStatus"`); + await queryRunner.query(`ALTER TABLE "webhook" DROP COLUMN "latestSentAt"`); + } +} diff --git a/packages/backend/src/migration/1651224615271-foreign-key.ts b/packages/backend/src/migration/1651224615271-foreign-key.ts new file mode 100644 index 0000000..6bcef06 --- /dev/null +++ b/packages/backend/src/migration/1651224615271-foreign-key.ts @@ -0,0 +1,188 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class foreignKeyReports1651224615271 implements MigrationInterface { + name = "foreignKeyReports1651224615271"; + + async up(queryRunner: QueryRunner): Promise { + await Promise.all([ + queryRunner.query( + `ALTER INDEX "public"."IDX_seoignmeoprigmkpodgrjmkpormg" RENAME TO "IDX_c8cc87bd0f2f4487d17c651fbf"`, + ), + queryRunner.query( + `DROP INDEX "public"."IDX_note_on_channelId_and_id_desc"`, + ), + + // remove unnecessary default null, see also down + queryRunner.query( + `ALTER TABLE "user" ALTER COLUMN "followersUri" DROP DEFAULT`, + ), + queryRunner.query( + `ALTER TABLE "access_token" ALTER COLUMN "session" DROP DEFAULT`, + ), + queryRunner.query( + `ALTER TABLE "access_token" ALTER COLUMN "appId" DROP DEFAULT`, + ), + queryRunner.query( + `ALTER TABLE "access_token" ALTER COLUMN "name" DROP DEFAULT`, + ), + queryRunner.query( + `ALTER TABLE "access_token" ALTER COLUMN "description" DROP DEFAULT`, + ), + queryRunner.query( + `ALTER TABLE "access_token" ALTER COLUMN "iconUrl" DROP DEFAULT`, + ), + queryRunner.query( + `ALTER TABLE "instance" ALTER COLUMN "softwareName" DROP DEFAULT`, + ), + queryRunner.query( + `ALTER TABLE "instance" ALTER COLUMN "softwareVersion" DROP DEFAULT`, + ), + queryRunner.query( + `ALTER TABLE "instance" ALTER COLUMN "name" DROP DEFAULT`, + ), + queryRunner.query( + `ALTER TABLE "instance" ALTER COLUMN "description" DROP DEFAULT`, + ), + queryRunner.query( + `ALTER TABLE "instance" ALTER COLUMN "maintainerName" DROP DEFAULT`, + ), + queryRunner.query( + `ALTER TABLE "instance" ALTER COLUMN "maintainerEmail" DROP DEFAULT`, + ), + queryRunner.query( + `ALTER TABLE "instance" ALTER COLUMN "iconUrl" DROP DEFAULT`, + ), + queryRunner.query( + `ALTER TABLE "instance" ALTER COLUMN "faviconUrl" DROP DEFAULT`, + ), + queryRunner.query( + `ALTER TABLE "instance" ALTER COLUMN "themeColor" DROP DEFAULT`, + ), + queryRunner.query( + `ALTER TABLE "clip" ALTER COLUMN "description" DROP DEFAULT`, + ), + queryRunner.query( + `ALTER TABLE "note" ALTER COLUMN "channelId" DROP DEFAULT`, + ), + queryRunner.query( + `ALTER TABLE "abuse_user_report" ALTER COLUMN "comment" DROP DEFAULT`, + ), + + queryRunner.query( + `CREATE INDEX "IDX_315c779174fe8247ab324f036e" ON "drive_file" ("isLink")`, + ), + queryRunner.query( + `CREATE INDEX "IDX_f22169eb10657bded6d875ac8f" ON "note" ("channelId")`, + ), + //queryRunner.query(`CREATE INDEX "IDX_a9021cc2e1feb5f72d3db6e9f5" ON "abuse_user_report" ("targetUserId")`), + + //queryRunner.query(`DELETE FROM "abuse_user_report" WHERE "targetUserId" NOT IN (SELECT "id" FROM "user")`).then(() => { + // queryRunner.query(`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + //}), + + queryRunner.query( + `ALTER TABLE "poll" ADD CONSTRAINT "UQ_da851e06d0dfe2ef397d8b1bf1b" UNIQUE ("noteId")`, + ), + queryRunner.query( + `ALTER TABLE "user_keypair" ADD CONSTRAINT "UQ_f4853eb41ab722fe05f81cedeb6" UNIQUE ("userId")`, + ), + queryRunner.query( + `ALTER TABLE "user_profile" ADD CONSTRAINT "UQ_51cb79b5555effaf7d69ba1cff9" UNIQUE ("userId")`, + ), + queryRunner.query( + `ALTER TABLE "user_publickey" ADD CONSTRAINT "UQ_10c146e4b39b443ede016f6736d" UNIQUE ("userId")`, + ), + queryRunner.query( + `ALTER TABLE "promo_note" ADD CONSTRAINT "UQ_e263909ca4fe5d57f8d4230dd5c" UNIQUE ("noteId")`, + ), + + queryRunner.query( + `ALTER TABLE "page" RENAME CONSTRAINT "FK_3126dd7c502c9e4d7597ef7ef10" TO "FK_a9ca79ad939bf06066b81c9d3aa"`, + ), + + queryRunner.query( + `ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum" ADD VALUE 'pollEnded' AFTER 'pollVote'`, + ), + ]); + } + + async down(queryRunner: QueryRunner): Promise { + await Promise.all([ + // There is no ALTER TYPE REMOVE VALUE query, so the reverse operation is a bit more complex + queryRunner + .query( + `UPDATE "user_profile" SET "mutingNotificationTypes" = array_remove("mutingNotificationTypes", 'pollEnded')`, + ) + .then(() => + queryRunner.query( + `CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`, + ), + ) + .then(() => + queryRunner.query( + `ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`, + ), + ) + .then(() => + queryRunner.query( + `ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum_old"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum_old"[]`, + ), + ) + .then(() => + queryRunner.query( + `ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`, + ), + ) + .then(() => + queryRunner.query( + `DROP TYPE "public"."user_profile_mutingnotificationtypes_enum"`, + ), + ) + .then(() => + queryRunner.query( + `ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum_old" RENAME TO "user_profile_mutingnotificationtypes_enum"`, + ), + ), + + queryRunner.query( + `ALTER TABLE "page" RENAME CONSTRAINT "FK_a9ca79ad939bf06066b81c9d3aa" TO "FK_3126dd7c502c9e4d7597ef7ef10"`, + ), + + queryRunner.query( + `ALTER TABLE "promo_note" DROP CONSTRAINT "UQ_e263909ca4fe5d57f8d4230dd5c"`, + ), + queryRunner.query( + `ALTER TABLE "user_publickey" DROP CONSTRAINT "UQ_10c146e4b39b443ede016f6736d"`, + ), + queryRunner.query( + `ALTER TABLE "user_profile" DROP CONSTRAINT "UQ_51cb79b5555effaf7d69ba1cff9"`, + ), + queryRunner.query( + `ALTER TABLE "user_keypair" DROP CONSTRAINT "UQ_f4853eb41ab722fe05f81cedeb6"`, + ), + queryRunner.query( + `ALTER TABLE "poll" DROP CONSTRAINT "UQ_da851e06d0dfe2ef397d8b1bf1b"`, + ), + + queryRunner.query( + `ALTER TABLE "abuse_user_report" ALTER COLUMN "comment" SET DEFAULT '{}'`, + ), + queryRunner.query( + `ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f"`, + ), + + queryRunner.query(`DROP INDEX "public"."IDX_a9021cc2e1feb5f72d3db6e9f5"`), + queryRunner.query(`DROP INDEX "public"."IDX_f22169eb10657bded6d875ac8f"`), + queryRunner.query(`DROP INDEX "public"."IDX_315c779174fe8247ab324f036e"`), + + /* DEFAULT's are not set again because if the column can be NULL, then DEFAULT NULL is not necessary. + see also https://github.com/typeorm/typeorm/issues/7579#issuecomment-835423615 */ + + queryRunner.query( + `CREATE INDEX "IDX_note_on_channelId_and_id_desc" ON "note" ("id", "channelId") `, + ), + queryRunner.query( + `ALTER INDEX "public"."IDX_c8cc87bd0f2f4487d17c651fbf" RENAME TO "IDX_seoignmeoprigmkpodgrjmkpormg"`, + ), + ]); + } +} diff --git a/packages/backend/src/migration/1652859567549-uniform-themecolor.ts b/packages/backend/src/migration/1652859567549-uniform-themecolor.ts new file mode 100644 index 0000000..d885421 --- /dev/null +++ b/packages/backend/src/migration/1652859567549-uniform-themecolor.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +import tinycolor from "tinycolor2"; + +export class uniformThemecolor1652859567549 implements MigrationInterface { + name = "uniformThemecolor1652859567549"; + + async up(queryRunner: QueryRunner): Promise { + const formatColor = (color) => { + let tc = new tinycolor(color); + if (tc.isValid()) { + return tc.toHexString(); + } else { + return null; + } + }; + + await queryRunner + .query( + 'SELECT "id", "themeColor" FROM "instance" WHERE "themeColor" IS NOT NULL', + ) + .then((instances) => + Promise.all( + instances.map((instance) => { + // update theme color to uniform format, e.g. #00ff00 + // invalid theme colors get set to null + return queryRunner.query( + 'UPDATE "instance" SET "themeColor" = $1 WHERE "id" = $2', + [formatColor(instance.themeColor), instance.id], + ); + }), + ), + ); + + // also fix own theme color + await queryRunner + .query( + 'SELECT "themeColor" FROM "meta" WHERE "themeColor" IS NOT NULL LIMIT 1', + ) + .then((metas) => { + if (metas.length > 0) { + return queryRunner.query('UPDATE "meta" SET "themeColor" = $1', [ + formatColor(metas[0].themeColor), + ]); + } + }); + } + + async down(queryRunner: QueryRunner): Promise { + // The original representation is not stored, so migrating back is not possible. + // The new format also works in older versions so this is not a problem. + } +} diff --git a/packages/backend/src/migration/1655368940105-nsfw-detection.ts b/packages/backend/src/migration/1655368940105-nsfw-detection.ts new file mode 100644 index 0000000..59e9d0f --- /dev/null +++ b/packages/backend/src/migration/1655368940105-nsfw-detection.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class nsfwDetection1655368940105 implements MigrationInterface { + name = "nsfwDetection1655368940105"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "drive_file" ADD "forceIsSensitive" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "drive_file" ADD "predictedIsSensitive" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."predictedIsSensitive" IS 'Whether the DriveFile is NSFW. (predict)'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."meta_sensitiveimagedetection_enum" AS ENUM('none', 'all', 'local', 'remote')`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "sensitiveImageDetection" "public"."meta_sensitiveimagedetection_enum" NOT NULL DEFAULT 'none'`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "forceIsSensitiveWhenPredicted" boolean NOT NULL DEFAULT true`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_fc2d74a6d7d8b11292a851d8f8" ON "drive_file" ("predictedIsSensitive") `, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_fc2d74a6d7d8b11292a851d8f8"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "forceIsSensitiveWhenPredicted"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "sensitiveImageDetection"`, + ); + await queryRunner.query( + `DROP TYPE "public"."meta_sensitiveimagedetection_enum"`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."predictedIsSensitive" IS 'Whether the DriveFile is NSFW. (predict)'`, + ); + await queryRunner.query( + `ALTER TABLE "drive_file" DROP COLUMN "predictedIsSensitive"`, + ); + await queryRunner.query( + `ALTER TABLE "drive_file" DROP COLUMN "forceIsSensitive"`, + ); + } +} diff --git a/packages/backend/src/migration/1655371960534-nsfw-detection-2.ts b/packages/backend/src/migration/1655371960534-nsfw-detection-2.ts new file mode 100644 index 0000000..19de858 --- /dev/null +++ b/packages/backend/src/migration/1655371960534-nsfw-detection-2.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class nsfwDetection21655371960534 implements MigrationInterface { + name = "nsfwDetection21655371960534"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum" AS ENUM('medium', 'low', 'high')`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "sensitiveImageDetectionSensitivity" "public"."meta_sensitiveimagedetectionsensitivity_enum" NOT NULL DEFAULT 'medium'`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "disallowUploadWhenPredictedAsPorn" boolean NOT NULL DEFAULT false`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "disallowUploadWhenPredictedAsPorn"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "sensitiveImageDetectionSensitivity"`, + ); + await queryRunner.query( + `DROP TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum"`, + ); + } +} diff --git a/packages/backend/src/migration/1655388169582-nsfw-detection-3.ts b/packages/backend/src/migration/1655388169582-nsfw-detection-3.ts new file mode 100644 index 0000000..b1730f5 --- /dev/null +++ b/packages/backend/src/migration/1655388169582-nsfw-detection-3.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class nsfwDetection31655388169582 implements MigrationInterface { + name = "nsfwDetection31655388169582"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum" RENAME TO "meta_sensitiveimagedetectionsensitivity_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum" AS ENUM('medium', 'low', 'high', 'veryLow', 'veryHigh')`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum" USING "sensitiveImageDetectionSensitivity"::"text"::"public"."meta_sensitiveimagedetectionsensitivity_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" SET DEFAULT 'medium'`, + ); + await queryRunner.query( + `DROP TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum_old"`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum_old" AS ENUM('medium', 'low', 'high')`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum_old" USING "sensitiveImageDetectionSensitivity"::"text"::"public"."meta_sensitiveimagedetectionsensitivity_enum_old"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ALTER COLUMN "sensitiveImageDetectionSensitivity" SET DEFAULT 'medium'`, + ); + await queryRunner.query( + `DROP TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum"`, + ); + await queryRunner.query( + `ALTER TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum_old" RENAME TO "meta_sensitiveimagedetectionsensitivity_enum"`, + ); + } +} diff --git a/packages/backend/src/migration/1655393015659-nsfw-detection-4.ts b/packages/backend/src/migration/1655393015659-nsfw-detection-4.ts new file mode 100644 index 0000000..0adb11e --- /dev/null +++ b/packages/backend/src/migration/1655393015659-nsfw-detection-4.ts @@ -0,0 +1,58 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class nsfwDetection41655393015659 implements MigrationInterface { + name = "nsfwDetection41655393015659"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "sensitiveImageDetection"`, + ); + await queryRunner.query( + `DROP TYPE "public"."meta_sensitiveimagedetection_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "sensitiveImageDetectionSensitivity"`, + ); + await queryRunner.query( + `DROP TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."meta_sensitivemediadetection_enum" AS ENUM('none', 'all', 'local', 'remote')`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "sensitiveMediaDetection" "public"."meta_sensitivemediadetection_enum" NOT NULL DEFAULT 'none'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."meta_sensitivemediadetectionsensitivity_enum" AS ENUM('medium', 'low', 'high', 'veryLow', 'veryHigh')`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "sensitiveMediaDetectionSensitivity" "public"."meta_sensitivemediadetectionsensitivity_enum" NOT NULL DEFAULT 'medium'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "sensitiveMediaDetectionSensitivity"`, + ); + await queryRunner.query( + `DROP TYPE "public"."meta_sensitivemediadetectionsensitivity_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "sensitiveMediaDetection"`, + ); + await queryRunner.query( + `DROP TYPE "public"."meta_sensitivemediadetection_enum"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."meta_sensitiveimagedetectionsensitivity_enum" AS ENUM('medium', 'low', 'high', 'veryLow', 'veryHigh')`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "sensitiveImageDetectionSensitivity" "public"."meta_sensitiveimagedetectionsensitivity_enum" NOT NULL DEFAULT 'medium'`, + ); + await queryRunner.query( + `CREATE TYPE "public"."meta_sensitiveimagedetection_enum" AS ENUM('none', 'all', 'local', 'remote')`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "sensitiveImageDetection" "public"."meta_sensitiveimagedetection_enum" NOT NULL DEFAULT 'none'`, + ); + } +} diff --git a/packages/backend/src/migration/1655813815729-driveCapacityOverrideMb.ts b/packages/backend/src/migration/1655813815729-driveCapacityOverrideMb.ts new file mode 100644 index 0000000..e55939f --- /dev/null +++ b/packages/backend/src/migration/1655813815729-driveCapacityOverrideMb.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class driveCapacityOverrideMb1655813815729 implements MigrationInterface { + name = "driveCapacityOverrideMb1655813815729"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD "driveCapacityOverrideMb" integer`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."driveCapacityOverrideMb" IS 'Overrides user drive capacity limit'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `COMMENT ON COLUMN "user"."driveCapacityOverrideMb" IS 'Overrides user drive capacity limit'`, + ); + await queryRunner.query( + `ALTER TABLE "user" DROP COLUMN "driveCapacityOverrideMb"`, + ); + } +} diff --git a/packages/backend/src/migration/1655918165614-user-ip.ts b/packages/backend/src/migration/1655918165614-user-ip.ts new file mode 100644 index 0000000..c4d77e8 --- /dev/null +++ b/packages/backend/src/migration/1655918165614-user-ip.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class userIp1655918165614 implements MigrationInterface { + name = "userIp1655918165614"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_ip" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "ip" character varying(128) NOT NULL, CONSTRAINT "PK_2c44ddfbf7c0464d028dcef325e" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_7f7f1c66f48e9a8e18a33bc515" ON "user_ip" ("userId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_361b500e06721013c124b7b6c5" ON "user_ip" ("userId", "ip") `, + ); + await queryRunner.query( + `ALTER TABLE "user_ip" ADD CONSTRAINT "FK_7f7f1c66f48e9a8e18a33bc5150" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_ip" DROP CONSTRAINT "FK_7f7f1c66f48e9a8e18a33bc5150"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_361b500e06721013c124b7b6c5"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_7f7f1c66f48e9a8e18a33bc515"`, + ); + await queryRunner.query(`DROP TABLE "user_ip"`); + } +} diff --git a/packages/backend/src/migration/1656122560740-file-ip.ts b/packages/backend/src/migration/1656122560740-file-ip.ts new file mode 100644 index 0000000..fa69d8a --- /dev/null +++ b/packages/backend/src/migration/1656122560740-file-ip.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class fileIp1656122560740 implements MigrationInterface { + name = "fileIp1656122560740"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "drive_file" ADD "requestHeaders" jsonb DEFAULT '{}'`, + ); + await queryRunner.query( + `ALTER TABLE "drive_file" ADD "requestIp" character varying(128)`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "requestIp"`); + await queryRunner.query( + `ALTER TABLE "drive_file" DROP COLUMN "requestHeaders"`, + ); + } +} diff --git a/packages/backend/src/migration/1656251734807-nsfw-detection-5.ts b/packages/backend/src/migration/1656251734807-nsfw-detection-5.ts new file mode 100644 index 0000000..1d69cb5 --- /dev/null +++ b/packages/backend/src/migration/1656251734807-nsfw-detection-5.ts @@ -0,0 +1,80 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class nsfwDetection51656251734807 implements MigrationInterface { + name = "nsfwDetection51656251734807"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_fc2d74a6d7d8b11292a851d8f8"`, + ); + await queryRunner.query( + `ALTER TABLE "drive_file" DROP COLUMN "forceIsSensitive"`, + ); + await queryRunner.query( + `ALTER TABLE "drive_file" DROP COLUMN "predictedIsSensitive"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "forceIsSensitiveWhenPredicted"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "disallowUploadWhenPredictedAsPorn"`, + ); + await queryRunner.query( + `ALTER TABLE "drive_file" ADD "maybeSensitive" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."maybeSensitive" IS 'Whether the DriveFile is NSFW. (predict)'`, + ); + await queryRunner.query( + `ALTER TABLE "drive_file" ADD "maybePorn" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "setSensitiveFlagAutomatically" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "autoSensitive" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_3b33dff77bb64b23c88151d23e" ON "drive_file" ("maybeSensitive") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_8bdcd3dd2bddb78014999a16ce" ON "drive_file" ("maybePorn") `, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_8bdcd3dd2bddb78014999a16ce"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_3b33dff77bb64b23c88151d23e"`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "autoSensitive"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "setSensitiveFlagAutomatically"`, + ); + await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "maybePorn"`); + await queryRunner.query( + `COMMENT ON COLUMN "drive_file"."maybeSensitive" IS 'Whether the DriveFile is NSFW. (predict)'`, + ); + await queryRunner.query( + `ALTER TABLE "drive_file" DROP COLUMN "maybeSensitive"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "disallowUploadWhenPredictedAsPorn" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "forceIsSensitiveWhenPredicted" boolean NOT NULL DEFAULT true`, + ); + await queryRunner.query( + `ALTER TABLE "drive_file" ADD "predictedIsSensitive" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "drive_file" ADD "forceIsSensitive" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_fc2d74a6d7d8b11292a851d8f8" ON "drive_file" ("predictedIsSensitive") `, + ); + } +} diff --git a/packages/backend/src/migration/1656328812281-ip-2.ts b/packages/backend/src/migration/1656328812281-ip-2.ts new file mode 100644 index 0000000..75d4f57 --- /dev/null +++ b/packages/backend/src/migration/1656328812281-ip-2.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class ip21656328812281 implements MigrationInterface { + name = "ip21656328812281"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_ip" DROP CONSTRAINT "FK_7f7f1c66f48e9a8e18a33bc5150"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "enableIpLogging" boolean NOT NULL DEFAULT false`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableIpLogging"`); + await queryRunner.query( + `ALTER TABLE "user_ip" ADD CONSTRAINT "FK_7f7f1c66f48e9a8e18a33bc5150" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } +} diff --git a/packages/backend/src/migration/1656408772602-nsfw-detection-6.ts b/packages/backend/src/migration/1656408772602-nsfw-detection-6.ts new file mode 100644 index 0000000..ab1c8fb --- /dev/null +++ b/packages/backend/src/migration/1656408772602-nsfw-detection-6.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class nsfwDetection61656408772602 implements MigrationInterface { + name = "nsfwDetection61656408772602"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "enableSensitiveMediaDetectionForVideos" boolean NOT NULL DEFAULT false`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "enableSensitiveMediaDetectionForVideos"`, + ); + } +} diff --git a/packages/backend/src/migration/1656772790599-user-moderation-note.ts b/packages/backend/src/migration/1656772790599-user-moderation-note.ts new file mode 100644 index 0000000..dbc124d --- /dev/null +++ b/packages/backend/src/migration/1656772790599-user-moderation-note.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class userModerationNote1656772790599 implements MigrationInterface { + name = "userModerationNote1656772790599"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "moderationNote" character varying(8192) NOT NULL DEFAULT ''`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "moderationNote"`, + ); + } +} diff --git a/packages/backend/src/migration/1657346559800-active-email-validation.ts b/packages/backend/src/migration/1657346559800-active-email-validation.ts new file mode 100644 index 0000000..ed6394b --- /dev/null +++ b/packages/backend/src/migration/1657346559800-active-email-validation.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class activeEmailValidation1657346559800 implements MigrationInterface { + name = "activeEmailValidation1657346559800"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "enableActiveEmailValidation" boolean NOT NULL DEFAULT true`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "enableActiveEmailValidation"`, + ); + } +} diff --git a/packages/backend/src/migration/1658203170545firefish.ts b/packages/backend/src/migration/1658203170545firefish.ts new file mode 100644 index 0000000..65713cd --- /dev/null +++ b/packages/backend/src/migration/1658203170545firefish.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class calckey1658203170545 implements MigrationInterface { + name = "calckey1658203170545"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE meta SET "repositoryUrl" = 'https://codeberg/firefish/firefish'`, + ); + await queryRunner.query( + `UPDATE meta SET "feedbackUrl" = 'https://codeberg/firefish/firefish/issues'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE meta SET "repositoryUrl" = 'https://codeberg/firefish/firefish'`, + ); + await queryRunner.query( + `UPDATE meta SET "feedbackUrl" = 'https://codeberg/firefish/firefish/issues'`, + ); + } +} diff --git a/packages/backend/src/migration/1658656633972-note-replies-function.ts b/packages/backend/src/migration/1658656633972-note-replies-function.ts new file mode 100644 index 0000000..7f70bb1 --- /dev/null +++ b/packages/backend/src/migration/1658656633972-note-replies-function.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class noteRepliesFunction1658656633972 implements MigrationInterface { + name = "noteRepliesFunction1658656633972"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE OR REPLACE FUNCTION note_replies(start_id varchar, max_depth integer, max_breadth integer) RETURNS TABLE (id VARCHAR) AS + $$ + SELECT DISTINCT id FROM ( + WITH RECURSIVE tree (id, ancestors, depth) AS ( + SELECT start_id, '{}'::VARCHAR[], 0 + UNION + SELECT + note.id, + CASE + WHEN note."replyId" = tree.id THEN tree.ancestors || note."replyId" + ELSE tree.ancestors || note."renoteId" + END, + depth + 1 + FROM note, tree + WHERE ( + note."replyId" = tree.id + OR + ( + -- get renotes but not pure renotes + note."renoteId" = tree.id + AND + ( + note.text IS NOT NULL + OR + CARDINALITY(note."fileIds") != 0 + OR + note."hasPoll" = TRUE + ) + ) + ) AND depth < max_depth + ) + SELECT + id, + -- apply the limit per node + row_number() OVER (PARTITION BY ancestors[array_upper(ancestors, 1)]) AS nth_child + FROM tree + WHERE depth > 0 + ) AS recursive WHERE nth_child < max_breadth + $$ + LANGUAGE SQL + `); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP FUNCTION note_replies`); + } +} diff --git a/packages/backend/src/migration/1658939464003CustomMOTD.ts b/packages/backend/src/migration/1658939464003CustomMOTD.ts new file mode 100644 index 0000000..b082a3f --- /dev/null +++ b/packages/backend/src/migration/1658939464003CustomMOTD.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class CustomMOTD1658939464003 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "customMOTD" character varying(256) array NOT NULL DEFAULT '{}'::varchar[]`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "customMOTD"`); + } +} diff --git a/packages/backend/src/migration/1658941974648CustomSplashIcons.ts b/packages/backend/src/migration/1658941974648CustomSplashIcons.ts new file mode 100644 index 0000000..4fa2db5 --- /dev/null +++ b/packages/backend/src/migration/1658941974648CustomSplashIcons.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class CustomSplashIcons1658941974648 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "customSplashIcons" character varying(256) array NOT NULL DEFAULT '{}'::varchar[]`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "customSplashIcons"`, + ); + } +} diff --git a/packages/backend/src/migration/1658981842728FixCalckey.ts b/packages/backend/src/migration/1658981842728FixCalckey.ts new file mode 100644 index 0000000..7108640 --- /dev/null +++ b/packages/backend/src/migration/1658981842728FixCalckey.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class FixFirefish1658981842728 implements MigrationInterface { + name = "FixFirefish1658981842728"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "meta" SET "useStarForReactionFallback" = TRUE;`, + ); + await queryRunner.query( + `UPDATE "meta" SET "repositoryUrl" = 'https://codeberg/firefish/firefish'`, + ); + await queryRunner.query( + `UPDATE "meta" SET "feedbackUrl" = 'https://codeberg/firefish/firefish/issues'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "meta" SET "useStarForReactionFallback" = FALSE;`, + ); + await queryRunner.query( + `UPDATE "meta" SET "repositoryUrl" = 'https://codeberg/firefish/firefish'`, + ); + await queryRunner.query( + `UPDATE "meta" SET "feedbackUrl" = 'https://codeberg/firefish/firefish/issues'`, + ); + } +} diff --git a/packages/backend/src/migration/1659042130648RecommendedTimeline.ts b/packages/backend/src/migration/1659042130648RecommendedTimeline.ts new file mode 100644 index 0000000..f268870 --- /dev/null +++ b/packages/backend/src/migration/1659042130648RecommendedTimeline.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class RecommendedTimeline1659042130648 implements MigrationInterface { + name = "RecommendedTimeline1659042130648"; + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "disableRecommendedTimeline" boolean NOT NULL DEFAULT true`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "recommendedInstances" character varying(256) array NOT NULL DEFAULT '{}'::varchar[]`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "disableRecommendedTimeline"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "recommendedInstances"`, + ); + } +} diff --git a/packages/backend/src/migration/1660068273737GuestTimeline.ts b/packages/backend/src/migration/1660068273737GuestTimeline.ts new file mode 100644 index 0000000..fde5e59 --- /dev/null +++ b/packages/backend/src/migration/1660068273737GuestTimeline.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class GuestTimeline1660068273737 implements MigrationInterface { + name = "GuestTimeline1660068273737"; + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "enableGuestTimeline" boolean NOT NULL DEFAULT false`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "enableGuestTimeline"`, + ); + } +} diff --git a/packages/backend/src/migration/1665091090561-add-renote-muting.ts b/packages/backend/src/migration/1665091090561-add-renote-muting.ts new file mode 100644 index 0000000..2c136b3 --- /dev/null +++ b/packages/backend/src/migration/1665091090561-add-renote-muting.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class addRenoteMuting1665091090561 implements MigrationInterface { + name = "addRenoteMuting1665091090561"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "renote_muting" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "muteeId" character varying(32) NOT NULL, "muterId" character varying(32) NOT NULL, CONSTRAINT "PK_renoteMuting_id" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId") `, + ); + } + + async down(queryRunner: QueryRunner): Promise {} +} diff --git a/packages/backend/src/migration/1668828368510PageDraft.ts b/packages/backend/src/migration/1668828368510PageDraft.ts new file mode 100644 index 0000000..e50fd0d --- /dev/null +++ b/packages/backend/src/migration/1668828368510PageDraft.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class Page1668828368510 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "page" ADD "isPublic" boolean NOT NULL DEFAULT true`, + ); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "page" DROP COLUMN "isPublic"`); + } +} diff --git a/packages/backend/src/migration/1668831378728FixCalckeyAgain.ts b/packages/backend/src/migration/1668831378728FixCalckeyAgain.ts new file mode 100644 index 0000000..7ed1ab4 --- /dev/null +++ b/packages/backend/src/migration/1668831378728FixCalckeyAgain.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class FixFirefishAgain1668831378728 implements MigrationInterface { + name = "FixFirefishAgain1668831378728"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "meta" SET "useStarForReactionFallback" = TRUE`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE "meta" SET "useStarForReactionFallback" = FALSE`, + ); + } +} diff --git a/packages/backend/src/migration/1669138716634-whetherPushNotifyToSendReadMessage.ts b/packages/backend/src/migration/1669138716634-whetherPushNotifyToSendReadMessage.ts new file mode 100644 index 0000000..9e27779 --- /dev/null +++ b/packages/backend/src/migration/1669138716634-whetherPushNotifyToSendReadMessage.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class whetherPushNotifyToSendReadMessage1669138716634 implements MigrationInterface { + name = "whetherPushNotifyToSendReadMessage1669138716634"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "sw_subscription" ADD "sendReadMessage" boolean NOT NULL DEFAULT false`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "sw_subscription" DROP COLUMN "sendReadMessage"`, + ); + } +} diff --git a/packages/backend/src/migration/1669288094000-AddMovedToAndKnownAs.ts b/packages/backend/src/migration/1669288094000-AddMovedToAndKnownAs.ts new file mode 100644 index 0000000..c7d0fa2 --- /dev/null +++ b/packages/backend/src/migration/1669288094000-AddMovedToAndKnownAs.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class addMovedToAndKnownAs1669288094000 implements MigrationInterface { + name = "addMovedToAndKnownAs1669288094000"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD "movedToUri" character varying(512)`, + ); + await queryRunner.query(`ALTER TABLE "user" ADD "alsoKnownAs" TEXT`); + await queryRunner.query( + `COMMENT ON COLUMN "user"."movedToUri" IS 'The URI of the new account of the User'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "user"."alsoKnownAs" IS 'URIs the user is known as too'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "movedToUri"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "alsoKnownAs"`); + } +} diff --git a/packages/backend/src/migration/1671199573000-AddFkAbuseUserReportTargetUserIdToUserId.ts b/packages/backend/src/migration/1671199573000-AddFkAbuseUserReportTargetUserIdToUserId.ts new file mode 100644 index 0000000..00ea7bf --- /dev/null +++ b/packages/backend/src/migration/1671199573000-AddFkAbuseUserReportTargetUserIdToUserId.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class addFkAbuseUserReportTargetUserIdToUserId1671199573000 implements MigrationInterface { + name = "addFkAbuseUserReportTargetUserIdToUserId1671199573000"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM abuse_user_report WHERE NOT EXISTS (SELECT 1 FROM "user" WHERE "user"."id" = "abuse_user_report"."targetUserId")`, + ); + await queryRunner.query( + `ALTER TABLE abuse_user_report ADD CONSTRAINT fk_7f4e851a35d81b64dda28eee0 FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE abuse_user_report DROP CONSTRAINT fk_7f4e851a35d81b64dda28eee0`, + ); + } +} diff --git a/packages/backend/src/migration/1671388343000-CalckeyRepoMove.ts b/packages/backend/src/migration/1671388343000-CalckeyRepoMove.ts new file mode 100644 index 0000000..3fa0c54 --- /dev/null +++ b/packages/backend/src/migration/1671388343000-CalckeyRepoMove.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +/* "FirefishRepoMove1671388343000" is a class that updates the "useStarForReactionFallback" column in +the "meta" table to TRUE */ +export class FirefishRepoMove1671388343000 implements MigrationInterface { + name = "FirefishRepoMove1671388343000"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE meta SET "repositoryUrl" = 'https://codeberg/firefish/firefish'`, + ); + await queryRunner.query( + `UPDATE meta SET "feedbackUrl" = 'https://codeberg/firefish/firefish/issues'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE meta SET "repositoryUrl" = 'https://codeberg/firefish/firefish'`, + ); + await queryRunner.query( + `UPDATE meta SET "feedbackUrl" = 'https://codeberg/firefish/firefish/issues'`, + ); + } +} diff --git a/packages/backend/src/migration/1672882664294-DefaultReaction.ts b/packages/backend/src/migration/1672882664294-DefaultReaction.ts new file mode 100644 index 0000000..ee99c75 --- /dev/null +++ b/packages/backend/src/migration/1672882664294-DefaultReaction.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class DefaultReaction1672882664294 implements MigrationInterface { + name = "DefaultReaction1672882664294"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "defaultReaction" character varying(256) NOT NULL DEFAULT '⭐'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "meta"."defaultReaction" IS 'The fallback reaction for emoji reacts'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "defaultReaction"`); + } +} diff --git a/packages/backend/src/migration/1673336077243-PollChoiceLength.ts b/packages/backend/src/migration/1673336077243-PollChoiceLength.ts new file mode 100644 index 0000000..10cc40f --- /dev/null +++ b/packages/backend/src/migration/1673336077243-PollChoiceLength.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class PollChoiceLength1673336077243 implements MigrationInterface { + name = "PollChoiceLength1673336077243"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "poll" ALTER COLUMN "choices" TYPE character varying(256) array`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "poll" ALTER COLUMN "choices" TYPE character varying(128) array`, + ); + } +} diff --git a/packages/backend/src/migration/1676093997212-AntennaInstances.ts b/packages/backend/src/migration/1676093997212-AntennaInstances.ts new file mode 100644 index 0000000..afb7c86 --- /dev/null +++ b/packages/backend/src/migration/1676093997212-AntennaInstances.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class AntennaInstances1676093997212 implements MigrationInterface { + name = "AntennaInstances1676093997212"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE "antenna_src_enum" ADD VALUE 'instances'`, + ); + await queryRunner.query( + `ALTER TABLE "antenna" ADD "instances" jsonb NOT NULL DEFAULT '[]'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM "antenna" WHERE "src" = 'instances'`); + await queryRunner.query(`ALTER TABLE "antenna" DROP COLUMN "instances"`); + await queryRunner.query( + `CREATE TYPE "public"."antenna_src_enum_old" AS ENUM('home', 'all', 'users', 'list', 'group')`, + ); + await queryRunner.query( + `ALTER TABLE "antenna" ALTER COLUMN "src" TYPE "public"."antenna_src_enum_old" USING "src"::"text"::"public"."antenna_src_enum_old"`, + ); + await queryRunner.query(`DROP TYPE "public"."antenna_src_enum"`); + await queryRunner.query( + `ALTER TYPE "public"."antenna_src_enum_old" RENAME TO "antenna_src_enum"`, + ); + } +} diff --git a/packages/backend/src/migration/1677935903517-DriveComment.ts b/packages/backend/src/migration/1677935903517-DriveComment.ts new file mode 100644 index 0000000..f500ecd --- /dev/null +++ b/packages/backend/src/migration/1677935903517-DriveComment.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class DriveComment1677935903517 implements MigrationInterface { + name = "DriveComment1677935903517"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "drive_file" ALTER "comment" TYPE character varying(8192)`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "drive_file" ALTER "comment" TYPE character varying(512)`, + ); + } +} diff --git a/packages/backend/src/migration/1678426061773-tweak-varchar-length.ts b/packages/backend/src/migration/1678426061773-tweak-varchar-length.ts new file mode 100644 index 0000000..65ee46a --- /dev/null +++ b/packages/backend/src/migration/1678426061773-tweak-varchar-length.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class tweakVarcharLength1678426061773 implements MigrationInterface { + name = "tweakVarcharLength1678426061773"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ALTER COLUMN "smtpUser" TYPE character varying(1024)`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "meta" ALTER COLUMN "smtpPass" TYPE character varying(1024)`, + undefined, + ); + } + + async down(queryRunner: QueryRunner): Promise {} +} diff --git a/packages/backend/src/migration/1678945242650-add-props-for-custom-emoji.ts b/packages/backend/src/migration/1678945242650-add-props-for-custom-emoji.ts new file mode 100644 index 0000000..85e0948 --- /dev/null +++ b/packages/backend/src/migration/1678945242650-add-props-for-custom-emoji.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class addPropsForCustomEmoji1678945242650 implements MigrationInterface { + name = "addPropsForCustomEmoji1678945242650"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "emoji" ADD "license" character varying(1024)`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "license"`); + } +} diff --git a/packages/backend/src/migration/1679269929000-fix-repo.ts b/packages/backend/src/migration/1679269929000-fix-repo.ts new file mode 100644 index 0000000..72f660c --- /dev/null +++ b/packages/backend/src/migration/1679269929000-fix-repo.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class FixRepo1679269929000 implements MigrationInterface { + name = "FixRepo1679269929000"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE meta SET "repositoryUrl" = 'https://codeberg.org/firefish/firefish'`, + ); + await queryRunner.query( + `UPDATE meta SET "feedbackUrl" = 'https://codeberg.org/firefish/firefish/issues'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE meta SET "repositoryUrl" = 'https://codeberg.org/firefish/firefish'`, + ); + await queryRunner.query( + `UPDATE meta SET "feedbackUrl" = 'https://codeberg.org/firefish/firefish/issues'`, + ); + } +} diff --git a/packages/backend/src/migration/1680375641101-clean-charts.ts b/packages/backend/src/migration/1680375641101-clean-charts.ts new file mode 100644 index 0000000..c27b6ed --- /dev/null +++ b/packages/backend/src/migration/1680375641101-clean-charts.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class CleanCharts1680375641101 implements MigrationInterface { + constructor() { + this.name = "CleanCharts1680375641101"; + } + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `delete from __chart__hashtag where ___local_users = 0 and ___remote_users = 0;`, + ); + await queryRunner.query( + `delete from __chart_day__hashtag where ___local_users = 0 and ___remote_users = 0;`, + ); + await queryRunner.query(`COMMIT;`); + await queryRunner.query(`vacuum __chart__hashtag;`); + await queryRunner.query(`vacuum __chart_day__hashtag;`); + await queryRunner.query(`COMMIT;`); + } + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `delete from __chart__hashtag where ___local_users = 0 and ___remote_users = 0;`, + ); + await queryRunner.query( + `delete from __chart_day__hashtag where ___local_users = 0 and ___remote_users = 0;`, + ); + } +} diff --git a/packages/backend/src/migration/1680426269172-SpeakAsCat.ts b/packages/backend/src/migration/1680426269172-SpeakAsCat.ts new file mode 100644 index 0000000..35f8261 --- /dev/null +++ b/packages/backend/src/migration/1680426269172-SpeakAsCat.ts @@ -0,0 +1,21 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class SpeakAsCat1680426269172 implements MigrationInterface { + name = "SpeakAsCat1680426269172"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "user" + ADD "speakAsCat" boolean NOT NULL DEFAULT true + `); + await queryRunner.query(` + COMMENT ON COLUMN "user"."speakAsCat" + IS 'Whether to speak as a cat if isCat.' + `); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "user" DROP COLUMN "speakAsCat" + `); + } +} diff --git a/packages/backend/src/migration/1682753227899-NoteEdit.ts b/packages/backend/src/migration/1682753227899-NoteEdit.ts new file mode 100644 index 0000000..8bf908b --- /dev/null +++ b/packages/backend/src/migration/1682753227899-NoteEdit.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class NoteEdit1682753227899 implements MigrationInterface { + name = "NoteEdit1682753227899"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "note_edit" ( + "id" character varying(32) NOT NULL, + "noteId" character varying(32) NOT NULL, + "text" text, + "cw" character varying(512), + "fileIds" character varying(32) array NOT NULL DEFAULT '{}', + "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL, + CONSTRAINT "PK_736fc6e0d4e222ecc6f82058e08" PRIMARY KEY ("id") + ) + `); + await queryRunner.query(` + COMMENT ON COLUMN "note_edit"."noteId" IS 'The ID of note.' + `); + await queryRunner.query(` + COMMENT ON COLUMN "note_edit"."updatedAt" IS 'The updated date of the Note.' + `); + await queryRunner.query(` + CREATE INDEX "IDX_702ad5ae993a672e4fbffbcd38" ON "note_edit" ("noteId") + `); + await queryRunner.query(` + ALTER TABLE "note" + ADD "updatedAt" TIMESTAMP WITH TIME ZONE + `); + await queryRunner.query(` + COMMENT ON COLUMN "note"."updatedAt" IS 'The updated date of the Note.' + `); + await queryRunner.query(` + ALTER TABLE "note_edit" + ADD CONSTRAINT "FK_702ad5ae993a672e4fbffbcd38c" + FOREIGN KEY ("noteId") + REFERENCES "note"("id") + ON DELETE CASCADE + ON UPDATE NO ACTION + `); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "note_edit" DROP CONSTRAINT "FK_702ad5ae993a672e4fbffbcd38c" + `); + await queryRunner.query(` + ALTER TABLE "note" DROP COLUMN "updatedAt" + `); + await queryRunner.query(` + DROP TABLE "note_edit" + `); + } +} diff --git a/packages/backend/src/migration/1682777547198-LibreTranslate.ts b/packages/backend/src/migration/1682777547198-LibreTranslate.ts new file mode 100644 index 0000000..deda6ff --- /dev/null +++ b/packages/backend/src/migration/1682777547198-LibreTranslate.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class LibreTranslate1682777547198 implements MigrationInterface { + name = "LibreTranslate1682777547198"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "meta" + ADD "libreTranslateApiUrl" character varying(512) + `); + await queryRunner.query(` + ALTER TABLE "meta" + ADD "libreTranslateApiKey" character varying(128) + `); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "meta" DROP COLUMN "libreTranslateApiKey" + `); + await queryRunner.query(` + ALTER TABLE "meta" DROP COLUMN "libreTranslateApiUrl" + `); + } +} diff --git a/packages/backend/src/migration/1682891890317-InstanceSilence.ts b/packages/backend/src/migration/1682891890317-InstanceSilence.ts new file mode 100644 index 0000000..b35c69c --- /dev/null +++ b/packages/backend/src/migration/1682891890317-InstanceSilence.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class InstanceSilence1682891890317 implements MigrationInterface { + name = "InstanceSilence1682891890317"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "silencedHosts" character varying(256) array NOT NULL DEFAULT '{}'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "silencedHosts"`); + } +} diff --git a/packages/backend/src/migration/1682891891317-AddHiddenPosts.ts b/packages/backend/src/migration/1682891891317-AddHiddenPosts.ts new file mode 100644 index 0000000..fd08410 --- /dev/null +++ b/packages/backend/src/migration/1682891891317-AddHiddenPosts.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class AddHiddenPosts1682891891317 implements MigrationInterface { + name = "AddHiddenPosts1682891891317"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE note_visibility_enum ADD VALUE IF NOT EXISTS 'hidden'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TYPE note_visibility_enum REMOVE VALUE IF EXISTS 'hidden'`, + ); + } +} diff --git a/packages/backend/src/migration/1683682889948-PreventAiLearning.ts b/packages/backend/src/migration/1683682889948-PreventAiLearning.ts new file mode 100644 index 0000000..aba8833 --- /dev/null +++ b/packages/backend/src/migration/1683682889948-PreventAiLearning.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class PreventAiLearning1683682889948 implements MigrationInterface { + name = "PreventAiLearning1683682889948"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "preventAiLearning" boolean NOT NULL DEFAULT true`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_profile" DROP COLUMN "preventAiLearning"`, + ); + } +} diff --git a/packages/backend/src/migration/1683980686995-ExperimentalFeatures.ts b/packages/backend/src/migration/1683980686995-ExperimentalFeatures.ts new file mode 100644 index 0000000..e4f94d8 --- /dev/null +++ b/packages/backend/src/migration/1683980686995-ExperimentalFeatures.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class ExperimentalFeatures1683980686995 implements MigrationInterface { + name = "ExperimentalFeatures1683980686995"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "meta" + ADD "experimentalFeatures" jsonb NOT NULL DEFAULT '{}' + `); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "meta" DROP COLUMN "experimentalFeatures" + `); + } +} diff --git a/packages/backend/src/migration/1684206886988-remove-showTimelineReplies.ts b/packages/backend/src/migration/1684206886988-remove-showTimelineReplies.ts new file mode 100644 index 0000000..6c88190 --- /dev/null +++ b/packages/backend/src/migration/1684206886988-remove-showTimelineReplies.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class RemoveShowTimelineReplies1684206886988 implements MigrationInterface { + name = "RemoveShowTimelineReplies1684206886988"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" DROP COLUMN "showTimelineReplies"`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ADD "showTimelineReplies" boolean NOT NULL DEFAULT false`, + ); + } +} diff --git a/packages/backend/src/migration/1684494870830-EmojiSize.ts b/packages/backend/src/migration/1684494870830-EmojiSize.ts new file mode 100644 index 0000000..c053e6a --- /dev/null +++ b/packages/backend/src/migration/1684494870830-EmojiSize.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class EmojiSize1684494870830 implements MigrationInterface { + name = "EmojiSize1684494870830"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "emoji" ADD "width" integer`); + await queryRunner.query( + `COMMENT ON COLUMN "emoji"."width" IS 'Image width'`, + ); + await queryRunner.query(`ALTER TABLE "emoji" ADD "height" integer`); + await queryRunner.query( + `COMMENT ON COLUMN "emoji"."height" IS 'Image height'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "height"`); + await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "width"`); + } +} diff --git a/packages/backend/src/migration/1688280713783-add-meta-options.ts b/packages/backend/src/migration/1688280713783-add-meta-options.ts new file mode 100644 index 0000000..0463ac6 --- /dev/null +++ b/packages/backend/src/migration/1688280713783-add-meta-options.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class AddMetaOptions1688280713783 implements MigrationInterface { + name = "AddMetaOptions1688280713783"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "enableServerMachineStats" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD "enableIdenticonGeneration" boolean NOT NULL DEFAULT true`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "enableIdenticonGeneration"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "enableServerMachineStats"`, + ); + } +} diff --git a/packages/backend/src/migration/1688845537045-announcement-popup.ts b/packages/backend/src/migration/1688845537045-announcement-popup.ts new file mode 100644 index 0000000..5fe882a --- /dev/null +++ b/packages/backend/src/migration/1688845537045-announcement-popup.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class AnnouncementPopup1688845537045 implements MigrationInterface { + name = "AnnouncementPopup1688845537045"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "announcement" ADD "showPopup" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "announcement" ADD "isGoodNews" boolean NOT NULL DEFAULT false`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "announcement" DROP COLUMN "isGoodNews"`, + ); + await queryRunner.query( + `ALTER TABLE "announcement" DROP COLUMN "showPopup"`, + ); + } +} diff --git a/packages/backend/src/migration/1689136347561-donation-link.ts b/packages/backend/src/migration/1689136347561-donation-link.ts new file mode 100644 index 0000000..43ed8bc --- /dev/null +++ b/packages/backend/src/migration/1689136347561-donation-link.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class DonationLink1689136347561 implements MigrationInterface { + name = "DonationLink1689136347561"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "donationLink" character varying(256)`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "DonationLink1689136347561"`, + ); + } +} diff --git a/packages/backend/src/migration/1689739513827-firefish-repo.ts b/packages/backend/src/migration/1689739513827-firefish-repo.ts new file mode 100644 index 0000000..575eafd --- /dev/null +++ b/packages/backend/src/migration/1689739513827-firefish-repo.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class FirefishRepo1689739513827 implements MigrationInterface { + name = "FirefishRepo1689739513827"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE meta SET "repositoryUrl" = 'https://codeberg.org/firefish/firefish'`, + ); + await queryRunner.query( + `UPDATE meta SET "feedbackUrl" = 'https://codeberg.org/firefish/firefish/issues'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE meta SET "repositoryUrl" = 'https://codeberg.org/calckey/calckey'`, + ); + await queryRunner.query( + `UPDATE meta SET "feedbackUrl" = 'https://codeberg.org/calckey/calckey/firefish/firefish/issues'`, + ); + } +} diff --git a/packages/backend/src/migration/1689965609061-iceshrimp-repo.ts b/packages/backend/src/migration/1689965609061-iceshrimp-repo.ts new file mode 100644 index 0000000..c819981 --- /dev/null +++ b/packages/backend/src/migration/1689965609061-iceshrimp-repo.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class IceshrimpRepo1689965609061 implements MigrationInterface { + name = "IceshrimpRepo1689965609061"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE meta SET "repositoryUrl" = 'https://iceshrimp.dev/iceshrimp/iceshrimp'`, + ); + await queryRunner.query( + `UPDATE meta SET "feedbackUrl" = 'https://iceshrimp.dev/iceshrimp/iceshrimp/issues'`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `UPDATE meta SET "repositoryUrl" = 'https://codeberg.org/firefish/firefish'`, + ); + await queryRunner.query( + `UPDATE meta SET "feedbackUrl" = 'https://codeberg.org/firefish/firefish/issues'`, + ); + } +} diff --git a/packages/backend/src/migration/1695747439252-drop-reversi.ts b/packages/backend/src/migration/1695747439252-drop-reversi.ts new file mode 100644 index 0000000..2ba8e5c --- /dev/null +++ b/packages/backend/src/migration/1695747439252-drop-reversi.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class DropReversi1695747439252 implements MigrationInterface { + name = "DropReversi1695747439252"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "reversi_game"`); + await queryRunner.query(`DROP TABLE IF EXISTS "reversi_matching"`); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "reversi_game" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "startedAt" TIMESTAMP WITH TIME ZONE, "user1Id" character varying(32) NOT NULL, "user2Id" character varying(32) NOT NULL, "user1Accepted" boolean NOT NULL DEFAULT false, "user2Accepted" boolean NOT NULL DEFAULT false, "black" integer, "isStarted" boolean NOT NULL DEFAULT false, "isEnded" boolean NOT NULL DEFAULT false, "winnerId" character varying(32), "surrendered" character varying(32), "logs" jsonb NOT NULL DEFAULT '[]', "map" character varying(64) array NOT NULL, "bw" character varying(32) NOT NULL, "isLlotheo" boolean NOT NULL DEFAULT false, "canPutEverywhere" boolean NOT NULL DEFAULT false, "loopedBoard" boolean NOT NULL DEFAULT false, "form1" jsonb DEFAULT null, "form2" jsonb DEFAULT null, "crc32" character varying(32), CONSTRAINT "PK_76b30eeba71b1193ad7c5311c3f" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b46ec40746efceac604142be1c" ON "reversi_game" ("createdAt") `, + ); + await queryRunner.query( + `CREATE TABLE "reversi_matching" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "parentId" character varying(32) NOT NULL, "childId" character varying(32) NOT NULL, CONSTRAINT "PK_880bd0afbab232f21c8b9d146cf" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_b604d92d6c7aec38627f6eaf16" ON "reversi_matching" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_3b25402709dd9882048c2bbade" ON "reversi_matching" ("parentId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e247b23a3c9b45f89ec1299d06" ON "reversi_matching" ("childId") `, + ); + await queryRunner.query( + `ALTER TABLE "reversi_game" ADD CONSTRAINT "FK_f7467510c60a45ce5aca6292743" FOREIGN KEY ("user1Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "reversi_game" ADD CONSTRAINT "FK_6649a4e8c5d5cf32fb03b5da9f6" FOREIGN KEY ("user2Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "reversi_matching" ADD CONSTRAINT "FK_3b25402709dd9882048c2bbade0" FOREIGN KEY ("parentId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "reversi_matching" ADD CONSTRAINT "FK_e247b23a3c9b45f89ec1299d066" FOREIGN KEY ("childId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "reversi_game"."createdAt" IS 'The created date of the ReversiGame.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "reversi_game"."startedAt" IS 'The started date of the ReversiGame.'`, + ); + await queryRunner.query(`COMMENT ON COLUMN "reversi_game"."form1" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "reversi_game"."form2" IS NULL`); + await queryRunner.query( + `COMMENT ON COLUMN "reversi_matching"."createdAt" IS 'The created date of the ReversiMatching.'`, + ); + } +} diff --git a/packages/backend/src/migration/1695748502971-index-note-url.ts b/packages/backend/src/migration/1695748502971-index-note-url.ts new file mode 100644 index 0000000..2d7a546 --- /dev/null +++ b/packages/backend/src/migration/1695748502971-index-note-url.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class IndexNoteUrl1695748502971 implements MigrationInterface { + name = "IndexNoteUrl1695748502971"; + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_note_url" ON "note" ("url") `); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_note_url"`); + } +} diff --git a/packages/backend/src/migration/1695748874491-drop-ads.ts b/packages/backend/src/migration/1695748874491-drop-ads.ts new file mode 100644 index 0000000..ea4c834 --- /dev/null +++ b/packages/backend/src/migration/1695748874491-drop-ads.ts @@ -0,0 +1,24 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class DropAds1695748874491 implements MigrationInterface { + name = "DropAds1695748874491"; + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_2da24ce20ad209f1d9dc032457"`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_1129c2ef687fc272df040bafaa"`); + await queryRunner.query(`DROP TABLE IF EXISTS "ad"`); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "ad" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE NOT NULL, "place" character varying(32) NOT NULL, "priority" character varying(32) NOT NULL, "url" character varying(1024) NOT NULL, "imageUrl" character varying(1024) NOT NULL, "memo" character varying(8192) NOT NULL, CONSTRAINT "PK_0193d5ef09746e88e9ea92c634d" PRIMARY KEY ("id")); COMMENT ON COLUMN "ad"."createdAt" IS 'The created date of the Ad.'; COMMENT ON COLUMN "ad"."expiresAt" IS 'The expired date of the Ad.'`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_1129c2ef687fc272df040bafaa" ON "ad" ("createdAt") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_2da24ce20ad209f1d9dc032457" ON "ad" ("expiresAt") `, + ); + await queryRunner.query( + `ALTER TABLE "ad" ADD "ratio" integer NOT NULL DEFAULT '1'`, + ); + } +} diff --git a/packages/backend/src/migration/1695749386779-instance-account-domain-cleanup.ts b/packages/backend/src/migration/1695749386779-instance-account-domain-cleanup.ts new file mode 100644 index 0000000..3c2e688 --- /dev/null +++ b/packages/backend/src/migration/1695749386779-instance-account-domain-cleanup.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class InstanceAccountDomainCleanup1695749386779 implements MigrationInterface { + name = "InstanceAccountDomainCleanup1695749386779"; + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN IF EXISTS "accountDomain"`); + await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN IF EXISTS "account_domain"`); + } + + async down(queryRunner: QueryRunner): Promise { + // This migration is only here to ensure consistent state if upgrading from certain dev branch commits, skipping the final TypeORM migration. + // As such, there is no need to revert it. + } +} diff --git a/packages/backend/src/migration/1695749948350-move-antenna-to-cache.ts b/packages/backend/src/migration/1695749948350-move-antenna-to-cache.ts new file mode 100644 index 0000000..a6ea0ee --- /dev/null +++ b/packages/backend/src/migration/1695749948350-move-antenna-to-cache.ts @@ -0,0 +1,87 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MoveAntennaToCache1695749948350 implements MigrationInterface { + name = "MoveAntennaToCache1695749948350"; + async up(queryRunner: QueryRunner): Promise { + const tableExists = await queryRunner.query(`SELECT EXISTS ( SELECT 1 FROM pg_tables WHERE tablename = 'antenna_note' ) AS table_existence`) + .then(p => !!p[0]['table_existence']); + + if (!tableExists) { + console.log('Skipping migration ("antenna_note" table does not exist)'); + return; + } + + const skipCopy = process.env.ANTENNA_MIGRATION_SKIP === 'true'; + let readLimit = parseInt(process.env.ANTENNA_MIGRATION_READ_LIMIT ?? "10000", 10) ?? 10000; + + if (skipCopy) { + console.log('ANTENNA_MIGRATION_SKIP = true, skipping antenna note migration'); + } + else { + const { redisClient } = await import("../db/redis.js"); + const total = await queryRunner.query(`SELECT COUNT(1) FROM "antenna_note"`) + .then(p => p[0]['count']); + + console.log(`Copying ${total} entries in "antenna_note", please hang tight!`); + + let remaining = total; + + let query = `SELECT "id", "noteId", "antennaId" FROM "antenna_note" ORDER BY "id" ASC LIMIT ${readLimit}`; + + while (remaining > 0) { + let res = await queryRunner.query(query); + if (res.length === 0) break; + remaining -= res.length; + + for (const hit of res) { + redisClient.xadd(`antennaTimeline:${hit.antennaId}`, "MAXLEN", "~", "200", "*", "note", hit.noteId); + } + + console.log(`Copied ${total-remaining}/${total} notes to cache.`); + + query = `SELECT "id", "noteId", "antennaId" FROM "antenna_note" WHERE "id" > '${res.at(-1).id}' ORDER BY "id" ASC LIMIT ${Math.min(readLimit, remaining)}`; + } + + redisClient.quit(); + } + + await queryRunner.query(`DROP TABLE IF EXISTS "antenna_note"`); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE IF NOT EXISTS "antenna_note" ("id" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, "antennaId" character varying(32) NOT NULL, CONSTRAINT "PK_fb28d94d0989a3872df19fd6ef8" PRIMARY KEY ("id"))` + ); + await queryRunner.query( + `CREATE INDEX "IDX_bd0397be22147e17210940e125" ON "antenna_note" ("noteId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_0d775946662d2575dfd2068a5f" ON "antenna_note" ("antennaId") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_335a0bf3f904406f9ef3dd51c2" ON "antenna_note" ("noteId", "antennaId") `, + ); + await queryRunner.query( + `ALTER TABLE "antenna_note" ADD CONSTRAINT "FK_bd0397be22147e17210940e125b" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna_note" ADD CONSTRAINT "FK_0d775946662d2575dfd2068a5f5" FOREIGN KEY ("antennaId") REFERENCES "antenna"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + undefined, + ); + await queryRunner.query( + `ALTER TABLE "antenna_note" ADD "read" boolean NOT NULL DEFAULT false`, + undefined, + ); + await queryRunner.query( + `CREATE INDEX "IDX_9937ea48d7ae97ffb4f3f063a4" ON "antenna_note" ("read") `, + undefined, + ); + await queryRunner.query( + `COMMENT ON COLUMN "antenna_note"."noteId" IS 'The note ID.'`, + ); + await queryRunner.query( + `COMMENT ON COLUMN "antenna_note"."antennaId" IS 'The antenna ID.'`, + ); + } +} diff --git a/packages/backend/src/migration/1695861526125-index-note-userid.ts b/packages/backend/src/migration/1695861526125-index-note-userid.ts new file mode 100644 index 0000000..b3bb4ed --- /dev/null +++ b/packages/backend/src/migration/1695861526125-index-note-userid.ts @@ -0,0 +1,11 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; +export class IndexNoteUserId1695861526125 implements MigrationInterface { + name = "IndexNoteUserId1695861526125"; + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE INDEX "IDX_note_userId_id" ON "note" ("userId", "id")`); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_note_userId_id"`); + } +} diff --git a/packages/backend/src/migration/1697216726757-auto-generate-vapid-keys.ts b/packages/backend/src/migration/1697216726757-auto-generate-vapid-keys.ts new file mode 100644 index 0000000..a415e04 --- /dev/null +++ b/packages/backend/src/migration/1697216726757-auto-generate-vapid-keys.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm" +import push from 'web-push'; + +export class AutoGenerateVapidKeys1697216726757 implements MigrationInterface { + // Based on FoundKey's 1668374092227-forceEnablePush.js + name = 'AutoGenerateVapidKeys1697216726757'; + + public async up(queryRunner: QueryRunner): Promise { + // set VAPID keys if not yet set + const { publicKey, privateKey } = push.generateVAPIDKeys(); + await queryRunner.query(`UPDATE "meta" SET "swPublicKey" = $1, "swPrivateKey" = $2 WHERE "swPublicKey" IS NULL OR "swPrivateKey" IS NULL`, [publicKey, privateKey]); + + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableServiceWorker"`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "swPublicKey" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "swPrivateKey" SET NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "swPrivateKey" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "swPublicKey" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "meta" ADD "enableServiceWorker" boolean NOT NULL DEFAULT false`); + // since VAPID keys are set and the service worker may have been enabled before, make sure it is now enabled + await queryRunner.query(`UPDATE "meta" SET "enableServiceWorker" = true`); + // can't unset the VAPID keys because we do not know if we set them in the migration + } +} diff --git a/packages/backend/src/migration/1697226201723-add-oauth-tables.ts b/packages/backend/src/migration/1697226201723-add-oauth-tables.ts new file mode 100644 index 0000000..e4895cd --- /dev/null +++ b/packages/backend/src/migration/1697226201723-add-oauth-tables.ts @@ -0,0 +1,26 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddOAuthTables1697226201723 implements MigrationInterface { + name = 'AddOAuthTables1697226201723' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "oauth_app" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "clientId" character varying(64) NOT NULL, "clientSecret" character varying(64) NOT NULL, "name" character varying(128) NOT NULL, "website" character varying(256), "scopes" character varying(64) array NOT NULL, "redirectUris" character varying(64) array NOT NULL, CONSTRAINT "PK_3256b97c0a3ee2d67240805dca4" PRIMARY KEY ("id")); COMMENT ON COLUMN "oauth_app"."createdAt" IS 'The created date of the OAuth application'; COMMENT ON COLUMN "oauth_app"."clientId" IS 'The client id of the OAuth application'; COMMENT ON COLUMN "oauth_app"."clientSecret" IS 'The client secret of the OAuth application'; COMMENT ON COLUMN "oauth_app"."name" IS 'The name of the OAuth application'; COMMENT ON COLUMN "oauth_app"."website" IS 'The website of the OAuth application'; COMMENT ON COLUMN "oauth_app"."scopes" IS 'The scopes requested by the OAuth application'; COMMENT ON COLUMN "oauth_app"."redirectUris" IS 'The redirect URIs of the OAuth application'`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_65b61f406c811241e1315a2f82" ON "oauth_app" ("clientId") `); + await queryRunner.query(`CREATE TABLE "oauth_token" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "appId" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "code" character varying(64) NOT NULL, "token" character varying(64) NOT NULL, "active" boolean NOT NULL, "scopes" character varying(64) array NOT NULL, "redirectUri" character varying(64) NOT NULL, CONSTRAINT "PK_7e6a25a3cc4395d1658f5b89c73" PRIMARY KEY ("id")); COMMENT ON COLUMN "oauth_token"."createdAt" IS 'The created date of the OAuth token'; COMMENT ON COLUMN "oauth_token"."code" IS 'The auth code for the OAuth token'; COMMENT ON COLUMN "oauth_token"."token" IS 'The OAuth token'; COMMENT ON COLUMN "oauth_token"."active" IS 'Whether or not the token has been activated'; COMMENT ON COLUMN "oauth_token"."scopes" IS 'The scopes requested by the OAuth token'; COMMENT ON COLUMN "oauth_token"."redirectUri" IS 'The redirect URI of the OAuth token'`); + await queryRunner.query(`CREATE INDEX "IDX_dc5fe174a8b59025055f0ec136" ON "oauth_token" ("code") `); + await queryRunner.query(`CREATE INDEX "IDX_2cbeb4b389444bcf4379ef4273" ON "oauth_token" ("token") `); + await queryRunner.query(`ALTER TABLE "oauth_token" ADD CONSTRAINT "FK_6d3ef28ea647b1449ba79690874" FOREIGN KEY ("appId") REFERENCES "oauth_app"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "oauth_token" ADD CONSTRAINT "FK_f6b4b1ac66b753feab5d831ba04" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "oauth_token" DROP CONSTRAINT "FK_f6b4b1ac66b753feab5d831ba04"`); + await queryRunner.query(`ALTER TABLE "oauth_token" DROP CONSTRAINT "FK_6d3ef28ea647b1449ba79690874"`); + await queryRunner.query(`DROP INDEX "public"."IDX_2cbeb4b389444bcf4379ef4273"`); + await queryRunner.query(`DROP INDEX "public"."IDX_dc5fe174a8b59025055f0ec136"`); + await queryRunner.query(`DROP TABLE "oauth_token"`); + await queryRunner.query(`DROP INDEX "public"."IDX_65b61f406c811241e1315a2f82"`); + await queryRunner.query(`DROP TABLE "oauth_app"`); + } + +} diff --git a/packages/backend/src/migration/1697246035867-increase-oauth-redirecturis-length.ts b/packages/backend/src/migration/1697246035867-increase-oauth-redirecturis-length.ts new file mode 100644 index 0000000..f88d3bc --- /dev/null +++ b/packages/backend/src/migration/1697246035867-increase-oauth-redirecturis-length.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class IncreaseOAuthRedirecturisLength1697246035867 implements MigrationInterface { + name = 'IncreaseOAuthRedirecturisLength1697246035867' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "oauth_app" ALTER "redirectUris" TYPE character varying(512) array`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "oauth_app" ALTER "redirectUris" TYPE character varying(64) array`); + } +} diff --git a/packages/backend/src/migration/1697286869039-increase-oauth-token-redirecturis-length.ts b/packages/backend/src/migration/1697286869039-increase-oauth-token-redirecturis-length.ts new file mode 100644 index 0000000..e6fe1e7 --- /dev/null +++ b/packages/backend/src/migration/1697286869039-increase-oauth-token-redirecturis-length.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class IncreaseOauthTokenRedirecturisLength1697286869039 implements MigrationInterface { + name = 'IncreaseOauthTokenRedirecturisLength1697286869039' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "oauth_token" ALTER "redirectUri" TYPE character varying(512)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "oauth_token" ALTER "redirectUri" TYPE character varying(64)`); + } + +} diff --git a/packages/backend/src/migration/1697289658422-resync-with-orm.ts b/packages/backend/src/migration/1697289658422-resync-with-orm.ts new file mode 100644 index 0000000..aca3d8a --- /dev/null +++ b/packages/backend/src/migration/1697289658422-resync-with-orm.ts @@ -0,0 +1,107 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ResyncWithOrm1697289658422 implements MigrationInterface { + name = 'ResyncWithOrm1697289658422' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT IF EXISTS "fk_7f4e851a35d81b64dda28eee0"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_renote_muting_createdAt"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_renote_muting_muteeId"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_renote_muting_muterId"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "useStarForReactionFallback"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableGuestTimeline"`); + await queryRunner.query(`COMMENT ON COLUMN "notification"."isRead" IS 'Whether the notification was read.'`); + await queryRunner.query(`COMMENT ON COLUMN "meta"."defaultReaction" IS NULL`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "secureMode" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "privateMode" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "allowedHosts" SET NOT NULL`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "pinnedPages" SET DEFAULT '{/featured,/channels,/explore,/pages,/about-iceshrimp}'`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "repositoryUrl" SET DEFAULT 'https://iceshrimp.dev/iceshrimp/iceshrimp'`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "feedbackUrl" SET DEFAULT 'https://iceshrimp.dev/iceshrimp/iceshrimp/issues/new'`); + await queryRunner.query(`COMMENT ON COLUMN "renote_muting"."createdAt" IS 'The created date of the Muting.'`); + await queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muteeId" IS 'The mutee user ID.'`); + await queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muterId" IS 'The muter user ID.'`); + await queryRunner.query(`ALTER TABLE "poll" DROP CONSTRAINT IF EXISTS "FK_da851e06d0dfe2ef397d8b1bf1b"`); + await queryRunner.query(`ALTER TABLE "poll" DROP CONSTRAINT IF EXISTS "UQ_da851e06d0dfe2ef397d8b1bf1b"`); + await queryRunner.query(`ALTER TYPE "public"."poll_notevisibility_enum" RENAME TO "poll_notevisibility_enum_old"`); + await queryRunner.query(`CREATE TYPE "public"."poll_notevisibility_enum" AS ENUM('public', 'home', 'followers', 'specified', 'hidden')`); + await queryRunner.query(`ALTER TABLE "poll" ALTER COLUMN "noteVisibility" TYPE "public"."poll_notevisibility_enum" USING "noteVisibility"::"text"::"public"."poll_notevisibility_enum"`); + await queryRunner.query(`DROP TYPE "public"."poll_notevisibility_enum_old"`); + await queryRunner.query(`ALTER TABLE "user_keypair" DROP CONSTRAINT IF EXISTS "FK_f4853eb41ab722fe05f81cedeb6"`); + await queryRunner.query(`ALTER TABLE "user_keypair" DROP CONSTRAINT IF EXISTS "UQ_f4853eb41ab722fe05f81cedeb6"`); + await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT IF EXISTS "FK_10c146e4b39b443ede016f6736d"`); + await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT IF EXISTS "UQ_10c146e4b39b443ede016f6736d"`); + await queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "isPublic" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "user_profile" DROP CONSTRAINT IF EXISTS "FK_51cb79b5555effaf7d69ba1cff9"`); + await queryRunner.query(`ALTER TABLE "user_profile" DROP CONSTRAINT IF EXISTS "UQ_51cb79b5555effaf7d69ba1cff9"`); + await queryRunner.query(`ALTER TABLE "promo_note" DROP CONSTRAINT IF EXISTS "FK_e263909ca4fe5d57f8d4230dd5c"`); + await queryRunner.query(`ALTER TABLE "promo_note" DROP CONSTRAINT IF EXISTS "UQ_e263909ca4fe5d57f8d4230dd5c"`); + await queryRunner.query(`ALTER TABLE "renote_muting" DROP CONSTRAINT IF EXISTS "FK_7eac97594bcac5ffcf2068089b6"`); + await queryRunner.query(`ALTER TABLE "renote_muting" DROP CONSTRAINT IF EXISTS "FK_7aa72a5fe76019bfe8e5e0e8b7d"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT IF EXISTS "FK_a9021cc2e1feb5f72d3db6e9f5f"`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_d1259a2c2b7bb413ff449e8711" ON "renote_muting" ("createdAt") `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_7eac97594bcac5ffcf2068089b" ON "renote_muting" ("muteeId") `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_7aa72a5fe76019bfe8e5e0e8b7" ON "renote_muting" ("muterId") `); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_0d801c609cec4e9eb4b6b4490c" ON "renote_muting" ("muterId", "muteeId") `); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_a9021cc2e1feb5f72d3db6e9f5" ON "abuse_user_report" ("targetUserId") `); + await queryRunner.query(`DELETE FROM "renote_muting" WHERE NOT EXISTS (select 1 from "user" where "user"."id" = "renote_muting"."muterId")`); + await queryRunner.query(`DELETE FROM "renote_muting" WHERE NOT EXISTS (select 1 from "user" where "user"."id" = "renote_muting"."muteeId")`); + await queryRunner.query(`ALTER TABLE "renote_muting" ADD CONSTRAINT "FK_7eac97594bcac5ffcf2068089b6" FOREIGN KEY ("muteeId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "renote_muting" ADD CONSTRAINT "FK_7aa72a5fe76019bfe8e5e0e8b7d" FOREIGN KEY ("muterId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "poll" ADD CONSTRAINT "FK_da851e06d0dfe2ef397d8b1bf1b" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_keypair" ADD CONSTRAINT "FK_f4853eb41ab722fe05f81cedeb6" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "FK_10c146e4b39b443ede016f6736d" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_profile" ADD CONSTRAINT "FK_51cb79b5555effaf7d69ba1cff9" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "promo_note" ADD CONSTRAINT "FK_e263909ca4fe5d57f8d4230dd5c" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "promo_note" DROP CONSTRAINT "FK_e263909ca4fe5d57f8d4230dd5c"`); + await queryRunner.query(`ALTER TABLE "user_profile" DROP CONSTRAINT "FK_51cb79b5555effaf7d69ba1cff9"`); + await queryRunner.query(`ALTER TABLE "user_publickey" DROP CONSTRAINT "FK_10c146e4b39b443ede016f6736d"`); + await queryRunner.query(`ALTER TABLE "user_keypair" DROP CONSTRAINT "FK_f4853eb41ab722fe05f81cedeb6"`); + await queryRunner.query(`ALTER TABLE "poll" DROP CONSTRAINT "FK_da851e06d0dfe2ef397d8b1bf1b"`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" DROP CONSTRAINT "FK_a9021cc2e1feb5f72d3db6e9f5f"`); + await queryRunner.query(`ALTER TABLE "renote_muting" DROP CONSTRAINT "FK_7aa72a5fe76019bfe8e5e0e8b7d"`); + await queryRunner.query(`ALTER TABLE "renote_muting" DROP CONSTRAINT "FK_7eac97594bcac5ffcf2068089b6"`); + await queryRunner.query(`DROP INDEX "public"."IDX_a9021cc2e1feb5f72d3db6e9f5"`); + await queryRunner.query(`DROP INDEX "public"."IDX_0d801c609cec4e9eb4b6b4490c"`); + await queryRunner.query(`DROP INDEX "public"."IDX_7aa72a5fe76019bfe8e5e0e8b7"`); + await queryRunner.query(`DROP INDEX "public"."IDX_7eac97594bcac5ffcf2068089b"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d1259a2c2b7bb413ff449e8711"`); + await queryRunner.query(`ALTER TABLE "promo_note" ADD CONSTRAINT "UQ_e263909ca4fe5d57f8d4230dd5c" UNIQUE ("noteId")`); + await queryRunner.query(`ALTER TABLE "promo_note" ADD CONSTRAINT "FK_e263909ca4fe5d57f8d4230dd5c" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_profile" ADD CONSTRAINT "UQ_51cb79b5555effaf7d69ba1cff9" UNIQUE ("userId")`); + await queryRunner.query(`ALTER TABLE "user_profile" ADD CONSTRAINT "FK_51cb79b5555effaf7d69ba1cff9" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "page" ALTER COLUMN "isPublic" SET DEFAULT true`); + await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "UQ_10c146e4b39b443ede016f6736d" UNIQUE ("userId")`); + await queryRunner.query(`ALTER TABLE "user_publickey" ADD CONSTRAINT "FK_10c146e4b39b443ede016f6736d" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "user_keypair" ADD CONSTRAINT "UQ_f4853eb41ab722fe05f81cedeb6" UNIQUE ("userId")`); + await queryRunner.query(`ALTER TABLE "user_keypair" ADD CONSTRAINT "FK_f4853eb41ab722fe05f81cedeb6" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`CREATE TYPE "public"."poll_notevisibility_enum_old" AS ENUM('public', 'home', 'followers', 'specified')`); + await queryRunner.query(`ALTER TABLE "poll" ALTER COLUMN "noteVisibility" TYPE "public"."poll_notevisibility_enum_old" USING "noteVisibility"::"text"::"public"."poll_notevisibility_enum_old"`); + await queryRunner.query(`DROP TYPE "public"."poll_notevisibility_enum"`); + await queryRunner.query(`ALTER TYPE "public"."poll_notevisibility_enum_old" RENAME TO "poll_notevisibility_enum"`); + await queryRunner.query(`ALTER TABLE "poll" ADD CONSTRAINT "UQ_da851e06d0dfe2ef397d8b1bf1b" UNIQUE ("noteId")`); + await queryRunner.query(`ALTER TABLE "poll" ADD CONSTRAINT "FK_da851e06d0dfe2ef397d8b1bf1b" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muterId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "renote_muting"."muteeId" IS NULL`); + await queryRunner.query(`COMMENT ON COLUMN "renote_muting"."createdAt" IS NULL`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "feedbackUrl" SET DEFAULT 'https://codeberg.org/firefish/firefish/issues'`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "repositoryUrl" SET DEFAULT 'https://codeberg.org/firefish/firefish'`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "pinnedPages" SET DEFAULT '{/featured,/channels,/explore,/pages,/about-misskey}'`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "allowedHosts" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "privateMode" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "secureMode" DROP NOT NULL`); + await queryRunner.query(`COMMENT ON COLUMN "meta"."defaultReaction" IS 'The fallback reaction for emoji reacts'`); + await queryRunner.query(`COMMENT ON COLUMN "notification"."isRead" IS 'Whether the Notification is read.'`); + await queryRunner.query(`ALTER TABLE "meta" ADD "enableGuestTimeline" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "meta" ADD "useStarForReactionFallback" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId") `); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId") `); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt") `); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ADD CONSTRAINT "fk_7f4e851a35d81b64dda28eee0" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + +} diff --git a/packages/backend/src/migration/1697302438587-add-user-profile-mentions.ts b/packages/backend/src/migration/1697302438587-add-user-profile-mentions.ts new file mode 100644 index 0000000..a6c1ae9 --- /dev/null +++ b/packages/backend/src/migration/1697302438587-add-user-profile-mentions.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUserProfileMentions1697302438587 implements MigrationInterface { + name = 'AddUserProfileMentions1697302438587' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "mentions" jsonb NOT NULL DEFAULT '[]'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "mentions"`); + } +} diff --git a/packages/backend/src/migration/1697649475796-secure-mode-defaults.ts b/packages/backend/src/migration/1697649475796-secure-mode-defaults.ts new file mode 100644 index 0000000..0af5592 --- /dev/null +++ b/packages/backend/src/migration/1697649475796-secure-mode-defaults.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class SecureModeDefaults1697649475796 implements MigrationInterface { + name = 'SecureModeDefaults1697649475796' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "secureMode" SET DEFAULT true`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "secureMode" SET DEFAULT false`); + } +} diff --git a/packages/backend/src/migration/1697663824168-remote-nsfw-detection.ts b/packages/backend/src/migration/1697663824168-remote-nsfw-detection.ts new file mode 100644 index 0000000..a77dce9 --- /dev/null +++ b/packages/backend/src/migration/1697663824168-remote-nsfw-detection.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RemoveNsfwDetection1697663824168 implements MigrationInterface { + name = 'RemoveNsfwDetection1697663824168' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_3b33dff77bb64b23c88151d23e"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_8bdcd3dd2bddb78014999a16ce"`); + await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN IF EXISTS "maybeSensitive"`); + await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN IF EXISTS "maybePorn"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN IF EXISTS "sensitiveMediaDetection"`); + await queryRunner.query(`DROP TYPE IF EXISTS "public"."meta_sensitivemediadetection_enum"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN IF EXISTS "sensitiveMediaDetectionSensitivity"`); + await queryRunner.query(`DROP TYPE IF EXISTS "public"."meta_sensitivemediadetectionsensitivity_enum"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN IF EXISTS "setSensitiveFlagAutomatically"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN IF EXISTS "enableSensitiveMediaDetectionForVideos"`); + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN IF EXISTS "autoSensitive"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "autoSensitive" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "meta" ADD "enableSensitiveMediaDetectionForVideos" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "meta" ADD "setSensitiveFlagAutomatically" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`CREATE TYPE "public"."meta_sensitivemediadetectionsensitivity_enum" AS ENUM('medium', 'low', 'high', 'veryLow', 'veryHigh')`); + await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveMediaDetectionSensitivity" "public"."meta_sensitivemediadetectionsensitivity_enum" NOT NULL DEFAULT 'medium'`); + await queryRunner.query(`CREATE TYPE "public"."meta_sensitivemediadetection_enum" AS ENUM('none', 'all', 'local', 'remote')`); + await queryRunner.query(`ALTER TABLE "meta" ADD "sensitiveMediaDetection" "public"."meta_sensitivemediadetection_enum" NOT NULL DEFAULT 'none'`); + await queryRunner.query(`ALTER TABLE "drive_file" ADD "maybePorn" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "drive_file" ADD "maybeSensitive" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`CREATE INDEX "IDX_8bdcd3dd2bddb78014999a16ce" ON "drive_file" ("maybePorn") `); + await queryRunner.query(`CREATE INDEX "IDX_3b33dff77bb64b23c88151d23e" ON "drive_file" ("maybeSensitive") `); + } +} diff --git a/packages/backend/src/migration/1697665612162-remove-proxy-account.ts b/packages/backend/src/migration/1697665612162-remove-proxy-account.ts new file mode 100644 index 0000000..ff4d905 --- /dev/null +++ b/packages/backend/src/migration/1697665612162-remove-proxy-account.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RemoveProxyAccount1697665612162 implements MigrationInterface { + name = 'RemoveProxyAccount1697665612162' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" DROP CONSTRAINT IF EXISTS "FK_ab1bc0c1e209daa77b8e8d212ad"`); + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN IF EXISTS "proxyAccountId"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" ADD "proxyAccountId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "meta" ADD CONSTRAINT "FK_ab1bc0c1e209daa77b8e8d212ad" FOREIGN KEY ("proxyAccountId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION`); + } +} diff --git a/packages/backend/src/migration/1697730891701-remove-unfollowed-users-from-lists.ts b/packages/backend/src/migration/1697730891701-remove-unfollowed-users-from-lists.ts new file mode 100644 index 0000000..4e49e33 --- /dev/null +++ b/packages/backend/src/migration/1697730891701-remove-unfollowed-users-from-lists.ts @@ -0,0 +1,16 @@ +// This migration is currently inactive. +// It will be activated in the next stable release after the first one that includes this file, +// to make sure users have enough time to migrate their unfollowed list members to follows. + +/* +import { MigrationInterface, QueryRunner } from "typeorm" + +export class RemoveUnfollowedUsersFromLists1697730891701 implements MigrationInterface { + name = "RemoveUnfollowedUsersFromLists1697730891701"; + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM "user_list_joining" USING "user_list_joining" AS "member" INNER JOIN "user_list" "list" ON "member"."userListId" = "list"."id" WHERE "user_list_joining"."id" = "member"."id" AND "member"."userId" <> "list"."userId" AND "member"."userId" NOT IN (SELECT "followeeId" FROM "following" WHERE "following"."followerId" = "list"."userId")`); + } + + public async down(_queryRunner: QueryRunner): Promise {} +} + */ \ No newline at end of file diff --git a/packages/backend/src/migration/1697733603329-user-list-options.ts b/packages/backend/src/migration/1697733603329-user-list-options.ts new file mode 100644 index 0000000..ecc2f1c --- /dev/null +++ b/packages/backend/src/migration/1697733603329-user-list-options.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UserListOptions1697733603329 implements MigrationInterface { + name = 'UserListOptions1697733603329' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_list" ADD "hideFromHomeTl" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`COMMENT ON COLUMN "user_list"."hideFromHomeTl" IS 'Whether posts from list members should be hidden from the home timeline.'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`COMMENT ON COLUMN "user_list"."hideFromHomeTl" IS 'Whether posts from list members should be hidden from the home timeline.'`); + await queryRunner.query(`ALTER TABLE "user_list" DROP COLUMN "hideFromHomeTl"`); + } +} diff --git a/packages/backend/src/migration/1700331070890-note-text-fts-idx.ts b/packages/backend/src/migration/1700331070890-note-text-fts-idx.ts new file mode 100644 index 0000000..b0f6dfe --- /dev/null +++ b/packages/backend/src/migration/1700331070890-note-text-fts-idx.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class NoteTextFtsIdx1700331070890 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS pg_trgm`); + const total = await queryRunner.query(`SELECT COUNT(*) FROM "note"`); + if (total && total.length > 0) { + const count = BigInt(total[0].count); + console.log(`Indexing the "note" table for full text search, please hang tight!`); + console.log(`You have ${count} notes in your database. This process will take an estimated ${count / 1000000n * 45n} seconds, though the exact duration depends on your hardware configuration.`); + } + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "note_text_fts_idx" ON "note" USING gin ("text" gin_trgm_ops)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "note_text_fts_idx"`); + await queryRunner.query(`DROP EXTENSION IF EXISTS pg_trgm`); + } +} diff --git a/packages/backend/src/migration/1700517975122-drive-file-url.ts b/packages/backend/src/migration/1700517975122-drive-file-url.ts new file mode 100644 index 0000000..296136d --- /dev/null +++ b/packages/backend/src/migration/1700517975122-drive-file-url.ts @@ -0,0 +1,32 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UserAvatarBannerRefactor1700517975122 implements MigrationInterface { + name = 'UserAvatarBannerRefactor1700517975122' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" ADD "avatarUrl" character varying(512)`); + await queryRunner.query(`COMMENT ON COLUMN "user"."avatarUrl" IS 'The URL of the avatar DriveFile'`); + await queryRunner.query(`ALTER TABLE "user" ADD "avatarBlurhash" character varying(128)`); + await queryRunner.query(`COMMENT ON COLUMN "user"."avatarBlurhash" IS 'The blurhash of the avatar DriveFile'`); + await queryRunner.query(`ALTER TABLE "user" ADD "bannerUrl" character varying(512)`); + await queryRunner.query(`COMMENT ON COLUMN "user"."bannerUrl" IS 'The URL of the banner DriveFile'`); + await queryRunner.query(`ALTER TABLE "user" ADD "bannerBlurhash" character varying(128)`); + await queryRunner.query(`COMMENT ON COLUMN "user"."bannerBlurhash" IS 'The blurhash of the banner DriveFile'`); + + await queryRunner.query(`UPDATE "user" SET "avatarUrl" = (SELECT COALESCE("thumbnailUrl", "webpublicUrl", "url") FROM "drive_file" WHERE "id" = "user"."avatarId") WHERE "avatarId" IS NOT NULL`); + await queryRunner.query(`UPDATE "user" SET "avatarBlurhash" = (SELECT "blurhash" FROM "drive_file" WHERE "id" = "user"."avatarId") WHERE "avatarId" IS NOT NULL`); + await queryRunner.query(`UPDATE "user" SET "bannerUrl" = (SELECT COALESCE("webpublicUrl", "url") FROM "drive_file" WHERE "id" = "user"."bannerId") WHERE "bannerId" IS NOT NULL`); + await queryRunner.query(`UPDATE "user" SET "bannerBlurhash" = (SELECT "blurhash" FROM "drive_file" WHERE "id" = "user"."bannerId") WHERE "bannerId" IS NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`COMMENT ON COLUMN "user"."bannerBlurhash" IS 'The blurhash of the banner DriveFile'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerBlurhash"`); + await queryRunner.query(`COMMENT ON COLUMN "user"."bannerUrl" IS 'The URL of the banner DriveFile'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "bannerUrl"`); + await queryRunner.query(`COMMENT ON COLUMN "user"."avatarBlurhash" IS 'The blurhash of the avatar DriveFile'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarBlurhash"`); + await queryRunner.query(`COMMENT ON COLUMN "user"."avatarUrl" IS 'The URL of the avatar DriveFile'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "avatarUrl"`); + } +} diff --git a/packages/backend/src/migration/1700623165718-add-note-userhost-id-idx.ts b/packages/backend/src/migration/1700623165718-add-note-userhost-id-idx.ts new file mode 100644 index 0000000..2ba5759 --- /dev/null +++ b/packages/backend/src/migration/1700623165718-add-note-userhost-id-idx.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddNoteIdUserhostIdx1700623165718 implements MigrationInterface { + name = 'AddNoteIdUserhostIdx1700623165718' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE INDEX "IDX_note_id_userHost" ON "note" ("id", "userHost") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_note_id_userHost"`); + } +} diff --git a/packages/backend/src/migration/1700686908916-add-autofollowed-account.ts b/packages/backend/src/migration/1700686908916-add-autofollowed-account.ts new file mode 100644 index 0000000..24b7659 --- /dev/null +++ b/packages/backend/src/migration/1700686908916-add-autofollowed-account.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddAutofollowedAccount1700686908916 implements MigrationInterface { + name = "AddAutofollowedAccount1700686908916"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD "autofollowedAccount" character varying(128)`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN "autofollowedAccount"`, + ); + } +} diff --git a/packages/backend/src/migration/1700962939886-add-html-cache.ts b/packages/backend/src/migration/1700962939886-add-html-cache.ts new file mode 100644 index 0000000..ee90725 --- /dev/null +++ b/packages/backend/src/migration/1700962939886-add-html-cache.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddHtmlCache1700962939886 implements MigrationInterface { + name = 'AddHtmlCache1700962939886' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "html_note_cache_entry" ("noteId" character varying(32) NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE, "content" text, CONSTRAINT "PK_6ef86ec901b2017cbe82d3a8286" PRIMARY KEY ("noteId"))`); + await queryRunner.query(`CREATE TABLE "html_user_cache_entry" ("userId" character varying(32) NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE, "bio" text, "fields" jsonb NOT NULL DEFAULT '[]', CONSTRAINT "PK_920b9474e3c9cae3f3c37c057e1" PRIMARY KEY ("userId"))`); + await queryRunner.query(`ALTER TABLE "html_note_cache_entry" ADD CONSTRAINT "FK_6ef86ec901b2017cbe82d3a8286" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "html_user_cache_entry" ADD CONSTRAINT "FK_920b9474e3c9cae3f3c37c057e1" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "html_user_cache_entry" DROP CONSTRAINT "FK_920b9474e3c9cae3f3c37c057e1"`); + await queryRunner.query(`ALTER TABLE "html_note_cache_entry" DROP CONSTRAINT "FK_6ef86ec901b2017cbe82d3a8286"`); + await queryRunner.query(`DROP TABLE "html_user_cache_entry"`); + await queryRunner.query(`DROP TABLE "html_note_cache_entry"`); + } + +} diff --git a/packages/backend/src/migration/1701069578019-remove-twitter-integration.ts b/packages/backend/src/migration/1701069578019-remove-twitter-integration.ts new file mode 100644 index 0000000..f010162 --- /dev/null +++ b/packages/backend/src/migration/1701069578019-remove-twitter-integration.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RemoveTwitterIntegration1701069578019 implements MigrationInterface { + name = "RemoveTwitterIntegration1701069578019"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN IF EXISTS "enableTwitterIntegration"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN IF EXISTS "twitterConsumerKey"`, + ); + await queryRunner.query( + `ALTER TABLE "meta" DROP COLUMN IF EXISTS "twitterConsumerSecret"`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "meta" ADD COLUMN IF NOT EXISTS "enableTwitterIntegration" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD COLUMN IF NOT EXISTS "twitterConsumerKey" character varying(128)`, + ); + await queryRunner.query( + `ALTER TABLE "meta" ADD COLUMN IF NOT EXISTS "twitterConsumerSecret" character varying(128)`, + ); + } +} diff --git a/packages/backend/src/migration/1701108527387-rework-hard-mutes.ts b/packages/backend/src/migration/1701108527387-rework-hard-mutes.ts new file mode 100644 index 0000000..d115f1a --- /dev/null +++ b/packages/backend/src/migration/1701108527387-rework-hard-mutes.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ReworkHardMutes1701108527387 implements MigrationInterface { + name = 'ReworkHardMutes1701108527387' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "muted_note" DROP CONSTRAINT "FK_d8e07aa18c2d64e86201601aec1"`); + await queryRunner.query(`ALTER TABLE "muted_note" DROP CONSTRAINT "FK_70ab9786313d78e4201d81cdb89"`); + await queryRunner.query(`DROP INDEX "public"."IDX_a8c6bfd637d3f1d67a27c48e27"`); + await queryRunner.query(`DROP INDEX "public"."IDX_636e977ff90b23676fb5624b25"`); + await queryRunner.query(`DROP INDEX "public"."IDX_d8e07aa18c2d64e86201601aec"`); + await queryRunner.query(`DROP INDEX "public"."IDX_70ab9786313d78e4201d81cdb8"`); + await queryRunner.query(`DROP TABLE "muted_note"`); + await queryRunner.query(`DROP TYPE "public"."muted_note_reason_enum"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."muted_note_reason_enum" AS ENUM('word', 'manual', 'spam', 'other')`); + await queryRunner.query(`CREATE TABLE "muted_note" ("id" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "reason" "public"."muted_note_reason_enum" NOT NULL, CONSTRAINT "PK_897e2eff1c0b9b64e55ca1418a4" PRIMARY KEY ("id")); COMMENT ON COLUMN "muted_note"."noteId" IS 'The note ID.'; COMMENT ON COLUMN "muted_note"."userId" IS 'The user ID.'; COMMENT ON COLUMN "muted_note"."reason" IS 'The reason of the MutedNote.'`); + await queryRunner.query(`CREATE INDEX "IDX_70ab9786313d78e4201d81cdb8" ON "muted_note" ("noteId") `); + await queryRunner.query(`CREATE INDEX "IDX_d8e07aa18c2d64e86201601aec" ON "muted_note" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_636e977ff90b23676fb5624b25" ON "muted_note" ("reason") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_a8c6bfd637d3f1d67a27c48e27" ON "muted_note" ("noteId", "userId") `); + await queryRunner.query(`ALTER TABLE "muted_note" ADD CONSTRAINT "FK_70ab9786313d78e4201d81cdb89" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "muted_note" ADD CONSTRAINT "FK_d8e07aa18c2d64e86201601aec1" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } +} diff --git a/packages/backend/src/migration/1701118152149-increase-host-char-limit.ts b/packages/backend/src/migration/1701118152149-increase-host-char-limit.ts new file mode 100644 index 0000000..5703a4c --- /dev/null +++ b/packages/backend/src/migration/1701118152149-increase-host-char-limit.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from "typeorm" + +export class IncreaseHostCharLimit1701118152149 implements MigrationInterface { + name = 'IncreaseHostCharLimit1701118152149'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "drive_file" ALTER COLUMN "userHost" TYPE character varying(512)`); + await queryRunner.query(`ALTER TABLE "user" ALTER COLUMN "host" TYPE character varying(512)`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "userHost" TYPE character varying(512)`); + await queryRunner.query(`ALTER TABLE "user_publickey" ALTER COLUMN "keyId" TYPE character varying(512)`); + await queryRunner.query(`ALTER TABLE "emoji" ALTER COLUMN "host" TYPE character varying(512)`); + await queryRunner.query(`ALTER TABLE "note" ALTER COLUMN "userHost" TYPE character varying(512)`); + await queryRunner.query(`ALTER TABLE "note" ALTER COLUMN "replyUserHost" TYPE character varying(512)`); + await queryRunner.query(`ALTER TABLE "note" ALTER COLUMN "renoteUserHost" TYPE character varying(512)`); + await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "host" TYPE character varying(512)`); + await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "iconUrl" TYPE character varying(4096)`); + await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "faviconUrl" TYPE character varying(4096)`); + await queryRunner.query(`ALTER TABLE "poll" ALTER COLUMN "userHost" TYPE character varying(512)`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ALTER COLUMN "targetUserHost" TYPE character varying(512)`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ALTER COLUMN "reporterHost" TYPE character varying(512)`); + await queryRunner.query(`ALTER TABLE "following" ALTER COLUMN "followeeHost" TYPE character varying(512)`); + await queryRunner.query(`ALTER TABLE "following" ALTER COLUMN "followerHost" TYPE character varying(512)`); + await queryRunner.query(`ALTER TABLE "follow_request" ALTER COLUMN "followeeHost" TYPE character varying(512)`); + await queryRunner.query(`ALTER TABLE "follow_request" ALTER COLUMN "followerHost" TYPE character varying(512)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "drive_file" ALTER COLUMN "userHost" TYPE character varying(128)`); + await queryRunner.query(`ALTER TABLE "user" ALTER COLUMN "host" TYPE character varying(128)`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "userHost" TYPE character varying(128)`); + await queryRunner.query(`ALTER TABLE "user_publickey" ALTER COLUMN "keyId" TYPE character varying(256)`); + await queryRunner.query(`ALTER TABLE "emoji" ALTER COLUMN "host" TYPE character varying(128)`); + await queryRunner.query(`ALTER TABLE "note" ALTER COLUMN "userHost" TYPE character varying(128)`); + await queryRunner.query(`ALTER TABLE "note" ALTER COLUMN "replyUserHost" TYPE character varying(128)`); + await queryRunner.query(`ALTER TABLE "note" ALTER COLUMN "renoteUserHost" TYPE character varying(128)`); + await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "host" TYPE character varying(128)`); + await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "iconUrl" TYPE character varying(256)`); + await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "faviconUrl" TYPE character varying(256)`); + await queryRunner.query(`ALTER TABLE "poll" ALTER COLUMN "userHost" TYPE character varying(128)`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ALTER COLUMN "targetUserHost" TYPE character varying(128)`); + await queryRunner.query(`ALTER TABLE "abuse_user_report" ALTER COLUMN "reporterHost" TYPE character varying(128)`); + await queryRunner.query(`ALTER TABLE "following" ALTER COLUMN "followeeHost" TYPE character varying(128)`); + await queryRunner.query(`ALTER TABLE "following" ALTER COLUMN "followerHost" TYPE character varying(128)`); + await queryRunner.query(`ALTER TABLE "follow_request" ALTER COLUMN "followeeHost" TYPE character varying(128)`); + await queryRunner.query(`ALTER TABLE "follow_request" ALTER COLUMN "followerHost" TYPE character varying(128)`); + } +} diff --git a/packages/backend/src/migration/1702680809638-add-note-createdat-userid-idx.ts b/packages/backend/src/migration/1702680809638-add-note-createdat-userid-idx.ts new file mode 100644 index 0000000..e38f9d1 --- /dev/null +++ b/packages/backend/src/migration/1702680809638-add-note-createdat-userid-idx.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddNoteCreatedatUseridIdx1702680809638 implements MigrationInterface { + name = 'AddNoteCreatedatUseridIdx1702680809638' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_note_createdAt_userId" ON "note" ("createdAt", "userId")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_note_createdAt_userId"`); + } +} diff --git a/packages/backend/src/migration/1702744857694-user-issuspended-idx.ts b/packages/backend/src/migration/1702744857694-user-issuspended-idx.ts new file mode 100644 index 0000000..86616e8 --- /dev/null +++ b/packages/backend/src/migration/1702744857694-user-issuspended-idx.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UserIssuspendedIdx1702744857694 implements MigrationInterface { + name = 'UserIssuspendedIdx1702744857694' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_8977c6037a7bc2cb0c84b6d4db" ON "user" ("isSuspended")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_8977c6037a7bc2cb0c84b6d4db"`); + } +} diff --git a/packages/backend/src/migration/1705528046452-federated-bite.ts b/packages/backend/src/migration/1705528046452-federated-bite.ts new file mode 100644 index 0000000..dc954a1 --- /dev/null +++ b/packages/backend/src/migration/1705528046452-federated-bite.ts @@ -0,0 +1,46 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class FederatedBite1705528046452 implements MigrationInterface { + name = 'FederatedBite1705528046452' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."bite_targettype_enum" AS ENUM('user', 'bite')`); + await queryRunner.query(`CREATE TABLE "bite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "uri" character varying(512), "userId" character varying(32) NOT NULL, "targetType" "public"."bite_targettype_enum" NOT NULL, "targetUserId" character varying(32), "targetBiteId" character varying(32), "replied" boolean NOT NULL DEFAULT true, CONSTRAINT "CHK_c3a20c5756ccff3133f8927500" CHECK ("targetUserId" IS NOT NULL OR "targetBiteId" IS NOT NULL), CONSTRAINT "PK_1887f3f621a4a7655a1b78bfd66" PRIMARY KEY ("id")); COMMENT ON COLUMN "bite"."uri" IS 'null if local'`); + await queryRunner.query(`ALTER TABLE "notification" ADD "biteId" character varying(32)`); + await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum" RENAME TO "user_profile_mutingnotificationtypes_enum_old"`); + await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'bite')`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum"[]`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`); + await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum_old"`); + await queryRunner.query(`ALTER TABLE "bite" ADD CONSTRAINT "FK_8d00aa79e157364ac1f60c15098" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "bite" ADD CONSTRAINT "FK_a646fbbeb6efa2531c75fec46b9" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "bite" ADD CONSTRAINT "FK_5d5f68610583f2e0b6785d3c0e9" FOREIGN KEY ("targetBiteId") REFERENCES "bite"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "notification" ADD CONSTRAINT "FK_c54844158c1eead7042e7ca4c83" FOREIGN KEY ("biteId") REFERENCES "bite"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TYPE "public"."notification_type_enum" RENAME TO "notification_type_enum_old"`); + await queryRunner.query(`CREATE TYPE "public"."notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'bite')`); + await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum" USING "type"::"text"::"public"."notification_type_enum"`); + await queryRunner.query(`DROP TYPE "public"."notification_type_enum_old"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DELETE FROM "notification" WHERE "biteId" IS NOT NULL`); + await queryRunner.query(`ALTER TABLE "notification" DROP CONSTRAINT "FK_c54844158c1eead7042e7ca4c83"`); + await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "FK_5d5f68610583f2e0b6785d3c0e9"`); + await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "FK_a646fbbeb6efa2531c75fec46b9"`); + await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "FK_8d00aa79e157364ac1f60c15098"`); + await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum_old"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum_old"[]`); + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`); + await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum"`); + await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum_old" RENAME TO "user_profile_mutingnotificationtypes_enum"`); + await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "biteId"`); + await queryRunner.query(`DROP TABLE "bite"`); + await queryRunner.query(`DROP TYPE "public"."bite_targettype_enum"`); + await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`); + await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum_old" USING "type"::"text"::"public"."notification_type_enum_old"`); + await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`); + await queryRunner.query(`ALTER TYPE "public"."notification_type_enum_old" RENAME TO "notification_type_enum"`); + } +} diff --git a/packages/backend/src/migration/1722204953558-bite-notification-index.ts b/packages/backend/src/migration/1722204953558-bite-notification-index.ts new file mode 100644 index 0000000..dbc90c9 --- /dev/null +++ b/packages/backend/src/migration/1722204953558-bite-notification-index.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class BiteNotificationIndex1722204953558 implements MigrationInterface { + name = 'BiteNotificationIndex1722204953558' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE INDEX "IDX_c54844158c1eead7042e7ca4c8" ON "notification" ("biteId") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_c54844158c1eead7042e7ca4c8"`); + } +} diff --git a/packages/backend/src/migration/1751700000000-add-glyph-for-custom-emoji.ts b/packages/backend/src/migration/1751700000000-add-glyph-for-custom-emoji.ts new file mode 100644 index 0000000..2f76bf7 --- /dev/null +++ b/packages/backend/src/migration/1751700000000-add-glyph-for-custom-emoji.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class addGlyphForCustomEmoji1751700000000 + implements MigrationInterface +{ + name = "addGlyphForCustomEmoji1751700000000"; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "emoji" ADD "glyph" boolean NOT NULL DEFAULT false`, + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "emoji" DROP COLUMN "glyph"`); + } +} diff --git a/packages/backend/src/migration/1763080186006-bite-compatibility.ts b/packages/backend/src/migration/1763080186006-bite-compatibility.ts new file mode 100644 index 0000000..005072f --- /dev/null +++ b/packages/backend/src/migration/1763080186006-bite-compatibility.ts @@ -0,0 +1,29 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class BiteCompatibility1763080186006 implements MigrationInterface { + name = 'BiteCompatibility1763080186006' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "CHK_c3a20c5756ccff3133f8927500"`); + await queryRunner.query(`ALTER TABLE "bite" DROP COLUMN "targetType"`); + await queryRunner.query(`DROP TYPE "public"."bite_targettype_enum"`); + await queryRunner.query(`ALTER TABLE "bite" ADD "userHost" character varying(512)`); + await queryRunner.query(`COMMENT ON COLUMN "bite"."userHost" IS '[Denormalized]'`); + await queryRunner.query(`ALTER TABLE "bite" ADD "targetNoteId" character varying(32)`); + await queryRunner.query(`ALTER TABLE "bite" ADD CONSTRAINT "CHK_f0e178a8942af8cf564ac5a60e" CHECK ("targetUserId" IS NOT NULL OR "targetBiteId" IS NOT NULL OR "targetNoteId" IS NOT NULL)`); + await queryRunner.query(`ALTER TABLE "bite" ADD CONSTRAINT "FK_4710076fcd98193b0ec8d7f68de" FOREIGN KEY ("targetNoteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`UPDATE "bite" SET "userHost" = "user"."host" FROM "user" WHERE "user"."id" = "bite"."userId"`); // populate userHost + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "FK_4710076fcd98193b0ec8d7f68de"`); + await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "CHK_f0e178a8942af8cf564ac5a60e"`); + await queryRunner.query(`ALTER TABLE "bite" DROP COLUMN "targetNoteId"`); + await queryRunner.query(`COMMENT ON COLUMN "bite"."userHost" IS '[Denormalized]'`); + await queryRunner.query(`ALTER TABLE "bite" DROP COLUMN "userHost"`); + await queryRunner.query(`CREATE TYPE "public"."bite_targettype_enum" AS ENUM('user', 'bite')`); + await queryRunner.query(`DELETE FROM "bite"`); // it's not easy to determine targetType in our migration, so we just clear all bites + await queryRunner.query(`ALTER TABLE "bite" ADD "targetType" "public"."bite_targettype_enum" NOT NULL`); + await queryRunner.query(`ALTER TABLE "bite" ADD CONSTRAINT "CHK_c3a20c5756ccff3133f8927500" CHECK ((("targetUserId" IS NOT NULL) OR ("targetBiteId" IS NOT NULL)))`); + } +} diff --git a/packages/backend/src/migration/1763167719064-pronouns.ts b/packages/backend/src/migration/1763167719064-pronouns.ts new file mode 100644 index 0000000..0843605 --- /dev/null +++ b/packages/backend/src/migration/1763167719064-pronouns.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Pronouns1763167719064 implements MigrationInterface { + name = 'Pronouns1763167719064' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "pronouns" jsonb NOT NULL DEFAULT '{}'`); + await queryRunner.query(`COMMENT ON COLUMN "user_profile"."pronouns" IS 'Language map of user pronouns'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`COMMENT ON COLUMN "user_profile"."pronouns" IS 'Language map of user pronouns'`); + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "pronouns"`); + } + +} diff --git a/packages/backend/src/migration/1769148205847-FEP-044f.ts b/packages/backend/src/migration/1769148205847-FEP-044f.ts new file mode 100644 index 0000000..0cb28d8 --- /dev/null +++ b/packages/backend/src/migration/1769148205847-FEP-044f.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class FEP044f1769148205847 implements MigrationInterface { + name = 'FEP044f1769148205847' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."interaction_stamp_type_enum" AS ENUM('quote')`); + await queryRunner.query(`CREATE TABLE "interaction_stamp" ("id" character varying(32) NOT NULL, "type" "public"."interaction_stamp_type_enum" NOT NULL, "targetNoteId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, CONSTRAINT "PK_655e4f52463bd63c126bf8a21bf" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_966faaedd04f5bc0e4e7f23b51" ON "interaction_stamp" ("noteId", "targetNoteId") `); + await queryRunner.query(`ALTER TABLE "note" ADD "quoteAuthorization" character varying(512)`); + await queryRunner.query(`ALTER TABLE "note" ADD "canQuote" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "interaction_stamp" ADD CONSTRAINT "FK_5b41539ebe68913e845d32b1a14" FOREIGN KEY ("targetNoteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "interaction_stamp" ADD CONSTRAINT "FK_f22ecf4fa523296288dfff84c40" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "interaction_stamp" DROP CONSTRAINT "FK_f22ecf4fa523296288dfff84c40"`); + await queryRunner.query(`ALTER TABLE "interaction_stamp" DROP CONSTRAINT "FK_5b41539ebe68913e845d32b1a14"`); + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "canQuote"`); + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "quoteAuthorization"`); + await queryRunner.query(`DROP INDEX "public"."IDX_966faaedd04f5bc0e4e7f23b51"`); + await queryRunner.query(`DROP TABLE "interaction_stamp"`); + await queryRunner.query(`DROP TYPE "public"."interaction_stamp_type_enum"`); + } +} diff --git a/packages/backend/src/migration/1770193373959-biteControls.ts b/packages/backend/src/migration/1770193373959-biteControls.ts new file mode 100644 index 0000000..84ec1e0 --- /dev/null +++ b/packages/backend/src/migration/1770193373959-biteControls.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class BiteControls1770193373959 implements MigrationInterface { + name = 'BiteControls1770193373959' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "public"."user_canbite_enum" AS ENUM('anyone', 'followers', 'nobody')`); + await queryRunner.query(`ALTER TABLE "user" ADD "canBite" "public"."user_canbite_enum" NOT NULL DEFAULT 'nobody'`); + await queryRunner.query(`UPDATE "user" SET "canBite" = 'followers' WHERE HOST IS NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "canBite"`); + await queryRunner.query(`DROP TYPE "public"."user_canbite_enum"`); + } +} diff --git a/packages/backend/src/migration/1783148000000-user-emojis.ts b/packages/backend/src/migration/1783148000000-user-emojis.ts new file mode 100644 index 0000000..c7616ea --- /dev/null +++ b/packages/backend/src/migration/1783148000000-user-emojis.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UserEmojis1783148000000 implements MigrationInterface { + name = "UserEmojis1783148000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "user_emoji" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "name" character varying(128) NOT NULL, "userId" character varying(32) NOT NULL, "originalUrl" character varying(512) NOT NULL, "publicUrl" character varying(512) NOT NULL DEFAULT '', "type" character varying(64), "width" integer, "height" integer, CONSTRAINT "PK_6cae1a4f12c059a9f04f4fc6335" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_f60ba1b11d0c9d945ad6093339" ON "user_emoji" ("name")`); + await queryRunner.query(`CREATE INDEX "IDX_800e361f3e733b7c1682234ee0" ON "user_emoji" ("userId")`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_41a4ec233a7201def6c470d3c9" ON "user_emoji" ("name", "userId")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_41a4ec233a7201def6c470d3c9"`); + await queryRunner.query(`DROP INDEX "public"."IDX_800e361f3e733b7c1682234ee0"`); + await queryRunner.query(`DROP INDEX "public"."IDX_f60ba1b11d0c9d945ad6093339"`); + await queryRunner.query(`DROP TABLE "user_emoji"`); + } +} diff --git a/packages/backend/src/migration/1783149000000-frozen-friends-yume-features.ts b/packages/backend/src/migration/1783149000000-frozen-friends-yume-features.ts new file mode 100644 index 0000000..71e093e --- /dev/null +++ b/packages/backend/src/migration/1783149000000-frozen-friends-yume-features.ts @@ -0,0 +1,49 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class FrozenFriendsYumeFeatures1783149000000 + implements MigrationInterface +{ + name = "FrozenFriendsYumeFeatures1783149000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_emoji" ADD "glyph" boolean NOT NULL DEFAULT false`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "allowCalls" boolean NOT NULL DEFAULT true`, + ); + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "symbolFileId" character varying(32)`, + ); + await queryRunner.query( + `ALTER TABLE "user_group" ADD "username" character varying(64)`, + ); + await queryRunner.query( + `ALTER TABLE "user_group" ADD "allowCalls" boolean NOT NULL DEFAULT true`, + ); + await queryRunner.query( + `ALTER TABLE "user_group" ADD "iconFileId" character varying(32)`, + ); + await queryRunner.query( + `ALTER TABLE "user_group" ADD "symbolFileId" character varying(32)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_user_group_username" ON "user_group" ("username") WHERE "username" IS NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "note" ADD "viewCount" integer NOT NULL DEFAULT 0`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "viewCount"`); + await queryRunner.query(`DROP INDEX "public"."IDX_user_group_username"`); + await queryRunner.query(`ALTER TABLE "user_group" DROP COLUMN "symbolFileId"`); + await queryRunner.query(`ALTER TABLE "user_group" DROP COLUMN "iconFileId"`); + await queryRunner.query(`ALTER TABLE "user_group" DROP COLUMN "allowCalls"`); + await queryRunner.query(`ALTER TABLE "user_group" DROP COLUMN "username"`); + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "symbolFileId"`); + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "allowCalls"`); + await queryRunner.query(`ALTER TABLE "user_emoji" DROP COLUMN "glyph"`); + } +} diff --git a/packages/backend/src/migration/1783150000000-group-emojis-and-calls.ts b/packages/backend/src/migration/1783150000000-group-emojis-and-calls.ts new file mode 100644 index 0000000..c50ca7f --- /dev/null +++ b/packages/backend/src/migration/1783150000000-group-emojis-and-calls.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class GroupEmojisAndCalls1783150000000 implements MigrationInterface { + name = "GroupEmojisAndCalls1783150000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_emoji" ALTER COLUMN "userId" DROP NOT NULL`); + await queryRunner.query(`ALTER TABLE "user_emoji" ADD "userGroupId" character varying(32)`); + await queryRunner.query(`CREATE INDEX "IDX_user_emoji_userGroupId" ON "user_emoji" ("userGroupId")`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_user_emoji_name_userGroupId" ON "user_emoji" ("name", "userGroupId") WHERE "userGroupId" IS NOT NULL`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."IDX_user_emoji_name_userGroupId"`); + await queryRunner.query(`DROP INDEX "public"."IDX_user_emoji_userGroupId"`); + await queryRunner.query(`ALTER TABLE "user_emoji" DROP COLUMN "userGroupId"`); + await queryRunner.query(`ALTER TABLE "user_emoji" ALTER COLUMN "userId" SET NOT NULL`); + } +} diff --git a/packages/backend/src/migration/1783151000000-call-blocking.ts b/packages/backend/src/migration/1783151000000-call-blocking.ts new file mode 100644 index 0000000..c3ce17d --- /dev/null +++ b/packages/backend/src/migration/1783151000000-call-blocking.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class CallBlocking1783151000000 implements MigrationInterface { + name = "CallBlocking1783151000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "call_blocking" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "blockeeId" character varying(32) NOT NULL, "blockerId" character varying(32) NOT NULL, CONSTRAINT "PK_call_blocking_id" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_call_blocking_createdAt" ON "call_blocking" ("createdAt")`); + await queryRunner.query(`CREATE INDEX "IDX_call_blocking_blockeeId" ON "call_blocking" ("blockeeId")`); + await queryRunner.query(`CREATE INDEX "IDX_call_blocking_blockerId" ON "call_blocking" ("blockerId")`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_call_blocking_blocker_blockee" ON "call_blocking" ("blockerId", "blockeeId")`); + await queryRunner.query(`ALTER TABLE "call_blocking" ADD CONSTRAINT "FK_call_blocking_blockee" FOREIGN KEY ("blockeeId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "call_blocking" ADD CONSTRAINT "FK_call_blocking_blocker" FOREIGN KEY ("blockerId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "call_blocking" DROP CONSTRAINT "FK_call_blocking_blocker"`); + await queryRunner.query(`ALTER TABLE "call_blocking" DROP CONSTRAINT "FK_call_blocking_blockee"`); + await queryRunner.query(`DROP INDEX "public"."IDX_call_blocking_blocker_blockee"`); + await queryRunner.query(`DROP INDEX "public"."IDX_call_blocking_blockerId"`); + await queryRunner.query(`DROP INDEX "public"."IDX_call_blocking_blockeeId"`); + await queryRunner.query(`DROP INDEX "public"."IDX_call_blocking_createdAt"`); + await queryRunner.query(`DROP TABLE "call_blocking"`); + } +} diff --git a/packages/backend/src/migration/1783309000000-allow-calls-default-off.ts b/packages/backend/src/migration/1783309000000-allow-calls-default-off.ts new file mode 100644 index 0000000..9e47d7d --- /dev/null +++ b/packages/backend/src/migration/1783309000000-allow-calls-default-off.ts @@ -0,0 +1,13 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AllowCallsDefaultOff1783309000000 implements MigrationInterface { + name = "AllowCallsDefaultOff1783309000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "allowCalls" SET DEFAULT false`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "allowCalls" SET DEFAULT true`); + } +} diff --git a/packages/backend/src/migration/1783309100000-drive-file-allow-download.ts b/packages/backend/src/migration/1783309100000-drive-file-allow-download.ts new file mode 100644 index 0000000..7e9c103 --- /dev/null +++ b/packages/backend/src/migration/1783309100000-drive-file-allow-download.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class DriveFileAllowDownload1783309100000 implements MigrationInterface { + name = "DriveFileAllowDownload1783309100000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "drive_file" ADD "allowDownload" boolean NOT NULL DEFAULT true`); + await queryRunner.query(`CREATE INDEX "IDX_drive_file_allow_download" ON "drive_file" ("allowDownload")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_drive_file_allow_download"`); + await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "allowDownload"`); + } +} diff --git a/packages/backend/src/migration/1783650000000-restore-reversi.ts b/packages/backend/src/migration/1783650000000-restore-reversi.ts new file mode 100644 index 0000000..6a85ada --- /dev/null +++ b/packages/backend/src/migration/1783650000000-restore-reversi.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RestoreReversi1783650000000 implements MigrationInterface { + name = "RestoreReversi1783650000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE IF NOT EXISTS "reversi_game" ("id" character varying(32) NOT NULL, "startedAt" TIMESTAMP WITH TIME ZONE, "endedAt" TIMESTAMP WITH TIME ZONE, "user1Id" character varying(32) NOT NULL, "user2Id" character varying(32) NOT NULL, "user1Ready" boolean NOT NULL DEFAULT false, "user2Ready" boolean NOT NULL DEFAULT false, "black" integer, "isStarted" boolean NOT NULL DEFAULT false, "isEnded" boolean NOT NULL DEFAULT false, "winnerId" character varying(32), "surrenderedUserId" character varying(32), "timeoutUserId" character varying(32), "timeLimitForEachTurn" smallint NOT NULL DEFAULT '90', "logs" jsonb NOT NULL DEFAULT '[]', "map" character varying(64) array NOT NULL, "bw" character varying(32) NOT NULL, "noIrregularRules" boolean NOT NULL DEFAULT false, "isLlotheo" boolean NOT NULL DEFAULT false, "canPutEverywhere" boolean NOT NULL DEFAULT false, "loopedBoard" boolean NOT NULL DEFAULT false, "form1" jsonb DEFAULT null, "form2" jsonb DEFAULT null, "crc32" character varying(32), CONSTRAINT "PK_76b30eeba71b1193ad7c5311c3f" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE IF NOT EXISTS "reversi_matching" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "parentId" character varying(32) NOT NULL, "childId" character varying(32) NOT NULL, CONSTRAINT "PK_880bd0afbab232f21c8b9d146cf" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_3b25402709dd9882048c2bbade" ON "reversi_matching" ("parentId")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_e247b23a3c9b45f89ec1299d06" ON "reversi_matching" ("childId")`); + await queryRunner.query(`DO $$ BEGIN ALTER TABLE "reversi_game" ADD CONSTRAINT "FK_reversi_game_user1" FOREIGN KEY ("user1Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION; EXCEPTION WHEN duplicate_object THEN NULL; END $$`); + await queryRunner.query(`DO $$ BEGIN ALTER TABLE "reversi_game" ADD CONSTRAINT "FK_reversi_game_user2" FOREIGN KEY ("user2Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION; EXCEPTION WHEN duplicate_object THEN NULL; END $$`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "reversi_matching"`); + await queryRunner.query(`DROP TABLE IF EXISTS "reversi_game"`); + } +} diff --git a/packages/backend/src/migration/1783660000000-restore-shogi.ts b/packages/backend/src/migration/1783660000000-restore-shogi.ts new file mode 100644 index 0000000..8f03e00 --- /dev/null +++ b/packages/backend/src/migration/1783660000000-restore-shogi.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RestoreShogi1783660000000 implements MigrationInterface { + name = "RestoreShogi1783660000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE IF NOT EXISTS "shogi_game" ("id" character varying(32) NOT NULL, "startedAt" TIMESTAMP WITH TIME ZONE, "endedAt" TIMESTAMP WITH TIME ZONE, "user1Id" character varying(32) NOT NULL, "user2Id" character varying(32) NOT NULL, "user1Ready" boolean NOT NULL DEFAULT false, "user2Ready" boolean NOT NULL DEFAULT false, "sente" integer, "isStarted" boolean NOT NULL DEFAULT false, "isEnded" boolean NOT NULL DEFAULT false, "winnerId" character varying(32), "surrenderedUserId" character varying(32), "sfen" text NOT NULL, "logs" jsonb NOT NULL DEFAULT '[]', CONSTRAINT "PK_shogi_game" PRIMARY KEY ("id"))`); + await queryRunner.query(`DO $$ BEGIN ALTER TABLE "shogi_game" ADD CONSTRAINT "FK_shogi_game_user1" FOREIGN KEY ("user1Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION; EXCEPTION WHEN duplicate_object THEN NULL; END $$`); + await queryRunner.query(`DO $$ BEGIN ALTER TABLE "shogi_game" ADD CONSTRAINT "FK_shogi_game_user2" FOREIGN KEY ("user2Id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION; EXCEPTION WHEN duplicate_object THEN NULL; END $$`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "shogi_game"`); + } +} diff --git a/packages/backend/src/migration/1783700000000-scheduled-notes.ts b/packages/backend/src/migration/1783700000000-scheduled-notes.ts new file mode 100644 index 0000000..dd15929 --- /dev/null +++ b/packages/backend/src/migration/1783700000000-scheduled-notes.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ScheduledNotes1783700000000 implements MigrationInterface { + name = "ScheduledNotes1783700000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE IF NOT EXISTS "scheduled_note" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "scheduledAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "status" character varying(16) NOT NULL DEFAULT 'scheduled', "data" jsonb NOT NULL, "noteId" character varying(32), "error" text, CONSTRAINT "PK_scheduled_note" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_scheduled_note_createdAt" ON "scheduled_note" ("createdAt")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_scheduled_note_scheduledAt" ON "scheduled_note" ("scheduledAt")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_scheduled_note_userId" ON "scheduled_note" ("userId")`); + await queryRunner.query(`DO $$ BEGIN ALTER TABLE "scheduled_note" ADD CONSTRAINT "FK_scheduled_note_user" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION; EXCEPTION WHEN duplicate_object THEN NULL; END $$`); + await queryRunner.query(`DO $$ BEGIN ALTER TABLE "scheduled_note" ADD CONSTRAINT "FK_scheduled_note_note" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE SET NULL ON UPDATE NO ACTION; EXCEPTION WHEN duplicate_object THEN NULL; END $$`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "scheduled_note"`); + } +} diff --git a/packages/backend/src/migration/1783701000000-group-actor-actions.ts b/packages/backend/src/migration/1783701000000-group-actor-actions.ts new file mode 100644 index 0000000..90be2df --- /dev/null +++ b/packages/backend/src/migration/1783701000000-group-actor-actions.ts @@ -0,0 +1,93 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class GroupActorActions1783701000000 implements MigrationInterface { + name = "GroupActorActions1783701000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "note" ADD COLUMN IF NOT EXISTS "groupId" character varying(32)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_note_groupId" ON "note" ("groupId")`); + await queryRunner.query(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_note_groupId') THEN ALTER TABLE "note" ADD CONSTRAINT "FK_note_groupId" FOREIGN KEY ("groupId") REFERENCES "user_group"("id") ON DELETE SET NULL ON UPDATE NO ACTION; END IF; END $$`); + + await queryRunner.query(`ALTER TABLE "note_reaction" ADD COLUMN IF NOT EXISTS "groupId" character varying(32)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_note_reaction_groupId" ON "note_reaction" ("groupId")`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_note_reaction_userId_noteId"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_2ad0dfa7c5f662a9568d503f6c"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_ad0c221b25672daf2df320a817"`); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_note_reaction_user_note_personal" ON "note_reaction" ("userId", "noteId") WHERE "groupId" IS NULL`); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_note_reaction_group_note" ON "note_reaction" ("groupId", "noteId") WHERE "groupId" IS NOT NULL`); + await queryRunner.query(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_note_reaction_groupId') THEN ALTER TABLE "note_reaction" ADD CONSTRAINT "FK_note_reaction_groupId" FOREIGN KEY ("groupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION; END IF; END $$`); + + await queryRunner.query(`ALTER TABLE "note_favorite" ADD COLUMN IF NOT EXISTS "groupId" character varying(32)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_note_favorite_groupId" ON "note_favorite" ("groupId")`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_note_favorite_userId_noteId"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_2f9b01a470a2b5d20a87f7520e"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_0f4fb9ad355f3effff221ef245"`); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_note_favorite_user_note_personal" ON "note_favorite" ("userId", "noteId") WHERE "groupId" IS NULL`); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_note_favorite_group_note" ON "note_favorite" ("groupId", "noteId") WHERE "groupId" IS NOT NULL`); + await queryRunner.query(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_note_favorite_groupId') THEN ALTER TABLE "note_favorite" ADD CONSTRAINT "FK_note_favorite_groupId" FOREIGN KEY ("groupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION; END IF; END $$`); + + await queryRunner.query(`ALTER TABLE "muting" ADD COLUMN IF NOT EXISTS "groupId" character varying(32)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_muting_groupId" ON "muting" ("groupId")`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_1eb9d9824a630321a29fd3b290"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_muting_muterId_muteeId"`); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_muting_user_mutee_personal" ON "muting" ("muterId", "muteeId") WHERE "groupId" IS NULL`); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_muting_group_mutee" ON "muting" ("groupId", "muteeId") WHERE "groupId" IS NOT NULL`); + await queryRunner.query(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_muting_groupId') THEN ALTER TABLE "muting" ADD CONSTRAINT "FK_muting_groupId" FOREIGN KEY ("groupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION; END IF; END $$`); + + await queryRunner.query(`ALTER TABLE "blocking" ADD COLUMN IF NOT EXISTS "groupId" character varying(32)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_blocking_groupId" ON "blocking" ("groupId")`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_98a1bc5cb30dfd159de056549f"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_blocking_blockerId_blockeeId"`); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_blocking_user_blockee_personal" ON "blocking" ("blockerId", "blockeeId") WHERE "groupId" IS NULL`); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_blocking_group_blockee" ON "blocking" ("groupId", "blockeeId") WHERE "groupId" IS NOT NULL`); + await queryRunner.query(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_blocking_groupId') THEN ALTER TABLE "blocking" ADD CONSTRAINT "FK_blocking_groupId" FOREIGN KEY ("groupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION; END IF; END $$`); + + await queryRunner.query(`ALTER TABLE "call_blocking" ADD COLUMN IF NOT EXISTS "groupId" character varying(32)`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_call_blocking_groupId" ON "call_blocking" ("groupId")`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."IDX_call_blocking_blocker_blockee"`); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_call_blocking_user_blockee_personal" ON "call_blocking" ("blockerId", "blockeeId") WHERE "groupId" IS NULL`); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_call_blocking_group_blockee" ON "call_blocking" ("groupId", "blockeeId") WHERE "groupId" IS NOT NULL`); + await queryRunner.query(`DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'FK_call_blocking_groupId') THEN ALTER TABLE "call_blocking" ADD CONSTRAINT "FK_call_blocking_groupId" FOREIGN KEY ("groupId") REFERENCES "user_group"("id") ON DELETE CASCADE ON UPDATE NO ACTION; END IF; END $$`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "call_blocking" DROP CONSTRAINT "FK_call_blocking_groupId"`); + await queryRunner.query(`DROP INDEX "public"."IDX_call_blocking_group_blockee"`); + await queryRunner.query(`DROP INDEX "public"."IDX_call_blocking_user_blockee_personal"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_call_blocking_blocker_blockee" ON "call_blocking" ("blockerId", "blockeeId")`); + await queryRunner.query(`DROP INDEX "public"."IDX_call_blocking_groupId"`); + await queryRunner.query(`ALTER TABLE "call_blocking" DROP COLUMN "groupId"`); + + await queryRunner.query(`ALTER TABLE "blocking" DROP CONSTRAINT "FK_blocking_groupId"`); + await queryRunner.query(`DROP INDEX "public"."IDX_blocking_group_blockee"`); + await queryRunner.query(`DROP INDEX "public"."IDX_blocking_user_blockee_personal"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_98a1bc5cb30dfd159de056549f" ON "blocking" ("blockerId", "blockeeId")`); + await queryRunner.query(`DROP INDEX "public"."IDX_blocking_groupId"`); + await queryRunner.query(`ALTER TABLE "blocking" DROP COLUMN "groupId"`); + + await queryRunner.query(`ALTER TABLE "muting" DROP CONSTRAINT "FK_muting_groupId"`); + await queryRunner.query(`DROP INDEX "public"."IDX_muting_group_mutee"`); + await queryRunner.query(`DROP INDEX "public"."IDX_muting_user_mutee_personal"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_1eb9d9824a630321a29fd3b290" ON "muting" ("muterId", "muteeId")`); + await queryRunner.query(`DROP INDEX "public"."IDX_muting_groupId"`); + await queryRunner.query(`ALTER TABLE "muting" DROP COLUMN "groupId"`); + + await queryRunner.query(`ALTER TABLE "note_favorite" DROP CONSTRAINT "FK_note_favorite_groupId"`); + await queryRunner.query(`DROP INDEX "public"."IDX_note_favorite_group_note"`); + await queryRunner.query(`DROP INDEX "public"."IDX_note_favorite_user_note_personal"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_note_favorite_userId_noteId" ON "note_favorite" ("userId", "noteId")`); + await queryRunner.query(`DROP INDEX "public"."IDX_note_favorite_groupId"`); + await queryRunner.query(`ALTER TABLE "note_favorite" DROP COLUMN "groupId"`); + + await queryRunner.query(`ALTER TABLE "note_reaction" DROP CONSTRAINT "FK_note_reaction_groupId"`); + await queryRunner.query(`DROP INDEX "public"."IDX_note_reaction_group_note"`); + await queryRunner.query(`DROP INDEX "public"."IDX_note_reaction_user_note_personal"`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_note_reaction_userId_noteId" ON "note_reaction" ("userId", "noteId")`); + await queryRunner.query(`DROP INDEX "public"."IDX_note_reaction_groupId"`); + await queryRunner.query(`ALTER TABLE "note_reaction" DROP COLUMN "groupId"`); + + await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_note_groupId"`); + await queryRunner.query(`DROP INDEX "public"."IDX_note_groupId"`); + await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "groupId"`); + } +} diff --git a/packages/backend/src/migration/1783800000000-memoriet.ts b/packages/backend/src/migration/1783800000000-memoriet.ts new file mode 100644 index 0000000..aeece37 --- /dev/null +++ b/packages/backend/src/migration/1783800000000-memoriet.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Memoriet1783800000000 implements MigrationInterface { + name = "Memoriet1783800000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE IF NOT EXISTS "memoriet" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_memoriet" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_memoriet_createdAt" ON "memoriet" ("createdAt")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_memoriet_userId" ON "memoriet" ("userId")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_memoriet_noteId" ON "memoriet" ("noteId")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_memoriet_expiresAt" ON "memoriet" ("expiresAt")`); + await queryRunner.query(`DO $$ BEGIN ALTER TABLE "memoriet" ADD CONSTRAINT "FK_memoriet_user" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION; EXCEPTION WHEN duplicate_object THEN NULL; END $$`); + await queryRunner.query(`DO $$ BEGIN ALTER TABLE "memoriet" ADD CONSTRAINT "FK_memoriet_note" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION; EXCEPTION WHEN duplicate_object THEN NULL; END $$`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "memoriet"`); + } +} diff --git a/packages/backend/src/migration/1783801000000-memoriet-text-layers-and-views.ts b/packages/backend/src/migration/1783801000000-memoriet-text-layers-and-views.ts new file mode 100644 index 0000000..47aa692 --- /dev/null +++ b/packages/backend/src/migration/1783801000000-memoriet-text-layers-and-views.ts @@ -0,0 +1,22 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MemorietTextLayersAndViews1783801000000 implements MigrationInterface { + name = "MemorietTextLayersAndViews1783801000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "memoriet" ADD COLUMN IF NOT EXISTS "textLayers" jsonb NOT NULL DEFAULT '[]'::jsonb`); + await queryRunner.query(`CREATE TABLE IF NOT EXISTS "memoriet_view" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "viewedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "memorietId" character varying(32) NOT NULL, "viewerId" character varying(32) NOT NULL, CONSTRAINT "PK_memoriet_view" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_memoriet_view_createdAt" ON "memoriet_view" ("createdAt")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_memoriet_view_viewedAt" ON "memoriet_view" ("viewedAt")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_memoriet_view_memorietId" ON "memoriet_view" ("memorietId")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_memoriet_view_viewerId" ON "memoriet_view" ("viewerId")`); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_memoriet_view_memorietId_viewerId" ON "memoriet_view" ("memorietId", "viewerId")`); + await queryRunner.query(`DO $$ BEGIN ALTER TABLE "memoriet_view" ADD CONSTRAINT "FK_memoriet_view_memoriet" FOREIGN KEY ("memorietId") REFERENCES "memoriet"("id") ON DELETE CASCADE ON UPDATE NO ACTION; EXCEPTION WHEN duplicate_object THEN NULL; END $$`); + await queryRunner.query(`DO $$ BEGIN ALTER TABLE "memoriet_view" ADD CONSTRAINT "FK_memoriet_view_viewer" FOREIGN KEY ("viewerId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION; EXCEPTION WHEN duplicate_object THEN NULL; END $$`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "memoriet_view"`); + await queryRunner.query(`ALTER TABLE "memoriet" DROP COLUMN IF EXISTS "textLayers"`); + } +} diff --git a/packages/backend/src/migration/1783820000000-user-badges.ts b/packages/backend/src/migration/1783820000000-user-badges.ts new file mode 100644 index 0000000..edf23c4 --- /dev/null +++ b/packages/backend/src/migration/1783820000000-user-badges.ts @@ -0,0 +1,15 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UserBadges1783820000000 implements MigrationInterface { + name = "UserBadges1783820000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "isVerified" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "minorBadges" character varying(1) array NOT NULL DEFAULT '{}'::varchar[]`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN IF EXISTS "minorBadges"`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN IF EXISTS "isVerified"`); + } +} diff --git a/packages/backend/src/migration/1783821000000-verified-badge-requests.ts b/packages/backend/src/migration/1783821000000-verified-badge-requests.ts new file mode 100644 index 0000000..5f4748a --- /dev/null +++ b/packages/backend/src/migration/1783821000000-verified-badge-requests.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class VerifiedBadgeRequests1783821000000 implements MigrationInterface { + name = "VerifiedBadgeRequests1783821000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE IF NOT EXISTS "verified_badge_request" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "resolvedAt" TIMESTAMP WITH TIME ZONE, "userId" character varying(32) NOT NULL, "resolverId" character varying(32), "status" character varying(16) NOT NULL DEFAULT 'pending', "comment" character varying(2048) NOT NULL DEFAULT '', CONSTRAINT "PK_verified_badge_request" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_verified_badge_request_createdAt" ON "verified_badge_request" ("createdAt")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_verified_badge_request_userId" ON "verified_badge_request" ("userId")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_verified_badge_request_status" ON "verified_badge_request" ("status")`); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_verified_badge_request_user_pending" ON "verified_badge_request" ("userId") WHERE "status" = 'pending'`); + await queryRunner.query(`DO $$ BEGIN ALTER TABLE "verified_badge_request" ADD CONSTRAINT "FK_verified_badge_request_user" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION; EXCEPTION WHEN duplicate_object THEN NULL; END $$`); + await queryRunner.query(`DO $$ BEGIN ALTER TABLE "verified_badge_request" ADD CONSTRAINT "FK_verified_badge_request_resolver" FOREIGN KEY ("resolverId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE NO ACTION; EXCEPTION WHEN duplicate_object THEN NULL; END $$`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "verified_badge_request"`); + } +} diff --git a/packages/backend/src/migration/1783900000000-lua4frozen-database-files.ts b/packages/backend/src/migration/1783900000000-lua4frozen-database-files.ts new file mode 100644 index 0000000..b0a0209 --- /dev/null +++ b/packages/backend/src/migration/1783900000000-lua4frozen-database-files.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Lua4FrozenDatabaseFiles1783900000000 implements MigrationInterface { + name = "Lua4FrozenDatabaseFiles1783900000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "drive_file" ADD COLUMN IF NOT EXISTS "isDatabase" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_drive_file_is_database" ON "drive_file" ("isDatabase")`); + await queryRunner.query(`ALTER TABLE "meta" ADD COLUMN IF NOT EXISTS "lua4frozenDatabaseCapacityMb" integer NOT NULL DEFAULT 16`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN IF EXISTS "lua4frozenDatabaseCapacityMb"`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_drive_file_is_database"`); + await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN IF EXISTS "isDatabase"`); + } +} diff --git a/packages/backend/src/migration/1784000000000-ad-service-credits.ts b/packages/backend/src/migration/1784000000000-ad-service-credits.ts new file mode 100644 index 0000000..85fad43 --- /dev/null +++ b/packages/backend/src/migration/1784000000000-ad-service-credits.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class adServiceCredits1784000000000 implements MigrationInterface { + name = "adServiceCredits1784000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "promo_note" ADD COLUMN IF NOT EXISTS "totalCredits" integer NOT NULL DEFAULT 0`); + await queryRunner.query(`ALTER TABLE "promo_note" ADD COLUMN IF NOT EXISTS "remainingCredits" integer NOT NULL DEFAULT 0`); + await queryRunner.query(`ALTER TABLE "promo_read" ADD COLUMN IF NOT EXISTS "readDay" character varying(10)`); + await queryRunner.query(`UPDATE "promo_read" SET "readDay" = to_char("createdAt" AT TIME ZONE 'UTC', 'YYYY-MM-DD') WHERE "readDay" IS NULL`); + await queryRunner.query(`ALTER TABLE "promo_read" ALTER COLUMN "readDay" SET NOT NULL`); + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_2882b8a1a07c7d281a98b6db16"`); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_promo_read_user_note_day" ON "promo_read" ("userId", "noteId", "readDay")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX IF EXISTS "IDX_promo_read_user_note_day"`); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_2882b8a1a07c7d281a98b6db16" ON "promo_read" ("userId", "noteId")`); + await queryRunner.query(`ALTER TABLE "promo_read" DROP COLUMN IF EXISTS "readDay"`); + await queryRunner.query(`ALTER TABLE "promo_note" DROP COLUMN IF EXISTS "remainingCredits"`); + await queryRunner.query(`ALTER TABLE "promo_note" DROP COLUMN IF EXISTS "totalCredits"`); + } +} diff --git a/packages/backend/src/migration/1784100000000-memoriet-archive.ts b/packages/backend/src/migration/1784100000000-memoriet-archive.ts new file mode 100644 index 0000000..e19f194 --- /dev/null +++ b/packages/backend/src/migration/1784100000000-memoriet-archive.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class MemorietArchive1784100000000 implements MigrationInterface { + name = "MemorietArchive1784100000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE IF NOT EXISTS "memoriet_archive" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "deletedAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "text" text, "cw" character varying(128), "fileIds" character varying(32) array NOT NULL DEFAULT '{}', "textLayers" jsonb NOT NULL DEFAULT '[]'::jsonb, "visibility" character varying(32) NOT NULL DEFAULT 'home', CONSTRAINT "PK_memoriet_archive" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_memoriet_archive_createdAt" ON "memoriet_archive" ("createdAt")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_memoriet_archive_deletedAt" ON "memoriet_archive" ("deletedAt")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_memoriet_archive_userId" ON "memoriet_archive" ("userId")`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "memoriet_archive"`); + } +} diff --git a/packages/backend/src/migration/1784200000000-plans.ts b/packages/backend/src/migration/1784200000000-plans.ts new file mode 100644 index 0000000..b167129 --- /dev/null +++ b/packages/backend/src/migration/1784200000000-plans.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class Plans1784200000000 implements MigrationInterface { + name = "Plans1784200000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE IF NOT EXISTS "plan" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "updatedAt" TIMESTAMP WITH TIME ZONE, "name" character varying(128) NOT NULL, "icon" character varying(64) NOT NULL, "description" character varying(512) NOT NULL DEFAULT '', CONSTRAINT "PK_plan" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_plan_name" ON "plan" ("name")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_plan_createdAt" ON "plan" ("createdAt")`); + await queryRunner.query(`CREATE TABLE IF NOT EXISTS "user_plan" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "planId" character varying(32) NOT NULL, CONSTRAINT "PK_user_plan" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_user_plan_user_plan" ON "user_plan" ("userId", "planId")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_user_plan_createdAt" ON "user_plan" ("createdAt")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_user_plan_userId" ON "user_plan" ("userId")`); + await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_user_plan_planId" ON "user_plan" ("planId")`); + await queryRunner.query(`DO $$ BEGIN ALTER TABLE "user_plan" ADD CONSTRAINT "FK_user_plan_user" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION; EXCEPTION WHEN duplicate_object THEN NULL; END $$`); + await queryRunner.query(`DO $$ BEGIN ALTER TABLE "user_plan" ADD CONSTRAINT "FK_user_plan_plan" FOREIGN KEY ("planId") REFERENCES "plan"("id") ON DELETE CASCADE ON UPDATE NO ACTION; EXCEPTION WHEN duplicate_object THEN NULL; END $$`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "user_plan"`); + await queryRunner.query(`DROP TABLE IF EXISTS "plan"`); + } +} diff --git a/packages/backend/src/misc/acct.ts b/packages/backend/src/misc/acct.ts new file mode 100644 index 0000000..cb6808b --- /dev/null +++ b/packages/backend/src/misc/acct.ts @@ -0,0 +1,14 @@ +export type Acct = { + username: string; + host: string | null; +}; + +export function parse(acct: string): Acct { + if (acct.startsWith("@")) acct = acct.slice(1); + const split = acct.split("@", 2); + return { username: split[0], host: split[1] || null }; +} + +export function toString(acct: Acct): string { + return acct.host == null ? acct.username : `${acct.username}@${acct.host}`; +} diff --git a/packages/backend/src/misc/antenna-cache.ts b/packages/backend/src/misc/antenna-cache.ts new file mode 100644 index 0000000..7f199c3 --- /dev/null +++ b/packages/backend/src/misc/antenna-cache.ts @@ -0,0 +1,36 @@ +import { Antennas } from "@/models/index.js"; +import type { Antenna } from "@/models/entities/antenna.js"; +import { subscriber } from "@/db/redis.js"; + +let antennasFetched = false; +let antennas: Antenna[] = []; + +export async function getAntennas() { + if (!antennasFetched) { + antennas = await Antennas.find(); + antennasFetched = true; + } + + return antennas; +} + +subscriber.on("message", async (_, data) => { + const obj = JSON.parse(data); + + if (obj.channel === "internal") { + const { type, body } = obj.message; + switch (type) { + case "antennaCreated": + antennas.push(body); + break; + case "antennaUpdated": + antennas[antennas.findIndex((a) => a.id === body.id)] = body; + break; + case "antennaDeleted": + antennas = antennas.filter((a) => a.id !== body.id); + break; + default: + break; + } + } +}); diff --git a/packages/backend/src/misc/api-permissions.ts b/packages/backend/src/misc/api-permissions.ts new file mode 100644 index 0000000..9e04026 --- /dev/null +++ b/packages/backend/src/misc/api-permissions.ts @@ -0,0 +1,35 @@ +export const kinds = [ + "read:account", + "write:account", + "read:blocks", + "write:blocks", + "read:drive", + "write:drive", + "read:favorites", + "write:favorites", + "read:following", + "write:following", + "read:messaging", + "write:messaging", + "read:mutes", + "write:mutes", + "write:notes", + "read:notifications", + "write:notifications", + "read:reactions", + "write:reactions", + "write:votes", + "read:pages", + "write:pages", + "write:page-likes", + "read:page-likes", + "read:user-groups", + "write:user-groups", + "read:channels", + "write:channels", + "read:gallery", + "write:gallery", + "read:gallery-likes", + "write:gallery-likes", +]; +// IF YOU ADD KINDS(PERMISSIONS), YOU MUST ADD TRANSLATIONS (under _permissions). diff --git a/packages/backend/src/misc/app-lock.ts b/packages/backend/src/misc/app-lock.ts new file mode 100644 index 0000000..05bcf54 --- /dev/null +++ b/packages/backend/src/misc/app-lock.ts @@ -0,0 +1,33 @@ +import { redisClient } from "../db/redis.js"; +import { promisify } from "node:util"; +import redisLock from "redis-lock"; + +/** + * Retry delay (ms) for lock acquisition + */ +const retryDelay = 100; + +const lock: (key: string, timeout?: number) => Promise<() => void> = redisClient + ? promisify(redisLock(redisClient, retryDelay)) + : async () => () => {}; + +/** + * Get AP Object lock + * @param uri AP object ID + * @param timeout Lock timeout (ms), The timeout releases previous lock. + * @returns Unlock function + */ +export function getApLock(uri: string, timeout = 30 * 1000) { + return lock(`ap-object:${uri}`, timeout); +} + +export function getFetchInstanceMetadataLock( + host: string, + timeout = 30 * 1000, +) { + return lock(`instance:${host}`, timeout); +} + +export function getChartInsertLock(lockKey: string, timeout = 30 * 1000) { + return lock(`chart-insert:${lockKey}`, timeout); +} diff --git a/packages/backend/src/misc/before-shutdown.ts b/packages/backend/src/misc/before-shutdown.ts new file mode 100644 index 0000000..0820418 --- /dev/null +++ b/packages/backend/src/misc/before-shutdown.ts @@ -0,0 +1,103 @@ +// https://gist.github.com/nfantone/1eaa803772025df69d07f4dbf5df7e58 + +"use strict"; + +/** + * @callback BeforeShutdownListener + * @param {string} [signalOrEvent] The exit signal or event name received on the process. + */ + +/** + * System signals the app will listen to initiate shutdown. + * @const {string[]} + */ +const SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM"]; + +/** + * Time in milliseconds to wait before forcing shutdown. + * @const {number} + */ +const SHUTDOWN_TIMEOUT = 15000; + +/** + * A queue of listener callbacks to execute before shutting + * down the process. + * @type {BeforeShutdownListener[]} + */ +const shutdownListeners: ((signalOrEvent: string) => void)[] = []; + +/** + * Listen for signals and execute given `fn` function once. + * @param {string[]} signals System signals to listen to. + * @param {function(string)} fn Function to execute on shutdown. + */ +const processOnce = ( + signals: string[], + fn: (signalOrEvent: string) => void, +) => { + for (const sig of signals) { + process.once(sig, fn); + } +}; + +/** + * Sets a forced shutdown mechanism that will exit the process after `timeout` milliseconds. + * @param {number} timeout Time to wait before forcing shutdown (milliseconds) + */ +const forceExitAfter = (timeout: number) => () => { + setTimeout(() => { + // Force shutdown after timeout + console.warn( + `Could not close resources gracefully after ${timeout}ms: forcing shutdown`, + ); + return process.exit(1); + }, timeout).unref(); +}; + +/** + * Main process shutdown handler. Will invoke every previously registered async shutdown listener + * in the queue and exit with a code of `0`. Any `Promise` rejections from any listener will + * be logged out as a warning, but won't prevent other callbacks from executing. + * @param {string} signalOrEvent The exit signal or event name received on the process. + */ +async function shutdownHandler(signalOrEvent: string) { + if (process.env.NODE_ENV === "test") return process.exit(0); + + console.warn(`Shutting down: received [${signalOrEvent}] signal`); + + for (const listener of shutdownListeners) { + try { + await listener(signalOrEvent); + } catch (err) { + if (err instanceof Error) { + console.warn( + `A shutdown handler failed before completing with: ${ + err.message || err + }`, + ); + } + } + } + + return process.exit(0); +} + +/** + * Registers a new shutdown listener to be invoked before exiting + * the main process. Listener handlers are guaranteed to be called in the order + * they were registered. + * @param {BeforeShutdownListener} listener The shutdown listener to register. + * @returns {BeforeShutdownListener} Echoes back the supplied `listener`. + */ +export function beforeShutdown(listener: () => void) { + shutdownListeners.push(listener); + return listener; +} + +// Register shutdown callback that kills the process after `SHUTDOWN_TIMEOUT` milliseconds +// This prevents custom shutdown handlers from hanging the process indefinitely +processOnce(SHUTDOWN_SIGNALS, forceExitAfter(SHUTDOWN_TIMEOUT)); + +// Register process shutdown callback +// Will listen to incoming signal events and execute all registered handlers in the stack +processOnce(SHUTDOWN_SIGNALS, shutdownHandler); diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts new file mode 100644 index 0000000..f4e7a07 --- /dev/null +++ b/packages/backend/src/misc/cache.ts @@ -0,0 +1,134 @@ +import { redisClient } from "@/db/redis.js"; +import { encode, decode } from "msgpackr"; +import { ChainableCommander } from "ioredis"; +import config from "@/config/index.js"; + +export class Cache { + private ttl: number; + private prefix: string; + + constructor(name: string, ttlSeconds: number) { + this.ttl = ttlSeconds; + this.prefix = `cache:${name}`; + } + + private prefixedKey(key: string | null): string { + return key ? `${this.prefix}:${key}` : this.prefix; + } + + public async set( + key: string | null, + value: T, + transaction?: ChainableCommander, + ): Promise { + const _key = this.prefixedKey(key); + const _value = Buffer.from(encode(value)); + const commander = transaction ?? redisClient; + await commander.set(_key, _value, "EX", this.ttl); + } + + public async get(key: string | null, renew = false): Promise { + const _key = this.prefixedKey(key); + const cached = await redisClient.getBuffer(_key); + if (cached === null) return undefined; + + if (renew) await redisClient.expire(_key, this.ttl); + + return decode(cached) as T; + } + + public async getAll(renew = false): Promise> { + const finalPrefix = `${config.redis.prefix}:${this.prefix}:`; + const keys = (await redisClient.keys(`${finalPrefix}*`)).map(p => p.substring(finalPrefix.length)); + const prefixedKeys = keys.map(p => this.prefixedKey(p)); + const map = new Map(); + if (keys.length === 0) { + return map; + } + const values = await redisClient.mgetBuffer(prefixedKeys); + + for (const [i, key] of keys.entries()) { + const val = values[i]; + if (val !== null) { + map.set(key, decode(val) as T); + } + } + + if (renew) { + const trans = redisClient.multi(); + for (const key of map.keys()) { + trans.expire(this.prefixedKey(key), this.ttl); + } + await trans.exec(); + } + + return map; + } + + public async delete(...keys: (string | null)[]): Promise { + if (keys.length > 0) { + const _keys = keys.map(p => this.prefixedKey(p)); + await redisClient.del(_keys); + } + } + + /** + * Returns if cached value exists. Otherwise, calls fetcher and caches. + * Overwrites cached value if invalidated by the optional validator. + */ + public async fetch( + key: string | null, + fetcher: () => Promise, + renew = false, + validator?: (cachedValue: T) => boolean, + ): Promise { + const cachedValue = await this.get(key, renew); + if (cachedValue !== undefined) { + if (validator) { + if (validator(cachedValue)) { + // Cache HIT + return cachedValue; + } + } else { + // Cache HIT + return cachedValue; + } + } + + // Cache MISS + const value = await fetcher(); + await this.set(key, value); + return value; + } + + /** + * Returns if cached value exists. Otherwise, calls fetcher and caches if the fetcher returns a value. + * Overwrites cached value if invalidated by the optional validator. + */ + public async fetchMaybe( + key: string | null, + fetcher: () => Promise, + renew = false, + validator?: (cachedValue: T) => boolean, + ): Promise { + const cachedValue = await this.get(key, renew); + if (cachedValue !== undefined) { + if (validator) { + if (validator(cachedValue)) { + // Cache HIT + return cachedValue; + } + } else { + // Cache HIT + return cachedValue; + } + } + + // Cache MISS + const value = await fetcher(); + if (value !== undefined) { + await this.set(key, value); + } + return value; + } +} diff --git a/packages/backend/src/misc/captcha.ts b/packages/backend/src/misc/captcha.ts new file mode 100644 index 0000000..8ea4abe --- /dev/null +++ b/packages/backend/src/misc/captcha.ts @@ -0,0 +1,73 @@ +import fetch from "node-fetch"; +import { URLSearchParams } from "node:url"; +import { getAgentByUrl } from "./fetch.js"; +import config from "@/config/index.js"; + +export async function verifyRecaptcha(secret: string, response: string) { + const result = await getCaptchaResponse( + "https://www.recaptcha.net/recaptcha/api/siteverify", + secret, + response, + ).catch((e) => { + throw new Error(`recaptcha-request-failed: ${e.message}`); + }); + + if (result.success !== true) { + const errorCodes = result["error-codes"] + ? result["error-codes"]?.join(", ") + : ""; + throw new Error(`recaptcha-failed: ${errorCodes}`); + } +} + +export async function verifyHcaptcha(secret: string, response: string) { + const result = await getCaptchaResponse( + "https://hcaptcha.com/siteverify", + secret, + response, + ).catch((e) => { + throw new Error(`hcaptcha-request-failed: ${e.message}`); + }); + + if (result.success !== true) { + const errorCodes = result["error-codes"] + ? result["error-codes"]?.join(", ") + : ""; + throw new Error(`hcaptcha-failed: ${errorCodes}`); + } +} + +type CaptchaResponse = { + success: boolean; + "error-codes"?: string[]; +}; + +async function getCaptchaResponse( + url: string, + secret: string, + response: string, +): Promise { + const params = new URLSearchParams({ + secret, + response, + }); + + const res = await fetch(url, { + method: "POST", + body: params, + headers: { + "User-Agent": config.userAgent, + }, + // TODO + //timeout: 10 * 1000, + agent: getAgentByUrl, + }).catch((e) => { + throw new Error(`${e.message || e}`); + }); + + if (!res.ok) { + throw new Error(`${res.status}`); + } + + return (await res.json()) as CaptchaResponse; +} diff --git a/packages/backend/src/misc/check-hit-antenna.ts b/packages/backend/src/misc/check-hit-antenna.ts new file mode 100644 index 0000000..b914450 --- /dev/null +++ b/packages/backend/src/misc/check-hit-antenna.ts @@ -0,0 +1,138 @@ +import type { Antenna } from "@/models/entities/antenna.js"; +import type { Note } from "@/models/entities/note.js"; +import type { User } from "@/models/entities/user.js"; +import { + UserListJoinings, + UserGroupJoinings, + Blockings, +} from "@/models/index.js"; +import { getFullApAccount } from "./convert-host.js"; +import * as Acct from "@/misc/acct.js"; +import type { Packed } from "./schema.js"; +import { Cache } from "./cache.js"; + +const blockingCache = new Cache("blocking", 60 * 5); + +// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている + +/** + * noteUserFollowers / antennaUserFollowing はどちらか一方が指定されていればよい + */ +export async function checkHitAntenna( + antenna: Antenna, + note: Note | Packed<"Note">, + noteUser: { id: User["id"]; username: string; host: string | null }, + noteUserFollowers?: User["id"][], + antennaUserFollowing?: User["id"][], +): Promise { + if (note.visibility === "specified") return false; + if (note.visibility === "home") return false; + + // アンテナ作成者がノート作成者にブロックされていたらスキップ + const blockings = await blockingCache.fetch(noteUser.id, () => + Blockings.findBy({ blockerId: noteUser.id }).then((res) => + res.map((x) => x.blockeeId), + ), + ); + if (blockings.some((blocking) => blocking === antenna.userId)) return false; + + if (note.visibility === "followers") { + if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) + return false; + if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) + return false; + } + + if (!antenna.withReplies && note.replyId != null) return false; + + if (antenna.src === "home") { + if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) + return false; + if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) + return false; + } else if (antenna.src === "list") { + const listUsers = ( + await UserListJoinings.findBy({ + userListId: antenna.userListId!, + }) + ).map((x) => x.userId); + + if (!listUsers.includes(note.userId)) return false; + } else if (antenna.src === "group") { + const joining = await UserGroupJoinings.findOneByOrFail({ + id: antenna.userGroupJoiningId!, + }); + + const groupUsers = ( + await UserGroupJoinings.findBy({ + userGroupId: joining.userGroupId, + }) + ).map((x) => x.userId); + + if (!groupUsers.includes(note.userId)) return false; + } else if (antenna.src === "users") { + const accts = antenna.users.map((x) => { + const { username, host } = Acct.parse(x); + return getFullApAccount(username, host).toLowerCase(); + }); + if ( + !accts.includes( + getFullApAccount(noteUser.username, noteUser.host).toLowerCase(), + ) + ) + return false; + } else if (antenna.src === "instances") { + const instances = antenna.instances + .filter((x) => x !== "") + .map((host) => { + return host.toLowerCase(); + }); + if (!instances.includes(noteUser.host?.toLowerCase() ?? "")) return false; + } + + const keywords = antenna.keywords + // Clean up + .map((xs) => xs.filter((x) => x !== "")) + .filter((xs) => xs.length > 0); + + if (keywords.length > 0) { + if (note.text == null) return false; + + const matched = keywords.some((and) => + and.every((keyword) => + antenna.caseSensitive + ? note.text!.includes(keyword) || note.cw?.includes(keyword) + : note.text!.toLowerCase().includes(keyword.toLowerCase()) || note.cw?.toLowerCase().includes(keyword.toLowerCase()), + ), + ); + + if (!matched) return false; + } + + const excludeKeywords = antenna.excludeKeywords + // Clean up + .map((xs) => xs.filter((x) => x !== "")) + .filter((xs) => xs.length > 0); + + if (excludeKeywords.length > 0) { + if (note.text == null) return false; + + const matched = excludeKeywords.some((and) => + and.every((keyword) => + antenna.caseSensitive + ? note.text!.includes(keyword) || note.cw?.includes(keyword) + : note.text!.toLowerCase().includes(keyword.toLowerCase()) || note.cw?.toLowerCase().includes(keyword.toLowerCase()), + ), + ); + + if (matched) return false; + } + + if (antenna.withFile) { + if (note.fileIds && note.fileIds.length === 0) return false; + } + + // TODO: eval expression + + return true; +} diff --git a/packages/backend/src/misc/check-word-mute.ts b/packages/backend/src/misc/check-word-mute.ts new file mode 100644 index 0000000..868008c --- /dev/null +++ b/packages/backend/src/misc/check-word-mute.ts @@ -0,0 +1,79 @@ +import RE2 from "re2"; +import type { Note } from "@/models/entities/note.js"; +import type { User } from "@/models/entities/user.js"; +import { Packed } from "@/misc/schema.js"; + +type NoteLike = { + userId: Note["userId"]; + text: Note["text"]; + files?: Note["files"]; + cw?: Note["cw"]; + reply?: Note["reply"]; + renote?: Note["renote"]; + isFiltered?: Packed<"Note">["isFiltered"]; +}; + +type UserLike = { + id: User["id"]; +}; + +function checkWordMute( + note: NoteLike | null | undefined, + mutedWords: Array, +): boolean { + if (note == null) return false; + + let text = `${note.cw ?? ""} ${note.text ?? ""}`; + if (note.files != null) + text += ` ${note.files.map((f) => f.comment ?? "").join(" ")}`; + text = text.trim().toLowerCase(); + + if (text === "") return false; + + for (const mutePattern of mutedWords) { + if (Array.isArray(mutePattern)) { + // Clean up + const keywords = mutePattern.filter((keyword) => keyword !== ""); + + if ( + keywords.length > 0 && + keywords.every((keyword) => text.includes(keyword.toLowerCase())) + ) + return true; + } else { + // represents RegExp + const regexp = mutePattern.match(/^\/(.+)\/(.*)$/); + + // This should never happen due to input sanitisation. + if (!regexp) { + console.warn(`Found invalid regex in word mutes: ${mutePattern}`); + continue; + } + + try { + if (new RE2(regexp[1], regexp[2]).test(text)) return true; + } catch (err) { + // This should never happen due to input sanitisation. + } + } + } + + return false; +} + +export async function getWordHardMute( + note: NoteLike, + me: UserLike | null | undefined, + mutedWords: Array, +): Promise { + // 自分自身 + if (me && note.userId === me.id) return false; + if (mutedWords.length <= 0) return false; + if (note.isFiltered) return true; + + return ( + checkWordMute(note, mutedWords) || + checkWordMute(note.reply, mutedWords) || + checkWordMute(note.renote, mutedWords) + ); +} diff --git a/packages/backend/src/misc/checked-fetch.ts b/packages/backend/src/misc/checked-fetch.ts new file mode 100644 index 0000000..d3a4ff8 --- /dev/null +++ b/packages/backend/src/misc/checked-fetch.ts @@ -0,0 +1,78 @@ +import * as http from "node:http"; +import * as https from "node:https"; +import net from "node:net"; +import { HttpProxyAgent, HttpsProxyAgent } from "hpagent"; +import config from "@/config/index.js"; +import IPCIDR from "ip-cidr"; +import PrivateIp from "private-ip"; +import { Duplex } from "node:stream"; +import { ClientRequestArgs } from "node:http"; + +declare module 'node:http' { + interface Agent { + createConnection(options: ClientRequestArgs, callback?: (err: Error | null, stream: Duplex) => void): Duplex | undefined | null; + } +} + +function isPrivateIp(ip: string): boolean { + for (const net of config.allowedPrivateNetworks || []) { + const cidr = new IPCIDR(net); + if (cidr.contains(ip)) { + return false; + } + } + + return PrivateIp(ip); +} + +function checkConnection(socket: Duplex) { + if (socket instanceof net.Socket) { + const address = socket.remoteAddress; + if (process.env.NODE_ENV === 'production') { + if (address && IPCIDR.isValidAddress(address) && isPrivateIp(address)) { + socket.destroy(new Error(`Blocked address: ${address}`)); + } + } + } else { + throw "Tried to check connection for type that isn't net.Socket"; + } +} + +export class CheckedHttpAgent extends http.Agent { + createConnection(options: ClientRequestArgs, callback?: (err: Error | null, stream: Duplex) => void): Duplex | undefined | null { + const socket = super.createConnection(options, callback ? (err, stream) => { + if (stream) checkConnection(stream); + callback(err, stream); + } : undefined)?.on('connect', () => { socket && checkConnection(socket) }); + return socket; + } +} + +export class CheckedHttpsAgent extends https.Agent { + createConnection(options: ClientRequestArgs, callback?: (err: Error | null, stream: Duplex) => void): Duplex | undefined | null { + const socket = super.createConnection(options, callback ? (err, stream) => { + if (stream) checkConnection(stream); + callback(err, stream); + } : undefined)?.on('connect', () => { socket && checkConnection(socket) }); + return socket; + } +} +export class CheckedHttpProxyAgent extends HttpProxyAgent { + createConnection(options: ClientRequestArgs, callback?: (err: Error | null, stream: Duplex) => void): Duplex | undefined | null { + const socket = super.createConnection(options, callback ? (err, stream) => { + if (stream) checkConnection(stream); + callback(err, stream); + } : undefined)?.on('connect', () => { socket && checkConnection(socket) }); + return socket; + } +} + +export class CheckedHttpsProxyAgent extends HttpsProxyAgent { + createConnection(options: ClientRequestArgs, callback?: (err: Error | null, stream: Duplex) => void): Duplex | undefined | null { + const socket = super.createConnection(options, callback ? (err, stream) => { + if (stream) checkConnection(stream); + callback(err, stream); + } : undefined)?.on('connect', () => { socket && checkConnection(socket) }); + return socket; + } +} diff --git a/packages/backend/src/misc/clone.ts b/packages/backend/src/misc/clone.ts new file mode 100644 index 0000000..4322e2e --- /dev/null +++ b/packages/backend/src/misc/clone.ts @@ -0,0 +1,24 @@ +// structredCloneが遅いため +// SEE: http://var.blog.jp/archives/86038606.html + +type Cloneable = + | string + | number + | boolean + | null + | { [key: string]: Cloneable } + | Cloneable[]; + +export function deepClone(x: T): T { + if (typeof x === "object") { + if (x === null) return x; + if (Array.isArray(x)) return x.map(deepClone) as T; + const obj = {} as Record; + for (const [k, v] of Object.entries(x)) { + obj[k] = deepClone(v); + } + return obj as T; + } else { + return x; + } +} diff --git a/packages/backend/src/misc/content-disposition.ts b/packages/backend/src/misc/content-disposition.ts new file mode 100644 index 0000000..25d6f58 --- /dev/null +++ b/packages/backend/src/misc/content-disposition.ts @@ -0,0 +1,9 @@ +import cd from "content-disposition"; + +export function contentDisposition( + type: "inline" | "attachment", + filename: string, +): string { + const fallback = filename.replace(/[^\w.-]/g, "_"); + return cd(filename, { type, fallback }); +} diff --git a/packages/backend/src/misc/convert-host.ts b/packages/backend/src/misc/convert-host.ts new file mode 100644 index 0000000..949aced --- /dev/null +++ b/packages/backend/src/misc/convert-host.ts @@ -0,0 +1,28 @@ +import { URL } from "node:url"; +import config from "@/config/index.js"; +import punycode from "punycode/"; + +export function getFullApAccount(username: string, host: string | null) { + return host + ? `${username}@${toPuny(host)}` + : `${username}@${toPuny(config.domain)}`; +} + +export function isSelfHost(host: string) { + if (host == null) return true; + return toPuny(config.domain) === toPuny(host) || toPuny(config.host) === toPuny(host); +} + +export function extractDbHost(uri: string) { + const url = new URL(uri); + return toPuny(url.hostname); +} + +export function toPuny(host: string) { + return punycode.toASCII(host.toLowerCase()); +} + +export function toPunyNullable(host: string | null | undefined): string | null { + if (host == null) return null; + return punycode.toASCII(host.toLowerCase()); +} diff --git a/packages/backend/src/misc/convert-milliseconds.ts b/packages/backend/src/misc/convert-milliseconds.ts new file mode 100644 index 0000000..d8c163f --- /dev/null +++ b/packages/backend/src/misc/convert-milliseconds.ts @@ -0,0 +1,17 @@ +export function convertMilliseconds(ms: number) { + let seconds = Math.round(ms / 1000); + let minutes = Math.round(seconds / 60); + let hours = Math.round(minutes / 60); + const days = Math.round(hours / 24); + seconds %= 60; + minutes %= 60; + hours %= 24; + + const result = []; + if (days > 0) result.push(`${days} day(s)`); + if (hours > 0) result.push(`${hours} hour(s)`); + if (minutes > 0) result.push(`${minutes} minute(s)`); + if (seconds > 0) result.push(`${seconds} second(s)`); + + return result.join(", "); +} diff --git a/packages/backend/src/misc/count-same-renotes.ts b/packages/backend/src/misc/count-same-renotes.ts new file mode 100644 index 0000000..772170f --- /dev/null +++ b/packages/backend/src/misc/count-same-renotes.ts @@ -0,0 +1,26 @@ +import { Notes } from "@/models/index.js"; + +export async function countSameRenotes( + userId: string, + renoteId: string, + excludeNoteId: string | undefined, + groupId?: string | null, +): Promise { + // 指定したユーザーの指定したノートのリノートがいくつあるか数える + const query = Notes.createQueryBuilder("note") + .where("note.userId = :userId", { userId }) + .andWhere("note.renoteId = :renoteId", { renoteId }); + + if (groupId) { + query.andWhere("note.groupId = :groupId", { groupId }); + } else { + query.andWhere("note.groupId IS NULL"); + } + + // 指定した投稿を除く + if (excludeNoteId) { + query.andWhere("note.id != :excludeNoteId", { excludeNoteId }); + } + + return await query.getCount(); +} diff --git a/packages/backend/src/misc/create-temp.ts b/packages/backend/src/misc/create-temp.ts new file mode 100644 index 0000000..16c85ee --- /dev/null +++ b/packages/backend/src/misc/create-temp.ts @@ -0,0 +1,24 @@ +import * as tmp from "tmp"; + +export function createTemp(): Promise<[string, () => void]> { + return new Promise<[string, () => void]>((res, rej) => { + tmp.file((e, path, fd, cleanup) => { + if (e) return rej(e); + res([path, cleanup]); + }); + }); +} + +export function createTempDir(): Promise<[string, () => void]> { + return new Promise<[string, () => void]>((res, rej) => { + tmp.dir( + { + unsafeCleanup: true, + }, + (e, path, cleanup) => { + if (e) return rej(e); + res([path, cleanup]); + }, + ); + }); +} diff --git a/packages/backend/src/misc/detect-url-mime.ts b/packages/backend/src/misc/detect-url-mime.ts new file mode 100644 index 0000000..9f0e432 --- /dev/null +++ b/packages/backend/src/misc/detect-url-mime.ts @@ -0,0 +1,15 @@ +import { createTemp } from "./create-temp.js"; +import { downloadUrl } from "./download-url.js"; +import { detectType } from "./get-file-info.js"; + +export async function detectUrlMime(url: string) { + const [path, cleanup] = await createTemp(); + + try { + await downloadUrl(url, path); + const { mime } = await detectType(path); + return mime; + } finally { + cleanup(); + } +} diff --git a/packages/backend/src/misc/download-text-file.ts b/packages/backend/src/misc/download-text-file.ts new file mode 100644 index 0000000..9d3821b --- /dev/null +++ b/packages/backend/src/misc/download-text-file.ts @@ -0,0 +1,25 @@ +import * as fs from "node:fs"; +import * as util from "node:util"; +import Logger from "@/services/logger.js"; +import { createTemp } from "./create-temp.js"; +import { downloadUrl } from "./download-url.js"; + +const logger = new Logger("download-text-file"); + +export async function downloadTextFile(url: string): Promise { + // Create temp file + const [path, cleanup] = await createTemp(); + + logger.info(`Temp file is ${path}`); + + try { + // write content at URL to temp file + await downloadUrl(url, path); + + const text = await util.promisify(fs.readFile)(path, "utf8"); + + return text; + } finally { + cleanup(); + } +} diff --git a/packages/backend/src/misc/download-url.ts b/packages/backend/src/misc/download-url.ts new file mode 100644 index 0000000..fca6dc3 --- /dev/null +++ b/packages/backend/src/misc/download-url.ts @@ -0,0 +1,80 @@ +import * as fs from "node:fs"; +import * as stream from "node:stream"; +import * as util from "node:util"; +import got, * as Got from "got"; +import { httpAgent, httpsAgent, StatusError } from "./fetch.js"; +import config from "@/config/index.js"; +import chalk from "chalk"; +import Logger from "@/services/logger.js"; + +const pipeline = util.promisify(stream.pipeline); + +export async function downloadUrl(url: string, path: string): Promise { + const logger = new Logger("download"); + + logger.info(`Downloading ${chalk.cyan(url)} ...`); + + const timeout = 30 * 1000; + const operationTimeout = 60 * 1000; + const maxSize = config.maxFileSize || 262144000; + + const req = got + .stream(url, { + headers: { + "User-Agent": config.userAgent, + Host: new URL(url).hostname, + }, + timeout: { + lookup: timeout, + connect: timeout, + secureConnect: timeout, + socket: timeout, // read timeout + response: timeout, + send: timeout, + request: operationTimeout, // whole operation timeout + }, + agent: { + http: httpAgent, + https: httpsAgent, + }, + http2: false, // default + retry: { + limit: 0, + }, + }) + .on("response", (res: Got.Response) => { + const contentLength = res.headers["content-length"]; + if (contentLength != null) { + const size = Number(contentLength); + if (size > maxSize) { + logger.warn(`maxSize exceeded (${size} > ${maxSize}) on response`); + req.destroy(); + } + } + }) + .on("downloadProgress", (progress: Got.Progress) => { + if (progress.transferred > maxSize) { + logger.warn( + `maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`, + ); + req.destroy(); + } + }); + + try { + await pipeline(req, fs.createWriteStream(path)); + } catch (e) { + if (e instanceof Got.HTTPError) { + throw new StatusError( + `${e.response.statusCode} ${e.response.statusMessage}`, + e.response.statusCode, + e.response.statusMessage, + ); + } else { + throw e; + } + } + + logger.succ(`Download finished: ${chalk.cyan(url)}`); +} + diff --git a/packages/backend/src/misc/emoji-meta.ts b/packages/backend/src/misc/emoji-meta.ts new file mode 100644 index 0000000..2b9365b --- /dev/null +++ b/packages/backend/src/misc/emoji-meta.ts @@ -0,0 +1,58 @@ +import probeImageSize from "probe-image-size"; +import { Mutex } from "redis-semaphore"; + +import { FILE_TYPE_BROWSERSAFE } from "@/const.js"; +import Logger from "@/services/logger.js"; +import { Cache } from "./cache.js"; +import { redisClient } from "@/db/redis.js"; + +export type Size = { + width: number; + height: number; +}; + +const cache = new Cache("emojiMeta", 60 * 10); // once every 10 minutes for the same url +const logger = new Logger("emoji"); + +export async function getEmojiSize(url: string): Promise { + let attempted = true; + + const lock = new Mutex(redisClient, "getEmojiSize"); + await lock.acquire(); + + try { + attempted = (await cache.get(url)) === true; + if (!attempted) { + await cache.set(url, true); + } + } finally { + await lock.release(); + } + + if (attempted) { + logger.warn(`Attempt limit exceeded: ${url}`); + throw new Error("Too many attempts"); + } + + try { + logger.debug(`Retrieving emoji size from ${url}`); + const { width, height, mime } = await probeImageSize(url, { + timeout: 5000, + }); + if (!(mime.startsWith("image/") && FILE_TYPE_BROWSERSAFE.includes(mime))) { + throw new Error("Unsupported image type"); + } + return { width, height }; + } catch (e) { + throw new Error(`Unable to retrieve metadata: ${e}`); + } +} + +export function getNormalSize( + { width, height }: Size, + orientation?: number, +): Size { + return (orientation || 0) >= 5 + ? { width: height, height: width } + : { width, height }; +} diff --git a/packages/backend/src/misc/emoji-regex.ts b/packages/backend/src/misc/emoji-regex.ts new file mode 100644 index 0000000..72d6a62 --- /dev/null +++ b/packages/backend/src/misc/emoji-regex.ts @@ -0,0 +1,5 @@ +import twemoji from "@twemoji/parser/dist/lib/regex.js"; +const twemojiRegex = twemoji.default; + +export const emojiRegex = new RegExp(`(${twemojiRegex.source})`); +export const emojiRegexAtStartToEnd = new RegExp(`^(${twemojiRegex.source})$`); diff --git a/packages/backend/src/misc/extract-custom-emojis-from-mfm.test.ts b/packages/backend/src/misc/extract-custom-emojis-from-mfm.test.ts new file mode 100644 index 0000000..0a9fd93 --- /dev/null +++ b/packages/backend/src/misc/extract-custom-emojis-from-mfm.test.ts @@ -0,0 +1,23 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as mfm from "mfm-js"; +import { + extractCustomEmojiNamesFromText, + extractCustomEmojisFromMfm, +} from "./extract-custom-emojis-from-mfm.js"; + +test("extractCustomEmojiNamesFromText includes glyph syntax", () => { + assert.deepEqual( + extractCustomEmojiNamesFromText([ + "plain :blobcat: and ;glyph; and ;glyph@example.com;", + ]), + ["blobcat", "glyph", "glyph@example.com"], + ); +}); + +test("extractCustomEmojisFromMfm includes glyph syntax inside text nodes", () => { + assert.deepEqual( + extractCustomEmojisFromMfm(mfm.parse("hello :blobcat: ;glyph;")), + ["blobcat", "glyph"], + ); +}); diff --git a/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts b/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts new file mode 100644 index 0000000..f6e9e7b --- /dev/null +++ b/packages/backend/src/misc/extract-custom-emojis-from-mfm.ts @@ -0,0 +1,30 @@ +import * as mfm from "mfm-js"; +import { unique } from "@/prelude/array.js"; + +const wrappedEmojiRegex = /([:;])([^:;\s]{1,100})\1/g; +const glyphEmojiRegex = /;([^:;\s]{1,100});/g; + +export function extractCustomEmojisFromMfm(nodes: mfm.MfmNode[]): string[] { + const emojiNodes = mfm.extract(nodes, (node) => { + return node.type === "emojiCode" && node.props.name.length <= 100; + }); + + const glyphNames = mfm + .extract(nodes, (node) => node.type === "text") + .flatMap((node) => + Array.from(node.props.text.matchAll(glyphEmojiRegex), (match) => match[1]), + ); + + return unique([...emojiNodes.map((x) => x.props.name), ...glyphNames]); +} + +export function extractCustomEmojiNamesFromText(texts: (string | null | undefined)[]): string[] { + const emojis: string[] = []; + for (const text of texts) { + if (!text) continue; + for (const match of text.matchAll(wrappedEmojiRegex)) { + emojis.push(match[2]); + } + } + return unique(emojis); +} diff --git a/packages/backend/src/misc/extract-group-mentions.ts b/packages/backend/src/misc/extract-group-mentions.ts new file mode 100644 index 0000000..9df91e2 --- /dev/null +++ b/packages/backend/src/misc/extract-group-mentions.ts @@ -0,0 +1,49 @@ +import { UserGroupJoinings, UserGroups, Users } from "@/models/index.js"; +import type { User } from "@/models/entities/user.js"; +import { In } from "typeorm"; + +const GROUP_MENTION = /(^|[^\w@])@@([a-zA-Z0-9_]{1,64})\b/g; +const WRAPPED_ASSET = /[:;][^:;\s]{1,100}[:;]/g; + +export function extractGroupMentionNames(texts: (string | null | undefined)[]): string[] { + const names = new Set(); + + for (const text of texts) { + if (!text) continue; + const sanitized = text.replace(WRAPPED_ASSET, (match) => " ".repeat(match.length)); + for (const match of sanitized.matchAll(GROUP_MENTION)) { + names.add(match[2].toLowerCase()); + } + } + + return [...names]; +} + +export async function extractGroupMentionedUsers( + texts: (string | null | undefined)[], +): Promise { + const names = extractGroupMentionNames(texts); + if (names.length === 0) return []; + + const groups = await UserGroups.find({ + where: names.map((username) => ({ username })), + }); + const mentionedUserIds = new Set(); + + for (const group of groups) { + mentionedUserIds.add(group.userId); + + const joinings = await UserGroupJoinings.findBy({ + userGroupId: group.id, + }); + for (const joining of joinings) { + mentionedUserIds.add(joining.userId); + } + } + + if (mentionedUserIds.size === 0) return []; + + return await Users.findBy({ + id: In([...mentionedUserIds] as User["id"][]), + }); +} diff --git a/packages/backend/src/misc/extract-hashtags.ts b/packages/backend/src/misc/extract-hashtags.ts new file mode 100644 index 0000000..826e362 --- /dev/null +++ b/packages/backend/src/misc/extract-hashtags.ts @@ -0,0 +1,9 @@ +import * as mfm from "mfm-js"; +import { unique } from "@/prelude/array.js"; + +export function extractHashtags(nodes: mfm.MfmNode[]): string[] { + const hashtagNodes = mfm.extract(nodes, (node) => node.type === "hashtag"); + const hashtags = unique(hashtagNodes.map((x) => x.props.hashtag)); + + return hashtags; +} diff --git a/packages/backend/src/misc/extract-mentions.test.ts b/packages/backend/src/misc/extract-mentions.test.ts new file mode 100644 index 0000000..2d95623 --- /dev/null +++ b/packages/backend/src/misc/extract-mentions.test.ts @@ -0,0 +1,27 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as mfm from "mfm-js"; +import { extractMentions } from "./extract-mentions.js"; + +test("extractMentions keeps regular mentions", () => { + const mentions = extractMentions(mfm.parse("hello @alice and @bob@example.com")); + + assert.deepEqual(mentions, [ + { username: "alice", host: null, acct: "@alice" }, + { + username: "bob", + host: "example.com", + acct: "@bob@example.com", + }, + ]); +}); + +test("extractMentions ignores user icon syntax", () => { + const mentions = extractMentions( + mfm.parse("regular @alice icon :@alice: remote :@bob@example.com:"), + ); + + assert.deepEqual(mentions, [ + { username: "alice", host: null, acct: "@alice" }, + ]); +}); diff --git a/packages/backend/src/misc/extract-mentions.ts b/packages/backend/src/misc/extract-mentions.ts new file mode 100644 index 0000000..4015203 --- /dev/null +++ b/packages/backend/src/misc/extract-mentions.ts @@ -0,0 +1,42 @@ +// test is located in test/extract-mentions + +import * as mfm from "mfm-js"; + +export function extractMentions( + nodes: mfm.MfmNode[], +): mfm.MfmMention["props"][] { + const mentions: mfm.MfmMention["props"][] = []; + + collectMentions(nodes, mentions); + + return mentions; +} + +function collectMentions( + nodes: mfm.MfmNode[], + mentions: mfm.MfmMention["props"][], +): void { + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + + if (node.type === "mention" && !isWrappedUserEmojiMention(nodes, i)) { + mentions.push(node.props); + } + + if ("children" in node && Array.isArray(node.children)) { + collectMentions(node.children, mentions); + } + } +} + +function isWrappedUserEmojiMention(nodes: mfm.MfmNode[], index: number): boolean { + const previous = nodes[index - 1]; + const next = nodes[index + 1]; + + return ( + previous?.type === "text" && + next?.type === "text" && + previous.props.text.endsWith(":") && + next.props.text.startsWith(":") + ); +} diff --git a/packages/backend/src/misc/fetch-meta.ts b/packages/backend/src/misc/fetch-meta.ts new file mode 100644 index 0000000..119db91 --- /dev/null +++ b/packages/backend/src/misc/fetch-meta.ts @@ -0,0 +1,73 @@ +import { db } from "@/db/postgre.js"; +import { Meta } from "@/models/entities/meta.js"; +import push from 'web-push'; +import { Metas } from "@/models/index.js"; + +let cache: Meta; + +export function metaToPugArgs(meta: Meta): object { + let motd = ["Loading..."]; + if (meta.customMOTD.length > 0) { + motd = meta.customMOTD; + } + let splashIconUrl = meta.iconUrl; + if (meta.customSplashIcons.length > 0) { + splashIconUrl = + meta.customSplashIcons[ + Math.floor(Math.random() * meta.customSplashIcons.length) + ]; + } + + return { + img: meta.bannerUrl, + title: meta.name || "FrozenFriendsYume", + instanceName: meta.name || "FrozenFriendsYume", + desc: meta.description, + icon: meta.iconUrl, + splashIcon: splashIconUrl, + themeColor: meta.themeColor, + randomMOTD: motd[Math.floor(Math.random() * motd.length)], + privateMode: meta.privateMode, + }; +} + +export function fetchMetaSync(): Meta | null { + return cache; +} + + +export async function fetchMeta(noCache = false): Promise { + if (!noCache && cache) return cache; + + // New IDs are prioritized because multiple records may have been created due to past bugs. + const meta = await Metas.findOne({ + where: {}, + order: { + id: "DESC", + }, + }); + + if (meta) { + cache = meta; + return meta; + } + + const { publicKey, privateKey } = push.generateVAPIDKeys(); + const data = { + id: "x", + swPublicKey: publicKey, + swPrivateKey: privateKey, + }; + + // If fetchMeta is called at the same time when meta is empty, this part may be called at the same time, so use fail-safe upsert. + await Metas.upsert(data, ["id"]); + + cache = await Metas.findOneByOrFail({ id: data.id }); + return cache; +} + +setInterval(() => { + fetchMeta(true).then((meta) => { + cache = meta; + }); +}, 1000 * 10); diff --git a/packages/backend/src/misc/fetch.ts b/packages/backend/src/misc/fetch.ts new file mode 100644 index 0000000..bbf063d --- /dev/null +++ b/packages/backend/src/misc/fetch.ts @@ -0,0 +1,211 @@ +import * as http from "node:http"; +import * as https from "node:https"; +import type { URL } from "node:url"; +import CacheableLookup from "cacheable-lookup"; +import fetch from "node-fetch"; +import config from "@/config/index.js"; +import net from "node:net"; +import {CheckedHttpAgent, CheckedHttpProxyAgent, CheckedHttpsAgent, CheckedHttpsProxyAgent} from "@/misc/checked-fetch.js"; + +export async function getJson( + url: string, + accept = "application/json, */*", + timeout = 10000, + headers?: Record, +) { + const res = await getResponse({ + url, + method: "GET", + headers: Object.assign( + { + "User-Agent": config.userAgent, + Accept: accept, + }, + headers || {}, + ), + timeout, + }); + + return await res.json(); +} + +export async function getJsonActivity( + url: string, + accept = "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", + timeout = 10000, + headers?: Record, +) { + const res = await getResponse({ + url, + method: "GET", + headers: Object.assign( + { + "User-Agent": config.userAgent, + Accept: accept, + }, + headers || {}, + ), + timeout, + }); + + const contentType = res.headers.get('content-type'); + if (contentType == null || + (contentType !== 'application/activity+json' && !contentType.startsWith('application/activity+json;') && + (!contentType.startsWith('application/ld+json;') || !contentType.includes('profile="https://www.w3.org/ns/activitystreams"')))) { + throw new Error(`getJsonActivity response had unexpected content-type: ${contentType}`); + } + + return { + finalUrl: res.url, + content: await res.json() + } +} + +export async function getHtml( + url: string, + accept = "text/html, */*", + timeout = 10000, + headers?: Record, +) { + const res = await getResponse({ + url, + method: "GET", + headers: Object.assign( + { + "User-Agent": config.userAgent, + Accept: accept, + }, + headers || {}, + ), + timeout, + }); + + return await res.text(); +} + +export async function getResponse(args: { + url: string; + method: string; + body?: string; + headers: Record; + timeout?: number; + redirect?: RequestRedirect; +}) { + const timeout = args.timeout || 10 * 1000; + + const controller = new AbortController(); + setTimeout(() => { + controller.abort(); + }, timeout * 6); + + const res = await fetch(args.url, { + method: args.method, + headers: args.headers, + body: args.body, + timeout, + size: 10 * 1024 * 1024, + agent: getAgentByUrl, + signal: controller.signal, + redirect: args.redirect + }); + + if (args.redirect === "manual" && [301,302,307,308].includes(res.status)) { + return res; + } + + if (!res.ok) { + throw new StatusError( + `${res.status} ${res.statusText}`, + res.status, + res.statusText, + ); + } + + return res; +} + +const cache = new CacheableLookup({ + maxTtl: 3600, // 1hours + errorTtl: 30, // 30secs + lookup: false, // nativeのdns.lookupにfallbackしない +}); + +/** + * Get http non-proxy agent + */ +const _http = new CheckedHttpAgent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + lookup: cache.lookup, +} as http.AgentOptions); + +/** + * Get https non-proxy agent + */ +const _https = new CheckedHttpsAgent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + lookup: cache.lookup, +} as https.AgentOptions); + +const maxSockets = Math.max(256, config.deliverJobConcurrency || 128); + +/** + * Get http proxy or non-proxy agent + */ +export const httpAgent = config.proxy + ? new CheckedHttpProxyAgent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + maxSockets, + maxFreeSockets: 256, + scheduling: "lifo", + proxy: config.proxy, + }) + : _http; + +/** + * Get https proxy or non-proxy agent + */ +export const httpsAgent = config.proxy + ? new CheckedHttpsProxyAgent({ + keepAlive: true, + keepAliveMsecs: 30 * 1000, + maxSockets, + maxFreeSockets: 256, + scheduling: "lifo", + proxy: config.proxy, + }) + : _https; + +/** + * Get agent by URL + * @param url URL + * @param bypassProxy Allways bypass proxy + */ +export function getAgentByUrl(url: URL, bypassProxy = false) { + if (bypassProxy || (config.proxyBypassHosts || []).includes(url.hostname)) { + return url.protocol === "http:" ? _http : _https; + } else { + return url.protocol === "http:" ? httpAgent : httpsAgent; + } +} + +export class StatusError extends Error { + public statusCode: number; + public statusMessage?: string; + public isClientError: boolean; + public isRetryable: boolean; + + constructor(message: string, statusCode: number, statusMessage?: string) { + super(message); + this.name = "StatusError"; + this.statusCode = statusCode; + this.statusMessage = statusMessage; + this.isClientError = + typeof this.statusCode === "number" && + this.statusCode >= 400 && + this.statusCode < 500; + this.isRetryable = this.isClientError && this.statusCode != 429; + } +} diff --git a/packages/backend/src/misc/gen-id.ts b/packages/backend/src/misc/gen-id.ts new file mode 100644 index 0000000..fb92dd8 --- /dev/null +++ b/packages/backend/src/misc/gen-id.ts @@ -0,0 +1,27 @@ +import { init, createId } from "@paralleldrive/cuid2"; +import config from "@/config/index.js"; + +const TIME2000 = 946684800000; +const TIMESTAMP_LENGTH = 8; + +const length = + Math.min(Math.max(config.cuid?.length ?? 16, 16), 24) - TIMESTAMP_LENGTH; +const fingerprint = `${config.cuid?.fingerprint ?? ""}${createId()}`; + +const genCuid2 = init({ length, fingerprint }); + +/** + * The generated ID results in the form of `[8 chars timestamp] + [cuid2]`. + * The minimum and maximum lengths are 16 and 24, respectively. + * With the length of 16, namely 8 for cuid2, roughly 1427399 IDs are needed + * in the same millisecond to reach 50% chance of collision. + * + * Ref: https://github.com/paralleldrive/cuid2#parameterized-length + */ +export function genId(date?: Date): string { + const now = (date ?? new Date()).getTime(); + const time = Math.max(now - TIME2000, 0); + const timestamp = time.toString(36).padStart(TIMESTAMP_LENGTH, "0"); + + return `${timestamp}${genCuid2()}`; +} diff --git a/packages/backend/src/misc/gen-identicon.ts b/packages/backend/src/misc/gen-identicon.ts new file mode 100644 index 0000000..1e51dfe --- /dev/null +++ b/packages/backend/src/misc/gen-identicon.ts @@ -0,0 +1,114 @@ +/** + * Identicon generator + * https://en.wikipedia.org/wiki/Identicon + */ + +import type { WriteStream } from "node:fs"; +import * as p from "pureimage"; +import gen from "random-seed"; + +const size = 128; // px +const n = 5; // resolution +const margin = size / 4; +const colors = [ + ["#eb6f92", "#b4637a"], + ["#f6c177", "#ea9d34"], + ["#ebbcba", "#d7827e"], + ["#9ccfd8", "#56949f"], + ["#c4a7e7", "#907aa9"], + ["#eb6f92", "#f6c177"], + ["#eb6f92", "#ebbcba"], + ["#eb6f92", "#31748f"], + ["#eb6f92", "#9ccfd8"], + ["#eb6f92", "#c4a7e7"], + ["#f6c177", "#eb6f92"], + ["#f6c177", "#ebbcba"], + ["#f6c177", "#31748f"], + ["#f6c177", "#9ccfd8"], + ["#f6c177", "#c4a7e7"], + ["#ebbcba", "#eb6f92"], + ["#ebbcba", "#f6c177"], + ["#ebbcba", "#31748f"], + ["#ebbcba", "#9ccfd8"], + ["#ebbcba", "#c4a7e7"], + ["#31748f", "#eb6f92"], + ["#31748f", "#f6c177"], + ["#31748f", "#ebbcba"], + ["#31748f", "#9ccfd8"], + ["#31748f", "#c4a7e7"], + ["#9ccfd8", "#eb6f92"], + ["#9ccfd8", "#f6c177"], + ["#9ccfd8", "#ebbcba"], + ["#9ccfd8", "#31748f"], + ["#9ccfd8", "#c4a7e7"], + ["#c4a7e7", "#eb6f92"], + ["#c4a7e7", "#f6c177"], + ["#c4a7e7", "#ebbcba"], + ["#c4a7e7", "#31748f"], + ["#c4a7e7", "#9ccfd8"], +]; + +const actualSize = size - margin * 2; +const cellSize = actualSize / n; +const sideN = Math.floor(n / 2); + +/** + * Generate buffer of an identicon by seed + */ +export function genIdenticon(seed: string, stream: WriteStream): Promise { + const rand = gen.create(seed); + const canvas = p.make(size, size, undefined); + const ctx = canvas.getContext("2d"); + + const bgColors = colors[rand(colors.length)]; + + const bg = ctx.createLinearGradient(0, 0, size, size); + bg.addColorStop(0, bgColors[0]); + bg.addColorStop(1, bgColors[1]); + + ctx.fillStyle = bg; + ctx.beginPath(); + ctx.fillRect(0, 0, size, size); + + ctx.fillStyle = "#ffffff"; + + // side bitmap (filled by false) + const side: boolean[][] = new Array(sideN); + for (let i = 0; i < side.length; i++) { + side[i] = new Array(n).fill(false); + } + + // 1*n (filled by false) + const center: boolean[] = new Array(n).fill(false); + + for (let x = 0; x < side.length; x++) { + for (let y = 0; y < side[x].length; y++) { + side[x][y] = rand(3) === 0; + } + } + + for (let i = 0; i < center.length; i++) { + center[i] = rand(3) === 0; + } + + // Draw + for (let x = 0; x < n; x++) { + for (let y = 0; y < n; y++) { + const isXCenter = x === (n - 1) / 2; + if (isXCenter && !center[y]) continue; + + const isLeftSide = x < (n - 1) / 2; + if (isLeftSide && !side[x][y]) continue; + + const isRightSide = x > (n - 1) / 2; + if (isRightSide && !side[sideN - (x - sideN)][y]) continue; + + const actualX = margin + cellSize * x; + const actualY = margin + cellSize * y; + ctx.beginPath(); + ctx.fillRect(actualX, actualY, cellSize, cellSize); + } + } + + return p.encodePNGToStream(canvas, stream); +} diff --git a/packages/backend/src/misc/gen-key-pair.ts b/packages/backend/src/misc/gen-key-pair.ts new file mode 100644 index 0000000..8ae4175 --- /dev/null +++ b/packages/backend/src/misc/gen-key-pair.ts @@ -0,0 +1,42 @@ +import * as crypto from "node:crypto"; +import * as util from "node:util"; + +const generateKeyPair = util.promisify(crypto.generateKeyPair); + +export async function genRsaKeyPair(modulusLength = 2048) { + return await generateKeyPair("rsa", { + modulusLength, + publicKeyEncoding: { + type: "spki", + format: "pem", + }, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + cipher: undefined, + passphrase: undefined, + }, + }); +} + +export async function genEcKeyPair( + namedCurve: + | "prime256v1" + | "secp384r1" + | "secp521r1" + | "curve25519" = "prime256v1", +) { + return await generateKeyPair("ec", { + namedCurve, + publicKeyEncoding: { + type: "spki", + format: "pem", + }, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + cipher: undefined, + passphrase: undefined, + }, + }); +} diff --git a/packages/backend/src/misc/get-file-info.ts b/packages/backend/src/misc/get-file-info.ts new file mode 100644 index 0000000..9b1530d --- /dev/null +++ b/packages/backend/src/misc/get-file-info.ts @@ -0,0 +1,237 @@ +import * as fs from "node:fs"; +import * as crypto from "node:crypto"; +import * as stream from "node:stream"; +import * as util from "node:util"; +import { fileTypeFromFile } from "file-type"; +import probeImageSize from "probe-image-size"; +import isSvg from "is-svg"; +import sharp from "sharp"; +import { encode } from "blurhash"; + +const pipeline = util.promisify(stream.pipeline); + +export type FileInfo = { + size: number; + md5: string; + type: { + mime: string; + ext: string | null; + }; + width?: number; + height?: number; + orientation?: number; + blurhash?: string; + warnings: string[]; +}; + +const TYPE_OCTET_STREAM = { + mime: "application/octet-stream", + ext: null, +}; + +const TYPE_SVG = { + mime: "image/svg+xml", + ext: "svg", +}; + +/** + * Get file information + */ +export async function getFileInfo( + path: string, +): Promise { + const warnings = [] as string[]; + + const size = await getFileSize(path); + const md5 = await calcHash(path); + + let type = await detectType(path); + + // image dimensions + let width: number | undefined; + let height: number | undefined; + let orientation: number | undefined; + + if ( + [ + "image/jpeg", + "image/gif", + "image/png", + "image/apng", + "image/webp", + "image/bmp", + "image/tiff", + "image/svg+xml", + "image/vnd.adobe.photoshop", + "image/avif", + ].includes(type.mime) + ) { + const imageSize = await detectImageSize(path).catch((e) => { + warnings.push(`detectImageSize failed: ${e}`); + return undefined; + }); + + // うまく判定できない画像は octet-stream にする + if (!imageSize) { + warnings.push("cannot detect image dimensions"); + type = TYPE_OCTET_STREAM; + } else if (imageSize.wUnits === "px") { + width = imageSize.width; + height = imageSize.height; + orientation = imageSize.orientation; + + // 制限を超えている画像は octet-stream にする + if (imageSize.width > 16383 || imageSize.height > 16383) { + warnings.push("image dimensions exceeds limits"); + type = TYPE_OCTET_STREAM; + } + } else { + warnings.push(`unsupported unit type: ${imageSize.wUnits}`); + } + } + + let blurhash: string | undefined; + + if ( + [ + "image/jpeg", + "image/gif", + "image/png", + "image/apng", + "image/webp", + "image/svg+xml", + "image/avif", + ].includes(type.mime) + ) { + blurhash = await getBlurhash(path).catch((e) => { + warnings.push(`getBlurhash failed: ${e}`); + return undefined; + }); + } + + return { + size, + md5, + type, + width, + height, + orientation, + blurhash, + warnings, + }; +} + +function exists(path: string): Promise { + return fs.promises.access(path).then( + () => true, + () => false, + ); +} + +/** + * Detect MIME Type and extension + */ +export async function detectType(path: string): Promise<{ + mime: string; + ext: string | null; +}> { + // Check 0 byte + const fileSize = await getFileSize(path); + if (fileSize === 0) { + return TYPE_OCTET_STREAM; + } + + const type = await fileTypeFromFile(path); + + if (type) { + // XMLはSVGかもしれない + if (type.mime === "application/xml" && (await checkSvg(path))) { + return TYPE_SVG; + } + + return { + mime: type.mime, + ext: type.ext, + }; + } + + // 種類が不明でもSVGかもしれない + if (await checkSvg(path)) { + return TYPE_SVG; + } + + // それでも種類が不明なら application/octet-stream にする + return TYPE_OCTET_STREAM; +} + +/** + * Check the file is SVG or not + */ +export async function checkSvg(path: string) { + try { + const size = await getFileSize(path); + if (size > 1 * 1024 * 1024) return false; + return isSvg(fs.readFileSync(path)); + } catch { + return false; + } +} + +/** + * Get file size + */ +export async function getFileSize(path: string): Promise { + const getStat = util.promisify(fs.stat); + return (await getStat(path)).size; +} + +/** + * Calculate MD5 hash + */ +async function calcHash(path: string): Promise { + const hash = crypto.createHash("md5").setEncoding("hex"); + await pipeline(fs.createReadStream(path), hash); + return hash.read(); +} + +/** + * Detect dimensions of image + */ +async function detectImageSize(path: string): Promise<{ + width: number; + height: number; + wUnits: string; + hUnits: string; + orientation?: number; +}> { + const readable = fs.createReadStream(path); + const imageSize = await probeImageSize(readable); + readable.destroy(); + return imageSize; +} + +/** + * Calculate average color of image + */ +function getBlurhash(path: string): Promise { + return new Promise((resolve, reject) => { + sharp(path) + .raw() + .ensureAlpha() + .resize(64, 64, { fit: "inside" }) + .toBuffer((err, buffer, info) => { + if (err) return reject(err); + + let { width, height } = info; + let hash; + + try { + hash = encode(new Uint8ClampedArray(buffer), width, height, 7, 7); + } catch (e) { + return reject(e); + } + + resolve(hash); + }); + }); +} diff --git a/packages/backend/src/misc/get-ip-hash.ts b/packages/backend/src/misc/get-ip-hash.ts new file mode 100644 index 0000000..2b694e1 --- /dev/null +++ b/packages/backend/src/misc/get-ip-hash.ts @@ -0,0 +1,29 @@ +import IPCIDR from "ip-cidr"; +import net from "node:net"; + +function normalizeIp(ip: string): string | null { + let normalized = ip.split(",")[0]?.trim(); + if (!normalized) return null; + + if (normalized.startsWith("[") && normalized.includes("]")) { + normalized = normalized.slice(1, normalized.indexOf("]")); + } + + if (net.isIP(normalized)) return normalized; + + const ipv4WithPort = normalized.match(/^(\d{1,3}(?:\.\d{1,3}){3}):\d+$/); + if (ipv4WithPort && net.isIPv4(ipv4WithPort[1])) return ipv4WithPort[1]; + + return null; +} + +export function getIpHash(ip: string) { + const normalized = normalizeIp(ip); + if (!normalized) return "ip-invalid"; + + // because a single person may control many IPv6 addresses, + // only a /64 subnet prefix of any IP will be taken into account. + // (this means for IPv4 the entire address is used) + const prefix = IPCIDR.createAddress(normalized).mask(64); + return `ip-${BigInt(`0b${prefix}`).toString(36)}`; +} diff --git a/packages/backend/src/misc/get-note-summary.ts b/packages/backend/src/misc/get-note-summary.ts new file mode 100644 index 0000000..0a662e4 --- /dev/null +++ b/packages/backend/src/misc/get-note-summary.ts @@ -0,0 +1,53 @@ +import type { Packed } from "./schema.js"; + +/** + * 投稿を表す文字列を取得します。 + * @param {*} note (packされた)投稿 + */ +export const getNoteSummary = (note: Packed<"Note">): string => { + if (note.deletedAt) { + return "❌"; + } + + let summary = ""; + + // 本文 + if (note.cw != null) { + summary += note.cw; + } else { + summary += note.text ? note.text : ""; + } + + // ファイルが添付されているとき + if ((note.files || []).length !== 0) { + const len = note.files?.length; + summary += ` 📎${len !== 1 ? ` (${len})` : ""}`; + } + + // 投票が添付されているとき + if (note.poll) { + summary += " 📊"; + } + + /* + // 返信のとき + if (note.replyId) { + if (note.reply) { + summary += `\n\nRE: ${getNoteSummary(note.reply)}`; + } else { + summary += '\n\nRE: ...'; + } + } + + // Renoteのとき + if (note.renoteId) { + if (note.renote) { + summary += `\n\nRN: ${getNoteSummary(note.renote)}`; + } else { + summary += '\n\nRN: ...'; + } + } + */ + + return summary.trim(); +}; diff --git a/packages/backend/src/misc/get-reaction-emoji.ts b/packages/backend/src/misc/get-reaction-emoji.ts new file mode 100644 index 0000000..71521c4 --- /dev/null +++ b/packages/backend/src/misc/get-reaction-emoji.ts @@ -0,0 +1,28 @@ +export default function (reaction: string): string { + switch (reaction) { + case "like": + return "👍"; + case "love": + return "❤️"; + case "laugh": + return "😆"; + case "hmm": + return "🤔"; + case "surprise": + return "😮"; + case "congrats": + return "🎉"; + case "angry": + return "💢"; + case "confused": + return "😥"; + case "rip": + return "😇"; + case "pudding": + return "🍮"; + case "star": + return "⭐"; + default: + return reaction; + } +} diff --git a/packages/backend/src/misc/hard-limits.ts b/packages/backend/src/misc/hard-limits.ts new file mode 100644 index 0000000..5ce3e0a --- /dev/null +++ b/packages/backend/src/misc/hard-limits.ts @@ -0,0 +1,18 @@ +// If you change DB_* values, you must also change the DB schema. + +/** + * Maximum note text length that can be stored in DB. + * Surrogate pairs count as one + * + * NOTE: this can hypothetically be pushed further + * (up to 250000000), but will likely cause truncations + * and incompatibilities with other servers, + * as well as potential performance issues. + */ +export const DB_MAX_NOTE_TEXT_LENGTH = 100000; + +/** + * Maximum image description length that can be stored in DB. + * Surrogate pairs count as one + */ +export const DB_MAX_IMAGE_COMMENT_LENGTH = 8192; diff --git a/packages/backend/src/misc/i18n.ts b/packages/backend/src/misc/i18n.ts new file mode 100644 index 0000000..742bdb0 --- /dev/null +++ b/packages/backend/src/misc/i18n.ts @@ -0,0 +1,29 @@ +export class I18n> { + public locale: T; + + constructor(locale: T) { + this.locale = locale; + + //#region BIND + this.t = this.t.bind(this); + //#endregion + } + + // string にしているのは、ドット区切りでのパス指定を許可するため + // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも + public t(key: string, args?: Record): string { + try { + let str = key.split(".").reduce((o, i) => o[i], this.locale) as string; + + if (args) { + for (const [k, v] of Object.entries(args)) { + str = str.replace(`{${k}}`, v); + } + } + return str; + } catch (e) { + console.warn(`missing localization '${key}'`); + return key; + } + } +} diff --git a/packages/backend/src/misc/id/aid.ts b/packages/backend/src/misc/id/aid.ts new file mode 100644 index 0000000..a123603 --- /dev/null +++ b/packages/backend/src/misc/id/aid.ts @@ -0,0 +1,25 @@ +// AID +// 長さ8の[2000年1月1日からの経過ミリ秒をbase36でエンコードしたもの] + 長さ2の[ノイズ文字列] + +import * as crypto from "node:crypto"; + +const TIME2000 = 946684800000; +let counter = crypto.randomBytes(2).readUInt16LE(0); + +function getTime(time: number) { + time = time - TIME2000; + if (time < 0) time = 0; + + return time.toString(36).padStart(8, "0"); +} + +function getNoise() { + return counter.toString(36).padStart(2, "0").slice(-2); +} + +export function genAid(date: Date): string { + const t = date.getTime(); + if (isNaN(t)) throw "Failed to create AID: Invalid Date"; + counter++; + return getTime(t) + getNoise(); +} diff --git a/packages/backend/src/misc/id/meid.ts b/packages/backend/src/misc/id/meid.ts new file mode 100644 index 0000000..ee78eb8 --- /dev/null +++ b/packages/backend/src/misc/id/meid.ts @@ -0,0 +1,26 @@ +const CHARS = "0123456789abcdef"; + +function getTime(time: number) { + if (time < 0) time = 0; + if (time === 0) { + return CHARS[0]; + } + + time += 0x800000000000; + + return time.toString(16).padStart(12, CHARS[0]); +} + +function getRandom() { + let str = ""; + + for (let i = 0; i < 12; i++) { + str += CHARS[Math.floor(Math.random() * CHARS.length)]; + } + + return str; +} + +export function genMeid(date: Date): string { + return getTime(date.getTime()) + getRandom(); +} diff --git a/packages/backend/src/misc/id/meidg.ts b/packages/backend/src/misc/id/meidg.ts new file mode 100644 index 0000000..4fd39a8 --- /dev/null +++ b/packages/backend/src/misc/id/meidg.ts @@ -0,0 +1,28 @@ +const CHARS = "0123456789abcdef"; + +// 4bit Fixed hex value 'g' +// 44bit UNIX Time ms in Hex +// 48bit Random value in Hex + +function getTime(time: number) { + if (time < 0) time = 0; + if (time === 0) { + return CHARS[0]; + } + + return time.toString(16).padStart(11, CHARS[0]); +} + +function getRandom() { + let str = ""; + + for (let i = 0; i < 12; i++) { + str += CHARS[Math.floor(Math.random() * CHARS.length)]; + } + + return str; +} + +export function genMeidg(date: Date): string { + return `g${getTime(date.getTime())}${getRandom()}`; +} diff --git a/packages/backend/src/misc/id/object-id.ts b/packages/backend/src/misc/id/object-id.ts new file mode 100644 index 0000000..45822f0 --- /dev/null +++ b/packages/backend/src/misc/id/object-id.ts @@ -0,0 +1,26 @@ +const CHARS = "0123456789abcdef"; + +function getTime(time: number) { + if (time < 0) time = 0; + if (time === 0) { + return CHARS[0]; + } + + time = Math.floor(time / 1000); + + return time.toString(16).padStart(8, CHARS[0]); +} + +function getRandom() { + let str = ""; + + for (let i = 0; i < 16; i++) { + str += CHARS[Math.floor(Math.random() * CHARS.length)]; + } + + return str; +} + +export function genObjectId(date: Date): string { + return getTime(date.getTime()) + getRandom(); +} diff --git a/packages/backend/src/misc/identifiable-error.ts b/packages/backend/src/misc/identifiable-error.ts new file mode 100644 index 0000000..be6eb5b --- /dev/null +++ b/packages/backend/src/misc/identifiable-error.ts @@ -0,0 +1,13 @@ +/** + * ID付きエラー + */ +export class IdentifiableError extends Error { + public message: string; + public id: string; + + constructor(id: string, message?: string) { + super(message); + this.message = message || ""; + this.id = id; + } +} diff --git a/packages/backend/src/misc/is-duplicate-key-value-error.ts b/packages/backend/src/misc/is-duplicate-key-value-error.ts new file mode 100644 index 0000000..670277f --- /dev/null +++ b/packages/backend/src/misc/is-duplicate-key-value-error.ts @@ -0,0 +1,4 @@ +export function isDuplicateKeyValueError(e: unknown | Error): boolean { + const nodeError = e as NodeJS.ErrnoException; + return nodeError.code === "23505"; +} diff --git a/packages/backend/src/misc/is-filtered.ts b/packages/backend/src/misc/is-filtered.ts new file mode 100644 index 0000000..a0853eb --- /dev/null +++ b/packages/backend/src/misc/is-filtered.ts @@ -0,0 +1,24 @@ +import { User } from "@/models/entities/user.js"; +import { Note } from "@/models/entities/note.js"; +import { UserProfile } from "@/models/entities/user-profile.js"; +import { getWordHardMute } from "@/misc/check-word-mute.js"; +import { Cache } from "@/misc/cache.js"; +import { unique } from "@/prelude/array.js"; +import config from "@/config/index.js"; +import { UserProfiles } from "@/models/index.js"; + +const filteredNoteCache = new Cache("filteredNote", config.wordMuteCache?.ttlSeconds ?? 60 * 60 * 24); +const mutedWordsCache = new Cache("mutedWords", 60 * 5); + +export async function isFiltered(note: Note, user: { id: User["id"] } | null | undefined, profile?: { mutedWords: UserProfile["mutedWords"] } | null): Promise { + if (!user) return false; + if (profile === undefined) + profile = { mutedWords: await mutedWordsCache.fetch(user.id, async () => + UserProfiles.findOneBy({ userId: user.id }).then(p => p?.mutedWords ?? [])) }; + + if (!profile || profile.mutedWords.length < 1) return false; + const ts = (note.updatedAt ?? note.createdAt) as Date | string; + const identifier = (typeof ts === "string" ? new Date(ts) : ts)?.getTime() ?? '0'; + return filteredNoteCache.fetch(`${note.id}:${identifier}:${user.id}`, + () => getWordHardMute(note, user, unique(profile!.mutedWords))); +} diff --git a/packages/backend/src/misc/is-instance-muted.ts b/packages/backend/src/misc/is-instance-muted.ts new file mode 100644 index 0000000..da949e6 --- /dev/null +++ b/packages/backend/src/misc/is-instance-muted.ts @@ -0,0 +1,22 @@ +import type { Packed } from "./schema.js"; +import { Note } from "@/models/entities/note.js"; + +export function isInstanceMuted( + note: Packed<"Note"> | Note, + mutedInstances: Set, +): boolean { + if (mutedInstances.has(note?.user?.host ?? "")) return true; + if (mutedInstances.has(note?.reply?.user?.host ?? "")) return true; + if (mutedInstances.has(note?.renote?.user?.host ?? "")) return true; + + return false; +} + +export function isUserFromMutedInstance( + notif: Packed<"Notification">, + mutedInstances: Set, +): boolean { + if (mutedInstances.has(notif?.user?.host ?? "")) return true; + + return false; +} diff --git a/packages/backend/src/misc/is-mime-image.ts b/packages/backend/src/misc/is-mime-image.ts new file mode 100644 index 0000000..a8ba62e --- /dev/null +++ b/packages/backend/src/misc/is-mime-image.ts @@ -0,0 +1,20 @@ +import { FILE_TYPE_BROWSERSAFE } from "@/const.js"; + +const dictionary = { + "safe-file": FILE_TYPE_BROWSERSAFE, + "sharp-convertible-image": [ + "image/jpeg", + "image/png", + "image/gif", + "image/apng", + "image/vnd.mozilla.apng", + "image/webp", + "image/svg+xml", + "image/avif", + ], +}; + +export const isMimeImage = ( + mime: string, + type: keyof typeof dictionary, +): boolean => dictionary[type].includes(mime); diff --git a/packages/backend/src/misc/is-quote.ts b/packages/backend/src/misc/is-quote.ts new file mode 100644 index 0000000..fe83a56 --- /dev/null +++ b/packages/backend/src/misc/is-quote.ts @@ -0,0 +1,10 @@ +import type { Note } from "@/models/entities/note.js"; + +export default function (note: Note): boolean { + return ( + note.renoteId != null && + (note.text != null || + note.hasPoll || + (note.fileIds != null && note.fileIds.length > 0)) + ); +} diff --git a/packages/backend/src/misc/is-user-related.ts b/packages/backend/src/misc/is-user-related.ts new file mode 100644 index 0000000..64591cf --- /dev/null +++ b/packages/backend/src/misc/is-user-related.ts @@ -0,0 +1,7 @@ +export function isUserRelated(note: any, ids: Set): boolean { + if (ids.has(note.userId)) return true; // note author is muted + if (note.mentions?.some((user: string) => ids.has(user))) return true; // any of mentioned users are muted + if (note.reply && isUserRelated(note.reply, ids)) return true; // also check reply target + if (note.renote && isUserRelated(note.renote, ids)) return true; // also check renote target + return false; +} diff --git a/packages/backend/src/misc/keypair-store.ts b/packages/backend/src/misc/keypair-store.ts new file mode 100644 index 0000000..6255773 --- /dev/null +++ b/packages/backend/src/misc/keypair-store.ts @@ -0,0 +1,14 @@ +import { UserKeypairs } from "@/models/index.js"; +import type { User } from "@/models/entities/user.js"; +import type { UserKeypair } from "@/models/entities/user-keypair.js"; +import { Cache } from "./cache.js"; + +const cache = new Cache("keypairStore", 60 * 30); + +export async function getUserKeypair(userId: User["id"]): Promise { + return await cache.fetch( + userId, + () => UserKeypairs.findOneByOrFail({ userId: userId }), + true, + ); +} diff --git a/packages/backend/src/misc/langmap.ts b/packages/backend/src/misc/langmap.ts new file mode 100644 index 0000000..106130d --- /dev/null +++ b/packages/backend/src/misc/langmap.ts @@ -0,0 +1,666 @@ +// TODO: sharedに置いてフロントエンドのと統合したい +export const langmap = { + ach: { + nativeName: "Lwo", + }, + ady: { + nativeName: "Адыгэбзэ", + }, + af: { + nativeName: "Afrikaans", + }, + "af-NA": { + nativeName: "Afrikaans (Namibia)", + }, + "af-ZA": { + nativeName: "Afrikaans (South Africa)", + }, + ak: { + nativeName: "Tɕɥi", + }, + ar: { + nativeName: "العربية", + }, + "ar-AR": { + nativeName: "العربية", + }, + "ar-MA": { + nativeName: "العربية", + }, + "ar-SA": { + nativeName: "العربية (السعودية)", + }, + "ay-BO": { + nativeName: "Aymar aru", + }, + az: { + nativeName: "Azərbaycan dili", + }, + "az-AZ": { + nativeName: "Azərbaycan dili", + }, + "be-BY": { + nativeName: "Беларуская", + }, + bg: { + nativeName: "Български", + }, + "bg-BG": { + nativeName: "Български", + }, + bn: { + nativeName: "বাংলা", + }, + "bn-IN": { + nativeName: "বাংলা (ভারত)", + }, + "bn-BD": { + nativeName: "বাংলা(বাংলাদেশ)", + }, + br: { + nativeName: "Brezhoneg", + }, + "bs-BA": { + nativeName: "Bosanski", + }, + ca: { + nativeName: "Català", + }, + "ca-ES": { + nativeName: "Català", + }, + cak: { + nativeName: "Maya Kaqchikel", + }, + "ck-US": { + nativeName: "ᏣᎳᎩ (tsalagi)", + }, + cs: { + nativeName: "Čeština", + }, + "cs-CZ": { + nativeName: "Čeština", + }, + cy: { + nativeName: "Cymraeg", + }, + "cy-GB": { + nativeName: "Cymraeg", + }, + da: { + nativeName: "Dansk", + }, + "da-DK": { + nativeName: "Dansk", + }, + de: { + nativeName: "Deutsch", + }, + "de-AT": { + nativeName: "Deutsch (Österreich)", + }, + "de-DE": { + nativeName: "Deutsch (Deutschland)", + }, + "de-CH": { + nativeName: "Deutsch (Schweiz)", + }, + dsb: { + nativeName: "Dolnoserbšćina", + }, + el: { + nativeName: "Ελληνικά", + }, + "el-GR": { + nativeName: "Ελληνικά", + }, + en: { + nativeName: "English", + }, + "en-GB": { + nativeName: "English (UK)", + }, + "en-AU": { + nativeName: "English (Australia)", + }, + "en-CA": { + nativeName: "English (Canada)", + }, + "en-IE": { + nativeName: "English (Ireland)", + }, + "en-IN": { + nativeName: "English (India)", + }, + "en-PI": { + nativeName: "English (Pirate)", + }, + "en-SG": { + nativeName: "English (Singapore)", + }, + "en-UD": { + nativeName: "English (Upside Down)", + }, + "en-US": { + nativeName: "English (US)", + }, + "en-ZA": { + nativeName: "English (South Africa)", + }, + "en@pirate": { + nativeName: "English (Pirate)", + }, + eo: { + nativeName: "Esperanto", + }, + "eo-EO": { + nativeName: "Esperanto", + }, + es: { + nativeName: "Español", + }, + "es-AR": { + nativeName: "Español (Argentine)", + }, + "es-419": { + nativeName: "Español (Latinoamérica)", + }, + "es-CL": { + nativeName: "Español (Chile)", + }, + "es-CO": { + nativeName: "Español (Colombia)", + }, + "es-EC": { + nativeName: "Español (Ecuador)", + }, + "es-ES": { + nativeName: "Español (España)", + }, + "es-LA": { + nativeName: "Español (Latinoamérica)", + }, + "es-NI": { + nativeName: "Español (Nicaragua)", + }, + "es-MX": { + nativeName: "Español (México)", + }, + "es-US": { + nativeName: "Español (Estados Unidos)", + }, + "es-VE": { + nativeName: "Español (Venezuela)", + }, + et: { + nativeName: "eesti keel", + }, + "et-EE": { + nativeName: "Eesti (Estonia)", + }, + eu: { + nativeName: "Euskara", + }, + "eu-ES": { + nativeName: "Euskara", + }, + fa: { + nativeName: "فارسی", + }, + "fa-IR": { + nativeName: "فارسی", + }, + "fb-LT": { + nativeName: "Leet Speak", + }, + ff: { + nativeName: "Fulah", + }, + fi: { + nativeName: "Suomi", + }, + "fi-FI": { + nativeName: "Suomi", + }, + fo: { + nativeName: "Føroyskt", + }, + "fo-FO": { + nativeName: "Føroyskt (Færeyjar)", + }, + fr: { + nativeName: "Français", + }, + "fr-CA": { + nativeName: "Français (Canada)", + }, + "fr-FR": { + nativeName: "Français (France)", + }, + "fr-BE": { + nativeName: "Français (Belgique)", + }, + "fr-CH": { + nativeName: "Français (Suisse)", + }, + "fy-NL": { + nativeName: "Frysk", + }, + ga: { + nativeName: "Gaeilge", + }, + "ga-IE": { + nativeName: "Gaeilge", + }, + gd: { + nativeName: "Gàidhlig", + }, + gl: { + nativeName: "Galego", + }, + "gl-ES": { + nativeName: "Galego", + }, + "gn-PY": { + nativeName: "Avañe'ẽ", + }, + "gu-IN": { + nativeName: "ગુજરાતી", + }, + gv: { + nativeName: "Gaelg", + }, + "gx-GR": { + nativeName: "Ἑλληνική ἀρχαία", + }, + he: { + nativeName: "עברית‏", + }, + "he-IL": { + nativeName: "עברית‏", + }, + hi: { + nativeName: "हिन्दी", + }, + "hi-IN": { + nativeName: "हिन्दी", + }, + hr: { + nativeName: "Hrvatski", + }, + "hr-HR": { + nativeName: "Hrvatski", + }, + hsb: { + nativeName: "Hornjoserbšćina", + }, + ht: { + nativeName: "Kreyòl", + }, + hu: { + nativeName: "Magyar", + }, + "hu-HU": { + nativeName: "Magyar", + }, + hy: { + nativeName: "Հայերեն", + }, + "hy-AM": { + nativeName: "Հայերեն (Հայաստան)", + }, + id: { + nativeName: "Bahasa Indonesia", + }, + "id-ID": { + nativeName: "Bahasa Indonesia", + }, + is: { + nativeName: "Íslenska", + }, + "is-IS": { + nativeName: "Íslenska (Iceland)", + }, + it: { + nativeName: "Italiano", + }, + "it-IT": { + nativeName: "Italiano", + }, + ja: { + nativeName: "日本語", + }, + "ja-JP": { + nativeName: "日本語 (日本)", + }, + "jv-ID": { + nativeName: "Basa Jawa", + }, + "ka-GE": { + nativeName: "ქართული", + }, + "kk-KZ": { + nativeName: "Қазақша", + }, + km: { + nativeName: "ភាសាខ្មែរ", + }, + kl: { + nativeName: "kalaallisut", + }, + "km-KH": { + nativeName: "ភាសាខ្មែរ", + }, + kab: { + nativeName: "Taqbaylit", + }, + kn: { + nativeName: "ಕನ್ನಡ", + }, + "kn-IN": { + nativeName: "ಕನ್ನಡ (India)", + }, + ko: { + nativeName: "한국어", + }, + "ko-KR": { + nativeName: "한국어 (한국)", + }, + "ku-TR": { + nativeName: "Kurdî", + }, + kw: { + nativeName: "Kernewek", + }, + la: { + nativeName: "Latin", + }, + "la-VA": { + nativeName: "Latin", + }, + lb: { + nativeName: "Lëtzebuergesch", + }, + "li-NL": { + nativeName: "Lèmbörgs", + }, + lt: { + nativeName: "Lietuvių", + }, + "lt-LT": { + nativeName: "Lietuvių", + }, + lv: { + nativeName: "Latviešu", + }, + "lv-LV": { + nativeName: "Latviešu", + }, + mai: { + nativeName: "मैथिली, মৈথিলী", + }, + "mg-MG": { + nativeName: "Malagasy", + }, + mk: { + nativeName: "Македонски", + }, + "mk-MK": { + nativeName: "Македонски (Македонски)", + }, + ml: { + nativeName: "മലയാളം", + }, + "ml-IN": { + nativeName: "മലയാളം", + }, + "mn-MN": { + nativeName: "Монгол", + }, + mr: { + nativeName: "मराठी", + }, + "mr-IN": { + nativeName: "मराठी", + }, + ms: { + nativeName: "Bahasa Melayu", + }, + "ms-MY": { + nativeName: "Bahasa Melayu", + }, + mt: { + nativeName: "Malti", + }, + "mt-MT": { + nativeName: "Malti", + }, + my: { + nativeName: "ဗမာစကာ", + }, + no: { + nativeName: "Norsk", + }, + nb: { + nativeName: "Norsk (bokmål)", + }, + "nb-NO": { + nativeName: "Norsk (bokmål)", + }, + ne: { + nativeName: "नेपाली", + }, + "ne-NP": { + nativeName: "नेपाली", + }, + nl: { + nativeName: "Nederlands", + }, + "nl-BE": { + nativeName: "Nederlands (België)", + }, + "nl-NL": { + nativeName: "Nederlands (Nederland)", + }, + "nn-NO": { + nativeName: "Norsk (nynorsk)", + }, + oc: { + nativeName: "Occitan", + }, + "or-IN": { + nativeName: "ଓଡ଼ିଆ", + }, + pa: { + nativeName: "ਪੰਜਾਬੀ", + }, + "pa-IN": { + nativeName: "ਪੰਜਾਬੀ (ਭਾਰਤ ਨੂੰ)", + }, + pl: { + nativeName: "Polski", + }, + "pl-PL": { + nativeName: "Polski", + }, + "ps-AF": { + nativeName: "پښتو", + }, + pt: { + nativeName: "Português", + }, + "pt-BR": { + nativeName: "Português (Brasil)", + }, + "pt-PT": { + nativeName: "Português (Portugal)", + }, + "qu-PE": { + nativeName: "Qhichwa", + }, + "rm-CH": { + nativeName: "Rumantsch", + }, + ro: { + nativeName: "Română", + }, + "ro-RO": { + nativeName: "Română", + }, + ru: { + nativeName: "Русский", + }, + "ru-RU": { + nativeName: "Русский", + }, + "sa-IN": { + nativeName: "संस्कृतम्", + }, + "se-NO": { + nativeName: "Davvisámegiella", + }, + sh: { + nativeName: "српскохрватски", + }, + "si-LK": { + nativeName: "සිංහල", + }, + sk: { + nativeName: "Slovenčina", + }, + "sk-SK": { + nativeName: "Slovenčina (Slovakia)", + }, + sl: { + nativeName: "Slovenščina", + }, + "sl-SI": { + nativeName: "Slovenščina", + }, + "so-SO": { + nativeName: "Soomaaliga", + }, + sq: { + nativeName: "Shqip", + }, + "sq-AL": { + nativeName: "Shqip", + }, + sr: { + nativeName: "Српски", + }, + "sr-RS": { + nativeName: "Српски (Serbia)", + }, + su: { + nativeName: "Basa Sunda", + }, + sv: { + nativeName: "Svenska", + }, + "sv-SE": { + nativeName: "Svenska", + }, + sw: { + nativeName: "Kiswahili", + }, + "sw-KE": { + nativeName: "Kiswahili", + }, + ta: { + nativeName: "தமிழ்", + }, + "ta-IN": { + nativeName: "தமிழ்", + }, + te: { + nativeName: "తెలుగు", + }, + "te-IN": { + nativeName: "తెలుగు", + }, + tg: { + nativeName: "забо́ни тоҷикӣ́", + }, + "tg-TJ": { + nativeName: "тоҷикӣ", + }, + th: { + nativeName: "ภาษาไทย", + }, + "th-TH": { + nativeName: "ภาษาไทย (ประเทศไทย)", + }, + fil: { + nativeName: "Filipino", + }, + tlh: { + nativeName: "tlhIngan-Hol", + }, + tr: { + nativeName: "Türkçe", + }, + "tr-TR": { + nativeName: "Türkçe", + }, + "tt-RU": { + nativeName: "татарча", + }, + uk: { + nativeName: "Українська", + }, + "uk-UA": { + nativeName: "Українська", + }, + ur: { + nativeName: "اردو", + }, + "ur-PK": { + nativeName: "اردو", + }, + uz: { + nativeName: "O'zbek", + }, + "uz-UZ": { + nativeName: "O'zbek", + }, + vi: { + nativeName: "Tiếng Việt", + }, + "vi-VN": { + nativeName: "Tiếng Việt", + }, + "xh-ZA": { + nativeName: "isiXhosa", + }, + yi: { + nativeName: "ייִדיש", + }, + "yi-DE": { + nativeName: "ייִדיש (German)", + }, + zh: { + nativeName: "中文", + }, + "zh-Hans": { + nativeName: "中文简体", + }, + "zh-Hant": { + nativeName: "中文繁體", + }, + "zh-CN": { + nativeName: "中文(中国大陆)", + }, + "zh-HK": { + nativeName: "中文(香港)", + }, + "zh-SG": { + nativeName: "中文(新加坡)", + }, + "zh-TW": { + nativeName: "中文(台灣)", + }, + "zu-ZA": { + nativeName: "isiZulu", + }, +}; diff --git a/packages/backend/src/misc/normalize-for-search.ts b/packages/backend/src/misc/normalize-for-search.ts new file mode 100644 index 0000000..6882a12 --- /dev/null +++ b/packages/backend/src/misc/normalize-for-search.ts @@ -0,0 +1,6 @@ +export function normalizeForSearch(tag: string): string { + // ref. + // - https://analytics-note.xyz/programming/unicode-normalization-forms/ + // - https://maku77.github.io/js/string/normalize.html + return tag.normalize("NFKC").toLowerCase(); +} diff --git a/packages/backend/src/misc/nyaize.ts b/packages/backend/src/misc/nyaize.ts new file mode 100644 index 0000000..13a112c --- /dev/null +++ b/packages/backend/src/misc/nyaize.ts @@ -0,0 +1,28 @@ +export function nyaize(text: string): string { + return ( + text + // ja-JP + .replaceAll("な", "にゃ") + .replaceAll("ナ", "ニャ") + .replaceAll("ナ", "ニャ") + // en-US + .replace(/(?<=n)a/gi, (x) => (x === "A" ? "YA" : "ya")) + .replace(/(?<=morn)ing/gi, (x) => (x === "ING" ? "YAN" : "yan")) + .replace(/(?<=every)one/gi, (x) => (x === "ONE" ? "NYAN" : "nyan")) + .replace(/non(?=[bcdfghjklmnpqrstvwxyz])/gi, (x) => + x === "NON" ? "NYAN" : "nyan", + ) + // ko-KR + .replace(/[나-낳]/g, (match) => + String.fromCharCode( + match.charCodeAt(0)! + "냐".charCodeAt(0) - "나".charCodeAt(0), + ), + ) + .replace(/(다$)|(다(?=\.))|(다(?= ))|(다(?=!))|(다(?=\?))/gm, "다냥") + .replace(/(야(?=\?))|(야$)|(야(?= ))/gm, "냥") + // el-GR + .replaceAll("να", "νια") + .replaceAll("ΝΑ", "ΝΙΑ") + .replaceAll("Να", "Νια") + ); +} diff --git a/packages/backend/src/misc/password.ts b/packages/backend/src/misc/password.ts new file mode 100644 index 0000000..c63f89f --- /dev/null +++ b/packages/backend/src/misc/password.ts @@ -0,0 +1,20 @@ +import bcrypt from "bcryptjs"; +import * as argon2 from "argon2"; + +export async function hashPassword(password: string): Promise { + return argon2.hash(password); +} + +export async function comparePassword( + password: string, + hash: string, +): Promise { + if (isOldAlgorithm(hash)) return bcrypt.compare(password, hash); + + return argon2.verify(hash, password); +} + +export function isOldAlgorithm(hash: string): boolean { + // bcrypt hashes start with $2[ab]$ + return hash.startsWith("$2"); +} diff --git a/packages/backend/src/misc/populate-emojis.ts b/packages/backend/src/misc/populate-emojis.ts new file mode 100644 index 0000000..67619c2 --- /dev/null +++ b/packages/backend/src/misc/populate-emojis.ts @@ -0,0 +1,402 @@ +import { In, IsNull } from "typeorm"; +import { DriveFiles, Emojis, UserEmojis, UserGroups, UserProfiles, Users } from "@/models/index.js"; +import type { Emoji } from "@/models/entities/emoji.js"; +import type { Note } from "@/models/entities/note.js"; +import { Cache } from "./cache.js"; +import { isSelfHost, toPunyNullable } from "./convert-host.js"; +import { decodeReaction } from "./reaction-lib.js"; +import config from "@/config/index.js"; +import { query } from "@/prelude/url.js"; +import { redisClient } from "@/db/redis.js"; +import { resolveUser } from "@/remote/resolve-user.js"; + +const cache = new Cache("populateEmojis", 60 * 60 * 12); +const userEmojiCache = new Cache("populateUserEmojis", 60 * 60 * 12); + +/** + * 添付用絵文字情報 + */ +export type PopulatedEmoji = { + name: string; + url: string; + glyph: boolean; + glyphUrl: string | null; + width: number | null; + height: number | null; +}; + +function normalizeHost( + src: string | undefined, + noteUserHost: string | null, +): string | null { + // クエリに使うホスト + let host = + src === "." + ? null // .はローカルホスト (ここがマッチするのはリアクションのみ) + : src === undefined + ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない) + : isSelfHost(src) + ? null // 自ホスト指定 + : src || noteUserHost; // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない) + + host = toPunyNullable(host); + + return host; +} + +function parseEmojiStr(emojiName: string, noteUserHost: string | null) { + // emojiName may be of the form `emoji@host`, turn it into a suitable form + const match = emojiName.split("@"); + const name = match[0]; + const host = toPunyNullable(normalizeHost(match[1], noteUserHost)); + + return { name, host }; +} + +function proxiedUrl(url: string, host: string | null) { + if (host == null) return url; + return `${config.url}/proxy/${encodeURIComponent( + new URL(url).pathname, + )}?${query({ url })}`; +} + +function proxiedGlyphUrl(url: string, host: string | null) { + if (host == null) return url; + return `${config.url}/proxy/${encodeURIComponent( + new URL(url).pathname, + )}?${query({ url, glyph: "1" })}`; +} + +function parseUserIconEmoji(emojiName: string) { + const match = emojiName.match(/^@([^@:\s]+)(?:@([^@:\s]+))?$/); + if (!match) return null; + + return { + username: match[1], + host: toPunyNullable(normalizeHost(match[2], null)), + }; +} + +function parseGroupSymbolEmoji(emojiName: string) { + const match = emojiName.match(/^@@([a-zA-Z0-9_]{1,64})$/); + if (!match) return null; + return { username: match[1].toLowerCase() }; +} + +function parseGroupEmoji(emojiName: string) { + const match = emojiName.match(/^([a-z0-9_]{1,64})@@([a-zA-Z0-9_]{1,64})$/); + if (!match) return null; + return { name: match[1], username: match[2].toLowerCase() }; +} + +function parseUserEmoji(emojiName: string) { + const parts = emojiName.split("@"); + if (parts.length !== 2 && parts.length !== 3) return null; + if (!parts[0] || !parts[1]) return null; + + return { + name: parts[0], + username: parts[1], + host: toPunyNullable(normalizeHost(parts[2], null)), + }; +} + +async function findUserByAcct(username: string, host: string | null) { + const user = await Users.findOneBy({ + usernameLower: username.toLowerCase(), + host: host ?? IsNull(), + }); + if (user) return user; + if (host == null) return null; + + return resolveUser(username, host).catch(() => null); +} + +async function populateUserIconEmoji( + emojiName: string, +): Promise { + const parsed = parseUserIconEmoji(emojiName); + if (!parsed) return null; + + const user = await findUserByAcct(parsed.username, parsed.host); + if (!user) return null; + const profile = await UserProfiles.findOneBy({ userId: user.id }); + if (profile?.symbolFileId) { + const symbol = await DriveFiles.findOneBy({ id: profile.symbolFileId }); + if (symbol) { + const symbolUrl = symbol.webpublicUrl ?? symbol.url; + return { + name: emojiName, + url: proxiedUrl(symbolUrl, user.host), + glyph: true, + glyphUrl: proxiedGlyphUrl(symbol.url, user.host), + width: null, + height: null, + }; + } + } + const avatarUrl = user.avatarUrl ?? (await Users.getAvatarUrl(user)); + + return { + name: emojiName, + url: proxiedUrl(avatarUrl, user.host), + glyph: false, + glyphUrl: null, + width: null, + height: null, + }; +} + +async function populateUserEmoji( + emojiName: string, +): Promise { + const parsed = parseUserEmoji(emojiName); + if (!parsed) return null; + + const user = await findUserByAcct(parsed.username, parsed.host); + if (!user) return null; + + const userEmoji = await UserEmojis.findOneBy({ + name: parsed.name, + userId: user.id, + }); + if (!userEmoji) return null; + + const emojiUrl = userEmoji.publicUrl || userEmoji.originalUrl; + return { + name: emojiName, + url: proxiedUrl(emojiUrl, user.host), + glyph: userEmoji.glyph, + glyphUrl: userEmoji.glyph + ? proxiedGlyphUrl(userEmoji.originalUrl, user.host) + : null, + width: userEmoji.width, + height: userEmoji.height, + }; +} + +async function populateGroupSymbolEmoji( + emojiName: string, +): Promise { + const parsed = parseGroupSymbolEmoji(emojiName); + if (!parsed) return null; + + const group = await UserGroups.findOneBy({ username: parsed.username }); + if (!group?.symbolFileId && !group?.iconFileId) return null; + + const file = await DriveFiles.findOneBy({ + id: group.symbolFileId ?? group.iconFileId!, + }); + if (!file) return null; + const symbolUrl = file.webpublicUrl ?? file.url; + + return { + name: emojiName, + url: proxiedUrl(symbolUrl, null), + glyph: true, + glyphUrl: proxiedGlyphUrl(file.url, null), + width: null, + height: null, + }; +} + +async function populateGroupEmoji( + emojiName: string, +): Promise { + const parsed = parseGroupEmoji(emojiName); + if (!parsed) return null; + + const group = await UserGroups.findOneBy({ username: parsed.username }); + if (!group) return null; + + const groupEmoji = await UserEmojis.findOneBy({ + name: parsed.name, + userGroupId: group.id, + }); + if (!groupEmoji) return null; + + const emojiUrl = groupEmoji.publicUrl || groupEmoji.originalUrl; + return { + name: emojiName, + url: proxiedUrl(emojiUrl, null), + glyph: groupEmoji.glyph, + glyphUrl: groupEmoji.glyph + ? proxiedGlyphUrl(groupEmoji.originalUrl, null) + : null, + width: groupEmoji.width, + height: groupEmoji.height, + }; +} + +/** + * 添付用絵文字情報を解決する + * @param emojiName ノートやユーザープロフィールに添付された、またはリアクションのカスタム絵文字名 (:は含めない, リアクションでローカルホストの場合は@.を付ける (これはdecodeReactionで可能)) + * @param noteUserHost ノートやユーザープロフィールの所有者のホスト + * @returns 絵文字情報, nullは未マッチを意味する + */ +export async function populateEmoji( + emojiName: string, + noteUserHost: string | null, +): Promise { + const { name, host } = parseEmojiStr(emojiName, noteUserHost); + if (name == null) return null; + + const queryOrNull = async () => + (await Emojis.findOneBy({ + name, + host: host ?? IsNull(), + })) || null; + + const cacheKey = `${name} ${host}`; + let emoji = await cache.fetch(cacheKey, queryOrNull); + + if (emoji && !(emoji.width && emoji.height)) { + emoji = await queryOrNull(); + await cache.set(cacheKey, emoji); + } + + if (emoji == null) return null; + + const isLocal = emoji.host == null; + const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため + const url = proxiedUrl(emojiUrl, isLocal ? null : emoji.host); + + return { + name: emojiName, + url, + glyph: emoji.glyph, + glyphUrl: emoji.glyph + ? proxiedGlyphUrl(emoji.originalUrl, isLocal ? null : emoji.host) + : null, + width: emoji.width, + height: emoji.height, + }; +} + +export async function populateEmojiOrUserEmoji( + emojiName: string, + noteUserHost: string | null, +): Promise { + const emoji = await populateEmoji(emojiName, noteUserHost); + if (emoji) return emoji; + + const userEmoji = await userEmojiCache.fetchMaybe( + `user ${emojiName}`, + async () => + (await populateUserIconEmoji(emojiName)) ?? + (await populateUserEmoji(emojiName)) ?? + (await populateGroupSymbolEmoji(emojiName)) ?? + (await populateGroupEmoji(emojiName)) ?? + undefined, + false, + (cached) => cached != null, + ); + + return userEmoji ?? null; +} + +export async function clearUserEmojiCache( + name: string, + username: string, + host: string | null, +): Promise { + const keys = new Set([`user ${name}@${username}`]); + if (host) { + keys.add(`user ${name}@${username}@${host}`); + } else { + keys.add(`user ${name}@${username}@${config.host}`); + } + + await userEmojiCache.delete(...keys); +} + +export async function clearGroupEmojiCache( + name: string, + groupUsername: string | null, +): Promise { + if (!groupUsername) return; + await userEmojiCache.delete( + `user ${name}@@${groupUsername}`, + `user @@${groupUsername}`, + ); +} + +/** + * 複数の添付用絵文字情報を解決する (キャシュ付き, 存在しないものは結果から除外される) + */ +export async function populateEmojis( + emojiNames: string[], + noteUserHost: string | null, +): Promise { + const emojis = await Promise.all( + emojiNames.map((x) => populateEmojiOrUserEmoji(x, noteUserHost)), + ); + return emojis.filter((x): x is PopulatedEmoji => x != null); +} + +export function aggregateNoteEmojis(notes: Note[]) { + let emojis: { name: string | null; host: string | null }[] = []; + for (const note of notes) { + emojis = emojis.concat( + note.emojis.map((e) => parseEmojiStr(e, note.userHost)), + ); + if (note.renote) { + emojis = emojis.concat( + note.renote.emojis.map((e) => parseEmojiStr(e, note.renote!.userHost)), + ); + if (note.renote.user) { + emojis = emojis.concat( + note.renote.user.emojis.map((e) => + parseEmojiStr(e, note.renote!.userHost), + ), + ); + } + } + const customReactions = Object.keys(note.reactions) + .map((x) => decodeReaction(x)) + .filter((x) => x.name != null) as typeof emojis; + emojis = emojis.concat(customReactions); + if (note.user) { + emojis = emojis.concat( + note.user.emojis.map((e) => parseEmojiStr(e, note.userHost)), + ); + } + } + return emojis.filter((x) => x.name != null) as { + name: string; + host: string | null; + }[]; +} + +/** + * 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します + */ +export async function prefetchEmojis( + emojis: { name: string; host: string | null }[], +): Promise { + const notCachedEmojis = emojis.filter( + async (emoji) => !(await cache.get(`${emoji.name} ${emoji.host}`)), + ); + const emojisQuery: any[] = []; + const hosts = new Set(notCachedEmojis.map((e) => e.host)); + for (const host of hosts) { + emojisQuery.push({ + name: In( + notCachedEmojis.filter((e) => e.host === host).map((e) => e.name), + ), + host: host ?? IsNull(), + }); + } + const _emojis = + emojisQuery.length > 0 + ? await Emojis.find({ + where: emojisQuery, + select: ["name", "host", "originalUrl", "publicUrl"], + }) + : []; + const trans = redisClient.multi(); + for (const emoji of _emojis) { + cache.set(`${emoji.name} ${emoji.host}`, emoji, trans); + } + await trans.exec(); +} diff --git a/packages/backend/src/misc/post.ts b/packages/backend/src/misc/post.ts new file mode 100644 index 0000000..90f4f75 --- /dev/null +++ b/packages/backend/src/misc/post.ts @@ -0,0 +1,19 @@ +export type Post = { + text: string | null; + cw: string | null; + localOnly: boolean; + createdAt: Date; +}; + +export function parse(acct: any): Post { + return { + text: acct.text, + cw: acct.cw, + localOnly: acct.localOnly, + createdAt: new Date(acct.createdAt), + }; +} + +export function toJson(acct: Post): string { + return { text: acct.text, cw: acct.cw, localOnly: acct.localOnly }.toString(); +} diff --git a/packages/backend/src/misc/process-masto-notes.ts b/packages/backend/src/misc/process-masto-notes.ts new file mode 100644 index 0000000..1327b4c --- /dev/null +++ b/packages/backend/src/misc/process-masto-notes.ts @@ -0,0 +1,143 @@ +import * as fs from "node:fs"; +import Logger from "@/services/logger.js"; +import { createTemp, createTempDir } from "./create-temp.js"; +import { downloadUrl } from "./download-url.js"; +import { addFile } from "@/services/drive/add-file.js"; +import { Users } from "@/models/index.js"; +import * as tar from "tar-stream"; +import gunzip from "gunzip-maybe"; +import decompress from "decompress"; +import * as Path from "node:path"; + +const logger = new Logger("process-masto-notes"); + +export async function processMastoNotes( + fn: string, + url: string, + uid: string, +): Promise { + // Create temp file + const [path, cleanup] = await createTemp(); + + const [unzipPath, unzipCleanup] = await createTempDir(); + + logger.info(`Temp file is ${path}`); + + try { + // write content at URL to temp file + await downloadUrl(url, path); + return await processMastoFile(fn, path, unzipPath, uid); + } finally { + cleanup(); + //unzipCleanup(); + } +} + +function processMastoFile(fn: string, path: string, dir: string, uid: string) { + return new Promise(async (resolve, reject) => { + const user = await Users.findOneBy({ id: uid }); + try { + logger.info(`Start unzip ${path}`); + fn.endsWith("tar.gz") + ? await unzipTarGz(path, dir) + : await unzipZip(path, dir); + logger.info(`Unzip to ${dir}`); + const outbox = JSON.parse(fs.readFileSync(`${dir}/outbox.json`)); + for (const note of outbox.orderedItems) { + // Skip if attachment is undefined or not iterable + if ( + note.object.attachment == null || + !note.object.attachment[Symbol.iterator] + ) { + continue; + } + for (const attachment of note.object.attachment) { + const url = attachment.url.replaceAll("..", ""); + if (url.indexOf("\0") !== -1) { + logger.error(`Found Poison Null Bytes Attack: ${url}`); + reject(); + return; + } + try { + const fpath = Path.resolve(`${dir}${url}`); + if (!fpath.startsWith(dir)) { + logger.error(`Found Path Attack: ${url}`); + reject(); + return; + } + logger.info(fpath); + const driveFile = await addFile({ user: user, path: fpath }); + attachment.driveFile = driveFile; + } catch (e) { + logger.error(`Skipped adding file to drive: ${url}`); + } + } + } + resolve(outbox); + } catch (e) { + logger.error(`Error on extract masto note package: ${fn}`); + reject(e); + } + }); +} + +function createFileDir(fn: string) { + if (!fs.existsSync(fn)) { + fs.mkdirSync(fn, { recursive: true }); + fs.rmdirSync(fn); + } +} + +function unzipZip(fn: string, dir: string) { + return new Promise(async (resolve, reject) => { + try { + decompress(fn, dir).then((files: any) => { + resolve(files); + }); + } catch (e) { + reject(); + } + }); +} + +function unzipTarGz(fn: string, dir: string) { + return new Promise(async (resolve, reject) => { + const onErr = (err: any) => { + logger.error(`pipe broken: ${err}`); + reject(); + }; + try { + const extract = tar.extract().on("error", onErr); + dir = dir.endsWith("/") ? dir : dir + "/"; + const ls: string[] = []; + extract.on("entry", function (header: any, stream: any, next: any) { + try { + ls.push(dir + header.name); + createFileDir(dir + header.name); + stream + .on("error", onErr) + .pipe(fs.createWriteStream(dir + header.name)) + .on("error", onErr); + next(); + } catch (e) { + logger.error(`create dir error:${e}`); + reject(); + } + }); + + extract.on("finish", function () { + resolve(ls); + }); + + fs.createReadStream(fn) + .on("error", onErr) + .pipe(gunzip()) + .on("error", onErr) + .pipe(extract) + .on("error", onErr); + } catch (e) { + logger.error(`unzipTarGz error: ${e}`); + reject(); + } + }); +} diff --git a/packages/backend/src/misc/reaction-lib.ts b/packages/backend/src/misc/reaction-lib.ts new file mode 100644 index 0000000..7d38632 --- /dev/null +++ b/packages/backend/src/misc/reaction-lib.ts @@ -0,0 +1,206 @@ +import { emojiRegex } from "./emoji-regex.js"; +import { fetchMeta } from "./fetch-meta.js"; +import { Emojis, UserEmojis, UserGroups, Users } from "@/models/index.js"; +import { toPunyNullable } from "./convert-host.js"; +import { IsNull } from "typeorm"; +import { resolveUser } from "@/remote/resolve-user.js"; + +const legacies = new Map([ + ["like", "👍"], + ["love", "❤️"], + ["laugh", "😆"], + ["hmm", "🤔"], + ["surprise", "😮"], + ["congrats", "🎉"], + ["angry", "💢"], + ["confused", "😥"], + ["rip", "😇"], + ["pudding", "🍮"], + ["star", "⭐"], +]); + +export async function getFallbackReaction() { + const meta = await fetchMeta(); + return meta.defaultReaction; +} + +export function convertLegacyReactions(reactions: Record) { + const _reactions = new Map(); + const decodedReactions = new Map(); + + for (const reaction in reactions) { + if (reactions[reaction] <= 0) continue; + + let decodedReaction; + if (decodedReactions.has(reaction)) { + decodedReaction = decodedReactions.get(reaction); + } else { + decodedReaction = decodeReaction(reaction); + decodedReactions.set(reaction, decodedReaction); + } + + let emoji = legacies.get(decodedReaction.reaction); + if (emoji) { + _reactions.set(emoji, (_reactions.get(emoji) || 0) + reactions[reaction]); + } else { + _reactions.set( + reaction, + (_reactions.get(reaction) || 0) + reactions[reaction], + ); + } + } + + const _reactions2 = new Map(); + for (const [reaction, count] of _reactions) { + const decodedReaction = decodedReactions.get(reaction); + _reactions2.set(decodedReaction.reaction, count); + } + + return Object.fromEntries(_reactions2); +} + +export async function toDbReaction( + reaction?: string | null, + reacterHost?: string | null, + recurse: boolean = true +): Promise { + if (!reaction) return await getFallbackReaction(); + + reacterHost = toPunyNullable(reacterHost); + + // Convert string-type reactions to unicode + const emoji = legacies.get(reaction) || (reaction === "♥️" ? "❤️" : null); + if (emoji) return emoji; + + // Allow unicode reactions + const match = emojiRegex.exec(reaction); + if (match) { + const unicode = match[0]; + return unicode; + } + + const custom = reaction.match(/^:([^:\s]+):$/); + if (custom) { + const decoded = decodeReaction(reaction); + if (decoded.name) { + const groupCustom = decoded.name.match(/^([a-z0-9_]{1,64})@@([a-zA-Z0-9_]{1,64})$/); + if (groupCustom) { + const group = await UserGroups.findOneBy({ + username: groupCustom[2].toLowerCase(), + }); + const groupEmoji = group + ? await UserEmojis.findOneBy({ + name: groupCustom[1], + userGroupId: group.id, + glyph: false, + }) + : null; + if (groupEmoji) return `:${groupCustom[1]}@@${group!.username}:`; + } + + const emoji = await Emojis.findOneBy({ + host: decoded.host ?? reacterHost ?? IsNull(), + name: decoded.name, + }); + + if (emoji) return emoji.host ? `:${emoji.name}@${emoji.host}:` : `:${emoji.name}:`; + } + + if (await isUserReaction(reaction)) return reaction; + } + + return recurse && reacterHost == null && reaction !== null + ? await toDbReaction(`:${reaction}:`, reacterHost, false) + : await getFallbackReaction(); +} + +type DecodedReaction = { + /** + * リアクション名 (Unicode Emoji or ':name@hostname' or ':name@.') + */ + reaction: string; + + /** + * name (カスタム絵文字の場合name, Emojiクエリに使う) + */ + name?: string; + + /** + * host (カスタム絵文字の場合host, Emojiクエリに使う) + */ + host?: string | null; +}; + +export function decodeReaction(str: string): DecodedReaction { + const custom = str.match(/^:([^:\s]+):$/); + + if (custom) { + const body = custom[1]; + const parts = body.split("@"); + const name = parts[0]; + const host = parts.length === 2 ? parts[1] || null : null; + + return { + reaction: host ? `:${name}@${host}:` : str, + name, + host, + }; + } + + return { + reaction: str, + name: undefined, + host: undefined, + }; +} + +async function isUserReaction(reaction: string) { + const body = reaction.slice(1, -1); + const icon = body.match(/^@([^@:\s]+)(?:@([^@:\s]+))?$/); + if (icon) { + return (await findUser(icon[1], icon[2] ?? null)) != null; + } + + if (/^@@[a-zA-Z0-9_]{1,64}$/.test(body)) return false; + const groupCustom = body.match(/^([a-z0-9_]{1,64})@@([a-zA-Z0-9_]{1,64})$/); + if (groupCustom) { + const group = await UserGroups.findOneBy({ + username: groupCustom[2].toLowerCase(), + }); + if (!group) return false; + return ( + (await UserEmojis.findOneBy({ + name: groupCustom[1], + userGroupId: group.id, + glyph: false, + })) != null + ); + } + + const parts = body.split("@"); + if (parts.length !== 2 && parts.length !== 3) return false; + const [name, username, host] = parts; + if (!name || !username) return false; + const user = await findUser(username, host ?? null); + if (!user) return false; + + return (await UserEmojis.findOneBy({ name, userId: user.id })) != null; +} + +async function findUser(username: string, host: string | null) { + const normalizedHost = toPunyNullable(host); + const user = await Users.findOneBy({ + usernameLower: username.toLowerCase(), + host: normalizedHost ?? IsNull(), + }); + if (user) return user; + if (normalizedHost == null) return null; + + return resolveUser(username, normalizedHost).catch(() => null); +} + +export function convertLegacyReaction(reaction: string): string { + const decoded = decodeReaction(reaction).reaction; + if (legacies.has(decoded)) return legacies.get(decoded)!; + return decoded; +} diff --git a/packages/backend/src/misc/safe-for-sql.ts b/packages/backend/src/misc/safe-for-sql.ts new file mode 100644 index 0000000..02eb7f0 --- /dev/null +++ b/packages/backend/src/misc/safe-for-sql.ts @@ -0,0 +1,3 @@ +export function safeForSql(text: string): boolean { + return !/[\0\x08\x09\x1a\n\r"'\\\%]/g.test(text); +} diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/schema.ts new file mode 100644 index 0000000..317856c --- /dev/null +++ b/packages/backend/src/misc/schema.ts @@ -0,0 +1,226 @@ +import { + packedUserLiteSchema, + packedUserDetailedNotMeOnlySchema, + packedMeDetailedOnlySchema, + packedUserDetailedNotMeSchema, + packedMeDetailedSchema, + packedUserDetailedSchema, + packedUserSchema, +} from "@/models/schema/user.js"; +import { packedNoteSchema } from "@/models/schema/note.js"; +import { packedUserListSchema } from "@/models/schema/user-list.js"; +import { packedAppSchema } from "@/models/schema/app.js"; +import { packedMessagingMessageSchema } from "@/models/schema/messaging-message.js"; +import { packedNotificationSchema } from "@/models/schema/notification.js"; +import { packedDriveFileSchema } from "@/models/schema/drive-file.js"; +import { packedDriveFolderSchema } from "@/models/schema/drive-folder.js"; +import { packedFollowingSchema } from "@/models/schema/following.js"; +import { packedMutingSchema } from "@/models/schema/muting.js"; +import { packedRenoteMutingSchema } from "@/models/schema/renote-muting.js"; +import { packedBlockingSchema } from "@/models/schema/blocking.js"; +import { packedNoteReactionSchema } from "@/models/schema/note-reaction.js"; +import { packedHashtagSchema } from "@/models/schema/hashtag.js"; +import { packedPageSchema } from "@/models/schema/page.js"; +import { packedUserGroupSchema } from "@/models/schema/user-group.js"; +import { packedNoteFavoriteSchema } from "@/models/schema/note-favorite.js"; +import { packedChannelSchema } from "@/models/schema/channel.js"; +import { packedAntennaSchema } from "@/models/schema/antenna.js"; +import { packedClipSchema } from "@/models/schema/clip.js"; +import { packedFederationInstanceSchema } from "@/models/schema/federation-instance.js"; +import { packedQueueCountSchema } from "@/models/schema/queue.js"; +import { packedGalleryPostSchema } from "@/models/schema/gallery-post.js"; +import { packedEmojiSchema } from "@/models/schema/emoji.js"; +import { packedNoteEdit } from "@/models/schema/note-edit.js"; +import { packedBiteSchema } from "@/models/schema/bite.js"; + +export const refs = { + UserLite: packedUserLiteSchema, + UserDetailedNotMeOnly: packedUserDetailedNotMeOnlySchema, + MeDetailedOnly: packedMeDetailedOnlySchema, + UserDetailedNotMe: packedUserDetailedNotMeSchema, + MeDetailed: packedMeDetailedSchema, + UserDetailed: packedUserDetailedSchema, + User: packedUserSchema, + + UserList: packedUserListSchema, + UserGroup: packedUserGroupSchema, + App: packedAppSchema, + MessagingMessage: packedMessagingMessageSchema, + Note: packedNoteSchema, + NoteEdit: packedNoteEdit, + NoteReaction: packedNoteReactionSchema, + NoteFavorite: packedNoteFavoriteSchema, + Notification: packedNotificationSchema, + DriveFile: packedDriveFileSchema, + DriveFolder: packedDriveFolderSchema, + Following: packedFollowingSchema, + Muting: packedMutingSchema, + RenoteMuting: packedRenoteMutingSchema, + Blocking: packedBlockingSchema, + Hashtag: packedHashtagSchema, + Page: packedPageSchema, + Channel: packedChannelSchema, + QueueCount: packedQueueCountSchema, + Antenna: packedAntennaSchema, + Clip: packedClipSchema, + FederationInstance: packedFederationInstanceSchema, + GalleryPost: packedGalleryPostSchema, + Emoji: packedEmojiSchema, + Bite: packedBiteSchema, +}; + +export type Packed = SchemaType; + +type TypeStringef = + | "null" + | "boolean" + | "integer" + | "number" + | "string" + | "array" + | "object" + | "any"; +type StringDefToType = T extends "null" + ? null + : T extends "boolean" + ? boolean + : T extends "integer" + ? number + : T extends "number" + ? number + : T extends "string" + ? string | Date + : T extends "array" + ? ReadonlyArray + : T extends "object" + ? Record + : any; + +// https://swagger.io/specification/?sbsearch=optional#schema-object +type OfSchema = { + readonly anyOf?: ReadonlyArray; + readonly oneOf?: ReadonlyArray; + readonly allOf?: ReadonlyArray; +}; + +export interface Schema extends OfSchema { + readonly type?: TypeStringef; + readonly nullable?: boolean; + readonly optional?: boolean; + readonly items?: Schema; + readonly properties?: Obj; + readonly required?: ReadonlyArray< + Extract, string> + >; + readonly description?: string; + readonly example?: any; + readonly format?: string; + readonly ref?: keyof typeof refs; + readonly enum?: ReadonlyArray; + readonly default?: + | (this["type"] extends TypeStringef ? StringDefToType : any) + | null; + readonly maxLength?: number; + readonly minLength?: number; + readonly maximum?: number; + readonly minimum?: number; + readonly pattern?: string; +} + +type RequiredPropertyNames = { + [K in keyof s]: // K is not optional + s[K]["optional"] extends false + ? K + : // K has default value + s[K]["default"] extends + | null + | string + | number + | boolean + | Record + ? K + : never; +}[keyof s]; + +export type Obj = Record; + +// https://github.com/misskey-dev/misskey/issues/8535 +// To avoid excessive stack depth error, +// deceive TypeScript with UnionToIntersection (or more precisely, `infer` expression within it). +export type ObjType< + s extends Obj, + RequiredProps extends keyof s, +> = UnionToIntersection< + { + -readonly [R in RequiredPropertyNames]-?: SchemaType; + } & { + -readonly [R in RequiredProps]-?: SchemaType; + } & { + -readonly [P in keyof s]?: SchemaType; + } +>; + +type NullOrUndefined

= + | (p["nullable"] extends true ? null : never) + | (p["optional"] extends true ? undefined : never) + | T; + +// https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection +// Get intersection from union +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never; + +// https://github.com/misskey-dev/misskey/pull/8144#discussion_r785287552 +// To get union, we use `Foo extends any ? Hoge : never` +type UnionSchemaType< + a extends readonly any[], + X extends Schema = a[number], +> = X extends any ? SchemaType : never; +type ArrayUnion = T extends any ? Array : never; + +export type SchemaTypeDef

= p["type"] extends "null" + ? null + : p["type"] extends "integer" + ? number + : p["type"] extends "number" + ? number + : p["type"] extends "string" + ? p["enum"] extends readonly string[] + ? p["enum"][number] + : p["format"] extends "date-time" + ? string + : // Dateにする?? + string + : p["type"] extends "boolean" + ? boolean + : p["type"] extends "object" + ? p["ref"] extends keyof typeof refs + ? Packed + : p["properties"] extends NonNullable + ? ObjType[number]> + : p["anyOf"] extends ReadonlyArray + ? UnionSchemaType & + Partial>> + : p["allOf"] extends ReadonlyArray + ? UnionToIntersection> + : any + : p["type"] extends "array" + ? p["items"] extends OfSchema + ? p["items"]["anyOf"] extends ReadonlyArray + ? UnionSchemaType>[] + : p["items"]["oneOf"] extends ReadonlyArray + ? ArrayUnion>> + : p["items"]["allOf"] extends ReadonlyArray + ? UnionToIntersection>>[] + : never + : p["items"] extends NonNullable + ? SchemaTypeDef[] + : any[] + : p["oneOf"] extends ReadonlyArray + ? UnionSchemaType + : any; + +export type SchemaType

= NullOrUndefined>; diff --git a/packages/backend/src/misc/secure-rndstr.ts b/packages/backend/src/misc/secure-rndstr.ts new file mode 100644 index 0000000..cf667fc --- /dev/null +++ b/packages/backend/src/misc/secure-rndstr.ts @@ -0,0 +1,19 @@ +import * as crypto from "node:crypto"; + +const charset = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + +export function secureRndstr(length = 32): string { + let str = ""; + + for (let i = 0; i < length; i++) { + let rand = Math.floor( + (crypto.randomBytes(1).readUInt8(0) / 0xff) * charset.length, + ); + if (rand === charset.length) { + rand = charset.length - 1; + } + str += charset.charAt(rand); + } + + return str; +} diff --git a/packages/backend/src/misc/should-block-instance.ts b/packages/backend/src/misc/should-block-instance.ts new file mode 100644 index 0000000..35ed307 --- /dev/null +++ b/packages/backend/src/misc/should-block-instance.ts @@ -0,0 +1,38 @@ +import { fetchMeta } from "@/misc/fetch-meta.js"; +import type { Instance } from "@/models/entities/instance.js"; +import type { Meta } from "@/models/entities/meta.js"; + +/** + * Returns whether a specific host (punycoded) should be blocked. + * + * @param host punycoded instance host + * @param meta a resolved Meta table + * @returns whether the given host should be blocked + */ +export async function shouldBlockInstance( + host: Instance["host"], + meta?: Meta, +): Promise { + const { blockedHosts } = meta ?? (await fetchMeta()); + return blockedHosts.some( + (blockedHost) => host === blockedHost || host.endsWith(`.${blockedHost}`), + ); +} + +/** + * Returns whether a specific host (punycoded) should be limited. + * + * @param host punycoded instance host + * @param meta a resolved Meta table + * @returns whether the given host should be limited + */ +export async function shouldSilenceInstance( + host: Instance["host"], + meta?: Meta, +): Promise { + const { silencedHosts } = meta ?? (await fetchMeta()); + return silencedHosts.some( + (silencedHost) => + host === silencedHost || host.endsWith(`.${silencedHost}`), + ); +} diff --git a/packages/backend/src/misc/show-machine-info.ts b/packages/backend/src/misc/show-machine-info.ts new file mode 100644 index 0000000..d3a28cb --- /dev/null +++ b/packages/backend/src/misc/show-machine-info.ts @@ -0,0 +1,17 @@ +import * as os from "node:os"; +import sysUtils from "systeminformation"; +import type Logger from "@/services/logger.js"; + +export async function showMachineInfo(parentLogger: Logger) { + const logger = parentLogger.createSubLogger("machine"); + logger.debug(`Hostname: ${os.hostname()}`); + logger.debug(`Platform: ${process.platform} Arch: ${process.arch}`); + const mem = await sysUtils.mem(); + const totalmem = (mem.total / 1024 / 1024 / 1024).toFixed(1); + const availmem = (mem.available / 1024 / 1024 / 1024).toFixed(1); + logger.debug( + `CPU: ${ + os.cpus().length + } core MEM: ${totalmem}GB (available: ${availmem}GB)`, + ); +} diff --git a/packages/backend/src/misc/skipped-instances.ts b/packages/backend/src/misc/skipped-instances.ts new file mode 100644 index 0000000..1ba2de9 --- /dev/null +++ b/packages/backend/src/misc/skipped-instances.ts @@ -0,0 +1,67 @@ +import { Brackets } from "typeorm"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { Instances } from "@/models/index.js"; +import type { Instance } from "@/models/entities/instance.js"; +import { DAY } from "@/const.js"; +import { shouldBlockInstance } from "./should-block-instance.js"; + +// Threshold from last contact after which an instance will be considered +// "dead" and should no longer get activities delivered to it. +const deadThreshold = 7 * DAY; + +/** + * Returns the subset of hosts which should be skipped. + * + * @param hosts array of punycoded instance hosts + * @returns array of punycoed instance hosts that should be skipped (subset of hosts parameter) + */ +export async function skippedInstances( + hosts: Instance["host"][], +): Promise { + // first check for blocked instances since that info may already be in memory + const meta = await fetchMeta(); + const shouldSkip = await Promise.all( + hosts.map((host) => shouldBlockInstance(host, meta)), + ); + const skipped = hosts.filter((_, i) => shouldSkip[i]); + + // if possible return early and skip accessing the database + if (skipped.length === hosts.length) return hosts; + + const deadTime = new Date(Date.now() - deadThreshold); + + return skipped.concat( + await Instances.createQueryBuilder("instance") + .where("instance.host in (:...hosts)", { + // don't check hosts again that we already know are suspended + // also avoids adding duplicates to the list + hosts: hosts.filter((host) => !skipped.includes(host)), + }) + .andWhere( + new Brackets((qb) => { + qb.where("instance.isSuspended") + .orWhere(new Brackets((qb) => { + qb.where("instance.isNotResponding") + .andWhere("instance.lastCommunicatedAt < :deadTime", { deadTime }); + })); + }), + ) + .select("host") + .getRawMany(), + ); +} + +/** + * Returns whether a specific host (punycoded) should be skipped. + * Convenience wrapper around skippedInstances which should only be used if there is a single host to check. + * If you have multiple hosts, consider using skippedInstances instead to do a bulk check. + * + * @param host punycoded instance host + * @returns whether the given host should be skipped + */ +export async function shouldSkipInstance( + host: Instance["host"], +): Promise { + const skipped = await skippedInstances([host]); + return skipped.length > 0; +} diff --git a/packages/backend/src/misc/sql-like-escape.ts b/packages/backend/src/misc/sql-like-escape.ts new file mode 100644 index 0000000..0dd477a --- /dev/null +++ b/packages/backend/src/misc/sql-like-escape.ts @@ -0,0 +1,3 @@ +export function sqlLikeEscape(s: string) { + return s.replace(/([%_\\])/g, "\\$1"); +} diff --git a/packages/backend/src/misc/sql-regex-escape.ts b/packages/backend/src/misc/sql-regex-escape.ts new file mode 100644 index 0000000..abeec5e --- /dev/null +++ b/packages/backend/src/misc/sql-regex-escape.ts @@ -0,0 +1,3 @@ +export function sqlRegexEscape(s: string) { + return s.replace(/([!$()*+.:<=>?[\\\]^{|}-])/g, "\\$1"); +} diff --git a/packages/backend/src/misc/truncate.ts b/packages/backend/src/misc/truncate.ts new file mode 100644 index 0000000..6bc5894 --- /dev/null +++ b/packages/backend/src/misc/truncate.ts @@ -0,0 +1,17 @@ +import { substring } from "stringz"; + +export function truncate(input: string, size: number): string; +export function truncate( + input: string | undefined, + size: number, +): string | undefined; +export function truncate( + input: string | undefined, + size: number, +): string | undefined { + if (!input) { + return input; + } else { + return substring(input, 0, size); + } +} diff --git a/packages/backend/src/misc/webhook-cache.ts b/packages/backend/src/misc/webhook-cache.ts new file mode 100644 index 0000000..1eda5ea --- /dev/null +++ b/packages/backend/src/misc/webhook-cache.ts @@ -0,0 +1,49 @@ +import { Webhooks } from "@/models/index.js"; +import type { Webhook } from "@/models/entities/webhook.js"; +import { subscriber } from "@/db/redis.js"; + +let webhooksFetched = false; +let webhooks: Webhook[] = []; + +export async function getActiveWebhooks() { + if (!webhooksFetched) { + webhooks = await Webhooks.findBy({ + active: true, + }); + webhooksFetched = true; + } + + return webhooks; +} + +subscriber.on("message", async (_, data) => { + const obj = JSON.parse(data); + + if (obj.channel === "internal") { + const { type, body } = obj.message; + switch (type) { + case "webhookCreated": + if (body.active) { + webhooks.push(body); + } + break; + case "webhookUpdated": + if (body.active) { + const i = webhooks.findIndex((a) => a.id === body.id); + if (i > -1) { + webhooks[i] = body; + } else { + webhooks.push(body); + } + } else { + webhooks = webhooks.filter((a) => a.id !== body.id); + } + break; + case "webhookDeleted": + webhooks = webhooks.filter((a) => a.id !== body.id); + break; + default: + break; + } + } +}); diff --git a/packages/backend/src/models/entities/abuse-user-report.ts b/packages/backend/src/models/entities/abuse-user-report.ts new file mode 100644 index 0000000..cb4d558 --- /dev/null +++ b/packages/backend/src/models/entities/abuse-user-report.ts @@ -0,0 +1,88 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +export class AbuseUserReport { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the AbuseUserReport.", + }) + public createdAt: Date; + + @Index() + @Column(id()) + public targetUserId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public targetUser: User | null; + + @Index() + @Column(id()) + public reporterId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public reporter: User | null; + + @Column({ + ...id(), + nullable: true, + }) + public assigneeId: User["id"] | null; + + @ManyToOne((type) => User, { + onDelete: "SET NULL", + }) + @JoinColumn() + public assignee: User | null; + + @Index() + @Column("boolean", { + default: false, + }) + public resolved: boolean; + + @Column("boolean", { + default: false, + }) + public forwarded: boolean; + + @Column("varchar", { + length: 2048, + }) + public comment: string; + + //#region Denormalized fields + @Index() + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public targetUserHost: string | null; + + @Index() + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public reporterHost: string | null; + //#endregion +} diff --git a/packages/backend/src/models/entities/access-token.ts b/packages/backend/src/models/entities/access-token.ts new file mode 100644 index 0000000..8b950b1 --- /dev/null +++ b/packages/backend/src/models/entities/access-token.ts @@ -0,0 +1,98 @@ +import { + Entity, + PrimaryColumn, + Index, + Column, + ManyToOne, + JoinColumn, +} from "typeorm"; +import { User } from "./user.js"; +import { App } from "./app.js"; +import { id } from "../id.js"; + +@Entity() +export class AccessToken { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the AccessToken.", + }) + public createdAt: Date; + + @Column("timestamp with time zone", { + nullable: true, + }) + public lastUsedAt: Date | null; + + @Index() + @Column("varchar", { + length: 128, + }) + public token: string; + + @Index() + @Column("varchar", { + length: 128, + nullable: true, + }) + public session: string | null; + + @Index() + @Column("varchar", { + length: 128, + }) + public hash: string; + + @Index() + @Column(id()) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column({ + ...id(), + nullable: true, + }) + public appId: App["id"] | null; + + @ManyToOne((type) => App, { + onDelete: "CASCADE", + }) + @JoinColumn() + public app: App | null; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public name: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public description: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public iconUrl: string | null; + + @Column("varchar", { + length: 64, + array: true, + default: "{}", + }) + public permission: string[]; + + @Column("boolean", { + default: false, + }) + public fetched: boolean; +} diff --git a/packages/backend/src/models/entities/announcement-read.ts b/packages/backend/src/models/entities/announcement-read.ts new file mode 100644 index 0000000..79af9e4 --- /dev/null +++ b/packages/backend/src/models/entities/announcement-read.ts @@ -0,0 +1,43 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { Announcement } from "./announcement.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["userId", "announcementId"], { unique: true }) +export class AnnouncementRead { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the AnnouncementRead.", + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column(id()) + public announcementId: Announcement["id"]; + + @ManyToOne((type) => Announcement, { + onDelete: "CASCADE", + }) + @JoinColumn() + public announcement: Announcement | null; +} diff --git a/packages/backend/src/models/entities/announcement.ts b/packages/backend/src/models/entities/announcement.ts new file mode 100644 index 0000000..7872c0f --- /dev/null +++ b/packages/backend/src/models/entities/announcement.ts @@ -0,0 +1,56 @@ +import { Entity, Index, Column, PrimaryColumn } from "typeorm"; +import { id } from "../id.js"; + +@Entity() +export class Announcement { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the Announcement.", + }) + public createdAt: Date; + + @Column("timestamp with time zone", { + comment: "The updated date of the Announcement.", + nullable: true, + }) + public updatedAt: Date | null; + + @Column("varchar", { + length: 8192, + nullable: false, + }) + public text: string; + + @Column("varchar", { + length: 256, + nullable: false, + }) + public title: string; + + @Column("varchar", { + length: 1024, + nullable: true, + }) + public imageUrl: string | null; + + @Column("boolean", { + default: false, + }) + public showPopup: boolean; + + @Column("boolean", { + default: false, + }) + public isGoodNews: boolean; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/antenna.ts b/packages/backend/src/models/entities/antenna.ts new file mode 100644 index 0000000..633dcc1 --- /dev/null +++ b/packages/backend/src/models/entities/antenna.ts @@ -0,0 +1,115 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; +import { UserList } from "./user-list.js"; +import { UserGroupJoining } from "./user-group-joining.js"; + +@Entity() +export class Antenna { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the Antenna.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The owner ID.", + }) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column("varchar", { + length: 128, + comment: "The name of the Antenna.", + }) + public name: string; + + @Column("enum", { + enum: ["home", "all", "users", "list", "group", "instances"], + }) + public src: "home" | "all" | "users" | "list" | "group" | "instances"; + + @Column({ + ...id(), + nullable: true, + }) + public userListId: UserList["id"] | null; + + @ManyToOne((type) => UserList, { + onDelete: "CASCADE", + }) + @JoinColumn() + public userList: UserList | null; + + @Column({ + ...id(), + nullable: true, + }) + public userGroupJoiningId: UserGroupJoining["id"] | null; + + @ManyToOne((type) => UserGroupJoining, { + onDelete: "CASCADE", + }) + @JoinColumn() + public userGroupJoining: UserGroupJoining | null; + + @Column("varchar", { + length: 1024, + array: true, + default: "{}", + }) + public users: string[]; + + @Column("jsonb", { + default: [], + }) + public instances: string[]; + + @Column("jsonb", { + default: [], + }) + public keywords: string[][]; + + @Column("jsonb", { + default: [], + }) + public excludeKeywords: string[][]; + + @Column("boolean", { + default: false, + }) + public caseSensitive: boolean; + + @Column("boolean", { + default: false, + }) + public withReplies: boolean; + + @Column("boolean") + public withFile: boolean; + + @Column("varchar", { + length: 2048, + nullable: true, + }) + public expression: string | null; + + @Column("boolean") + public notify: boolean; +} diff --git a/packages/backend/src/models/entities/app.ts b/packages/backend/src/models/entities/app.ts new file mode 100644 index 0000000..a41e35a --- /dev/null +++ b/packages/backend/src/models/entities/app.ts @@ -0,0 +1,62 @@ +import { Entity, PrimaryColumn, Column, Index, ManyToOne } from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +export class App { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the App.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: "The owner ID.", + }) + public userId: User["id"] | null; + + @ManyToOne((type) => User, { + onDelete: "SET NULL", + nullable: true, + }) + public user: User | null; + + @Index() + @Column("varchar", { + length: 64, + comment: "The secret key of the App.", + }) + public secret: string; + + @Column("varchar", { + length: 128, + comment: "The name of the App.", + }) + public name: string; + + @Column("varchar", { + length: 512, + comment: "The description of the App.", + }) + public description: string; + + @Column("varchar", { + length: 64, + array: true, + comment: "The permission of the App.", + }) + public permission: string[]; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "The callbackUrl of the App.", + }) + public callbackUrl: string | null; +} diff --git a/packages/backend/src/models/entities/attestation-challenge.ts b/packages/backend/src/models/entities/attestation-challenge.ts new file mode 100644 index 0000000..6a3a9c8 --- /dev/null +++ b/packages/backend/src/models/entities/attestation-challenge.ts @@ -0,0 +1,53 @@ +import { + PrimaryColumn, + Entity, + JoinColumn, + Column, + ManyToOne, + Index, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +export class AttestationChallenge { + @PrimaryColumn(id()) + public id: string; + + @Index() + @PrimaryColumn(id()) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column("varchar", { + length: 64, + comment: "Hex-encoded sha256 hash of the challenge.", + }) + public challenge: string; + + @Column("timestamp with time zone", { + comment: "The date challenge was created for expiry purposes.", + }) + public createdAt: Date; + + @Column("boolean", { + comment: + "Indicates that the challenge is only for registration purposes if true to prevent the challenge for being used as authentication.", + default: false, + }) + public registrationChallenge: boolean; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/auth-session.ts b/packages/backend/src/models/entities/auth-session.ts new file mode 100644 index 0000000..b31dca5 --- /dev/null +++ b/packages/backend/src/models/entities/auth-session.ts @@ -0,0 +1,50 @@ +import { + Entity, + PrimaryColumn, + Index, + Column, + ManyToOne, + JoinColumn, +} from "typeorm"; +import { User } from "./user.js"; +import { App } from "./app.js"; +import { id } from "../id.js"; + +@Entity() +export class AuthSession { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the AuthSession.", + }) + public createdAt: Date; + + @Index() + @Column("varchar", { + length: 128, + }) + public token: string; + + @Column({ + ...id(), + nullable: true, + }) + public userId: User["id"] | null; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + nullable: true, + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public appId: App["id"]; + + @ManyToOne((type) => App, { + onDelete: "CASCADE", + }) + @JoinColumn() + public app: App | null; +} diff --git a/packages/backend/src/models/entities/bite.ts b/packages/backend/src/models/entities/bite.ts new file mode 100644 index 0000000..6880500 --- /dev/null +++ b/packages/backend/src/models/entities/bite.ts @@ -0,0 +1,68 @@ +import { Check, Column, Entity, ManyToOne, PrimaryColumn, Index } from "typeorm"; +import { id } from "../id.js"; +import { User } from "./user.js"; +import { Note } from "./note.js"; + +@Entity() +@Check(`"targetUserId" IS NOT NULL OR "targetBiteId" IS NOT NULL OR "targetNoteId" IS NOT NULL`) +export class Bite { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone") + public createdAt: Date; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "null if local", + }) + public uri: string | null; + + @Column(id()) + public userId: string; + + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + public user: User | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public userHost: string; + + @Column({ ...id(), nullable: true }) + public targetUserId: string | null; + + @ManyToOne(() => User, { + onDelete: "CASCADE", + nullable: true, + }) + public targetUser: User | null; + + @Column({ ...id(), nullable: true }) + public targetBiteId: string | null; + + @ManyToOne(() => Bite, { + onDelete: "CASCADE", + nullable: true, + }) + public targetBite: Bite | null; + + @Column({ ...id(), nullable: true }) + public targetNoteId: string | null; + + @ManyToOne(() => Note, { + onDelete: "CASCADE", + nullable: true, + }) + public targetNote: Note | null; + + @Column("boolean", { + default: true, + }) + public replied: boolean; +} diff --git a/packages/backend/src/models/entities/blocking.ts b/packages/backend/src/models/entities/blocking.ts new file mode 100644 index 0000000..cbc22f5 --- /dev/null +++ b/packages/backend/src/models/entities/blocking.ts @@ -0,0 +1,61 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { UserGroup } from "./user-group.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["blockerId", "blockeeId"], { unique: true, where: '"groupId" IS NULL' }) +@Index(["groupId", "blockeeId"], { unique: true, where: '"groupId" IS NOT NULL' }) +export class Blocking { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the Blocking.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The blockee user ID.", + }) + public blockeeId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public blockee: User | null; + + @Index() + @Column({ ...id(), nullable: true }) + public groupId: UserGroup["id"] | null; + + @ManyToOne((type) => UserGroup, { + onDelete: "CASCADE", + }) + @JoinColumn() + public group: UserGroup | null; + + @Index() + @Column({ + ...id(), + comment: "The blocker user ID.", + }) + public blockerId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public blocker: User | null; +} diff --git a/packages/backend/src/models/entities/call-blocking.ts b/packages/backend/src/models/entities/call-blocking.ts new file mode 100644 index 0000000..a053d8d --- /dev/null +++ b/packages/backend/src/models/entities/call-blocking.ts @@ -0,0 +1,61 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { UserGroup } from "./user-group.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["blockerId", "blockeeId"], { unique: true, where: '"groupId" IS NULL' }) +@Index(["groupId", "blockeeId"], { unique: true, where: '"groupId" IS NOT NULL' }) +export class CallBlocking { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the CallBlocking.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The call-blocked user ID.", + }) + public blockeeId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public blockee: User | null; + + @Index() + @Column({ ...id(), nullable: true }) + public groupId: UserGroup["id"] | null; + + @ManyToOne((type) => UserGroup, { + onDelete: "CASCADE", + }) + @JoinColumn() + public group: UserGroup | null; + + @Index() + @Column({ + ...id(), + comment: "The call-blocking user ID.", + }) + public blockerId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public blocker: User | null; +} diff --git a/packages/backend/src/models/entities/channel-following.ts b/packages/backend/src/models/entities/channel-following.ts new file mode 100644 index 0000000..ee329fa --- /dev/null +++ b/packages/backend/src/models/entities/channel-following.ts @@ -0,0 +1,50 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; +import { Channel } from "./channel.js"; + +@Entity() +@Index(["followerId", "followeeId"], { unique: true }) +export class ChannelFollowing { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the ChannelFollowing.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The followee channel ID.", + }) + public followeeId: Channel["id"]; + + @ManyToOne((type) => Channel, { + onDelete: "CASCADE", + }) + @JoinColumn() + public followee: Channel | null; + + @Index() + @Column({ + ...id(), + comment: "The follower user ID.", + }) + public followerId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public follower: User | null; +} diff --git a/packages/backend/src/models/entities/channel-note-pining.ts b/packages/backend/src/models/entities/channel-note-pining.ts new file mode 100644 index 0000000..67d1d48 --- /dev/null +++ b/packages/backend/src/models/entities/channel-note-pining.ts @@ -0,0 +1,42 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { Note } from "./note.js"; +import { Channel } from "./channel.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["channelId", "noteId"], { unique: true }) +export class ChannelNotePining { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the ChannelNotePining.", + }) + public createdAt: Date; + + @Index() + @Column(id()) + public channelId: Channel["id"]; + + @ManyToOne((type) => Channel, { + onDelete: "CASCADE", + }) + @JoinColumn() + public channel: Channel | null; + + @Column(id()) + public noteId: Note["id"]; + + @ManyToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public note: Note | null; +} diff --git a/packages/backend/src/models/entities/channel.ts b/packages/backend/src/models/entities/channel.ts new file mode 100644 index 0000000..ea22fed --- /dev/null +++ b/packages/backend/src/models/entities/channel.ts @@ -0,0 +1,83 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; +import { DriveFile } from "./drive-file.js"; + +@Entity() +export class Channel { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the Channel.", + }) + public createdAt: Date; + + @Index() + @Column("timestamp with time zone", { + nullable: true, + }) + public lastNotedAt: Date | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: "The owner ID.", + }) + public userId: User["id"] | null; + + @ManyToOne((type) => User, { + onDelete: "SET NULL", + }) + @JoinColumn() + public user: User | null; + + @Column("varchar", { + length: 128, + comment: "The name of the Channel.", + }) + public name: string; + + @Column("varchar", { + length: 2048, + nullable: true, + comment: "The description of the Channel.", + }) + public description: string | null; + + @Column({ + ...id(), + nullable: true, + comment: "The ID of banner Channel.", + }) + public bannerId: DriveFile["id"] | null; + + @ManyToOne((type) => DriveFile, { + onDelete: "SET NULL", + }) + @JoinColumn() + public banner: DriveFile | null; + + @Index() + @Column("integer", { + default: 0, + comment: "The count of notes.", + }) + public notesCount: number; + + @Index() + @Column("integer", { + default: 0, + comment: "The count of users.", + }) + public usersCount: number; +} diff --git a/packages/backend/src/models/entities/clip-note.ts b/packages/backend/src/models/entities/clip-note.ts new file mode 100644 index 0000000..1697474 --- /dev/null +++ b/packages/backend/src/models/entities/clip-note.ts @@ -0,0 +1,44 @@ +import { + Entity, + Index, + JoinColumn, + Column, + ManyToOne, + PrimaryColumn, +} from "typeorm"; +import { Note } from "./note.js"; +import { Clip } from "./clip.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["noteId", "clipId"], { unique: true }) +export class ClipNote { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + comment: "The note ID.", + }) + public noteId: Note["id"]; + + @ManyToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public note: Note | null; + + @Index() + @Column({ + ...id(), + comment: "The clip ID.", + }) + public clipId: Clip["id"]; + + @ManyToOne((type) => Clip, { + onDelete: "CASCADE", + }) + @JoinColumn() + public clip: Clip | null; +} diff --git a/packages/backend/src/models/entities/clip.ts b/packages/backend/src/models/entities/clip.ts new file mode 100644 index 0000000..9554703 --- /dev/null +++ b/packages/backend/src/models/entities/clip.ts @@ -0,0 +1,52 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +export class Clip { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the Clip.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The owner ID.", + }) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column("varchar", { + length: 128, + comment: "The name of the Clip.", + }) + public name: string; + + @Column("boolean", { + default: false, + }) + public isPublic: boolean; + + @Column("varchar", { + length: 2048, + nullable: true, + comment: "The description of the Clip.", + }) + public description: string | null; +} diff --git a/packages/backend/src/models/entities/drive-file.ts b/packages/backend/src/models/entities/drive-file.ts new file mode 100644 index 0000000..c603986 --- /dev/null +++ b/packages/backend/src/models/entities/drive-file.ts @@ -0,0 +1,221 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { id } from "../id.js"; +import { User } from "./user.js"; +import { DriveFolder } from "./drive-folder.js"; +import { DB_MAX_IMAGE_COMMENT_LENGTH } from "@/misc/hard-limits.js"; + +@Entity() +@Index(["userId", "folderId", "id"]) +export class DriveFile { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the DriveFile.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: "The owner ID.", + }) + public userId: User["id"] | null; + + @ManyToOne((type) => User, { + onDelete: "SET NULL", + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column("varchar", { + length: 512, + nullable: true, + comment: "The host of owner. It will be null if the user in local.", + }) + public userHost: string | null; + + @Index() + @Column("varchar", { + length: 32, + comment: "The MD5 hash of the DriveFile.", + }) + public md5: string; + + @Column("varchar", { + length: 256, + comment: "The file name of the DriveFile.", + }) + public name: string; + + @Index() + @Column("varchar", { + length: 128, + comment: "The content type (MIME) of the DriveFile.", + }) + public type: string; + + @Column("integer", { + comment: "The file size (bytes) of the DriveFile.", + }) + public size: number; + + @Column("varchar", { + length: DB_MAX_IMAGE_COMMENT_LENGTH, + nullable: true, + comment: "The comment of the DriveFile.", + }) + public comment: string | null; + + @Column("varchar", { + length: 128, + nullable: true, + comment: "The BlurHash string.", + }) + public blurhash: string | null; + + @Column("jsonb", { + default: {}, + comment: + "The any properties of the DriveFile. For example, it includes image width/height.", + }) + public properties: { + width?: number; + height?: number; + orientation?: number; + avgColor?: string; + }; + + @Column("boolean") + public storedInternal: boolean; + + @Column("varchar", { + length: 512, + comment: "The URL of the DriveFile.", + }) + public url: string; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "The URL of the thumbnail of the DriveFile.", + }) + public thumbnailUrl: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "The URL of the webpublic of the DriveFile.", + }) + public webpublicUrl: string | null; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public webpublicType: string | null; + + @Index({ unique: true }) + @Column("varchar", { + length: 256, + nullable: true, + }) + public accessKey: string | null; + + @Index({ unique: true }) + @Column("varchar", { + length: 256, + nullable: true, + }) + public thumbnailAccessKey: string | null; + + @Index({ unique: true }) + @Column("varchar", { + length: 256, + nullable: true, + }) + public webpublicAccessKey: string | null; + + @Index() + @Column("varchar", { + length: 512, + nullable: true, + comment: + "The URI of the DriveFile. it will be null when the DriveFile is local.", + }) + public uri: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public src: string | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: + "The parent folder ID. If null, it means the DriveFile is located in root.", + }) + public folderId: DriveFolder["id"] | null; + + @ManyToOne((type) => DriveFolder, { + onDelete: "SET NULL", + }) + @JoinColumn() + public folder: DriveFolder | null; + + @Index() + @Column("boolean", { + default: false, + comment: "Whether the DriveFile is NSFW.", + }) + public isSensitive: boolean; + + @Index() + @Column("boolean", { + default: true, + comment: "Whether the DriveFile can be downloaded by users other than its owner.", + }) + public allowDownload: boolean; + + @Index() + @Column("boolean", { + default: false, + comment: "Whether the DriveFile is a Lua4Frozen database file.", + }) + public isDatabase: boolean; + + /** + * 外部の(信頼されていない)URLへの直リンクか否か + */ + @Index() + @Column("boolean", { + default: false, + comment: "Whether the DriveFile is direct link to remote server.", + }) + public isLink: boolean; + + @Column("jsonb", { + default: {}, + nullable: true, + }) + public requestHeaders: Record | null; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public requestIp: string | null; +} diff --git a/packages/backend/src/models/entities/drive-folder.ts b/packages/backend/src/models/entities/drive-folder.ts new file mode 100644 index 0000000..0bb2c7a --- /dev/null +++ b/packages/backend/src/models/entities/drive-folder.ts @@ -0,0 +1,57 @@ +import { + JoinColumn, + ManyToOne, + Entity, + PrimaryColumn, + Index, + Column, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +export class DriveFolder { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the DriveFolder.", + }) + public createdAt: Date; + + @Column("varchar", { + length: 128, + comment: "The name of the DriveFolder.", + }) + public name: string; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: "The owner ID.", + }) + public userId: User["id"] | null; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: + "The parent folder ID. If null, it means the DriveFolder is located in root.", + }) + public parentId: DriveFolder["id"] | null; + + @ManyToOne((type) => DriveFolder, { + onDelete: "SET NULL", + }) + @JoinColumn() + public parent: DriveFolder | null; +} diff --git a/packages/backend/src/models/entities/emoji.ts b/packages/backend/src/models/entities/emoji.ts new file mode 100644 index 0000000..546129d --- /dev/null +++ b/packages/backend/src/models/entities/emoji.ts @@ -0,0 +1,88 @@ +import { PrimaryColumn, Entity, Index, Column } from "typeorm"; +import { id } from "../id.js"; + +@Entity() +@Index(["name", "host"], { unique: true }) +export class Emoji { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + nullable: true, + }) + public updatedAt: Date | null; + + @Index() + @Column("varchar", { + length: 128, + }) + public name: string; + + @Index() + @Column("varchar", { + length: 512, + nullable: true, + }) + public host: string | null; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public category: string | null; + + @Column("varchar", { + length: 512, + }) + public originalUrl: string; + + @Column("varchar", { + length: 512, + default: "", + }) + public publicUrl: string; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public uri: string | null; + + // publicUrlの方のtypeが入る + // (mime) + @Column("varchar", { + length: 64, + nullable: true, + }) + public type: string | null; + + @Column("varchar", { + array: true, + length: 128, + default: "{}", + }) + public aliases: string[]; + + @Column("varchar", { + length: 1024, + nullable: true, + }) + public license: string | null; + + @Column("boolean", { + default: false, + }) + public glyph: boolean; + + @Column("integer", { + nullable: true, + comment: "Image width", + }) + public width: number | null; + + @Column("integer", { + nullable: true, + comment: "Image height", + }) + public height: number | null; +} diff --git a/packages/backend/src/models/entities/follow-request.ts b/packages/backend/src/models/entities/follow-request.ts new file mode 100644 index 0000000..a1c747d --- /dev/null +++ b/packages/backend/src/models/entities/follow-request.ts @@ -0,0 +1,99 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["followerId", "followeeId"], { unique: true }) +export class FollowRequest { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the FollowRequest.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The followee user ID.", + }) + public followeeId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public followee: User | null; + + @Index() + @Column({ + ...id(), + comment: "The follower user ID.", + }) + public followerId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public follower: User | null; + + @Column("varchar", { + length: 128, + nullable: true, + comment: "id of Follow Activity.", + }) + public requestId: string | null; + + //#region Denormalized fields + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public followerHost: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public followerInbox: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public followerSharedInbox: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public followeeHost: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public followeeInbox: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public followeeSharedInbox: string | null; + //#endregion +} diff --git a/packages/backend/src/models/entities/following.ts b/packages/backend/src/models/entities/following.ts new file mode 100644 index 0000000..ea8f325 --- /dev/null +++ b/packages/backend/src/models/entities/following.ts @@ -0,0 +1,95 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["followerId", "followeeId"], { unique: true }) +export class Following { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the Following.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The followee user ID.", + }) + public followeeId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public followee: User | null; + + @Index() + @Column({ + ...id(), + comment: "The follower user ID.", + }) + public followerId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public follower: User | null; + + //#region Denormalized fields + @Index() + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public followerHost: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public followerInbox: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public followerSharedInbox: string | null; + + @Index() + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public followeeHost: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public followeeInbox: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public followeeSharedInbox: string | null; + //#endregion +} diff --git a/packages/backend/src/models/entities/gallery-like.ts b/packages/backend/src/models/entities/gallery-like.ts new file mode 100644 index 0000000..259feb8 --- /dev/null +++ b/packages/backend/src/models/entities/gallery-like.ts @@ -0,0 +1,40 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; +import { GalleryPost } from "./gallery-post.js"; + +@Entity() +@Index(["userId", "postId"], { unique: true }) +export class GalleryLike { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone") + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public postId: GalleryPost["id"]; + + @ManyToOne((type) => GalleryPost, { + onDelete: "CASCADE", + }) + @JoinColumn() + public post: GalleryPost | null; +} diff --git a/packages/backend/src/models/entities/gallery-post.ts b/packages/backend/src/models/entities/gallery-post.ts new file mode 100644 index 0000000..9383486 --- /dev/null +++ b/packages/backend/src/models/entities/gallery-post.ts @@ -0,0 +1,90 @@ +import { + Entity, + Index, + JoinColumn, + Column, + PrimaryColumn, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; +import type { DriveFile } from "./drive-file.js"; + +@Entity() +export class GalleryPost { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the GalleryPost.", + }) + public createdAt: Date; + + @Index() + @Column("timestamp with time zone", { + comment: "The updated date of the GalleryPost.", + }) + public updatedAt: Date; + + @Column("varchar", { + length: 256, + }) + public title: string; + + @Column("varchar", { + length: 2048, + nullable: true, + }) + public description: string | null; + + @Index() + @Column({ + ...id(), + comment: "The ID of author.", + }) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + array: true, + default: "{}", + }) + public fileIds: DriveFile["id"][]; + + @Index() + @Column("boolean", { + default: false, + comment: "Whether the post is sensitive.", + }) + public isSensitive: boolean; + + @Index() + @Column("integer", { + default: 0, + }) + public likedCount: number; + + @Index() + @Column("varchar", { + length: 128, + array: true, + default: "{}", + }) + public tags: string[]; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/hashtag.ts b/packages/backend/src/models/entities/hashtag.ts new file mode 100644 index 0000000..7b3df1c --- /dev/null +++ b/packages/backend/src/models/entities/hashtag.ts @@ -0,0 +1,87 @@ +import { Entity, PrimaryColumn, Index, Column } from "typeorm"; +import type { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +export class Hashtag { + @PrimaryColumn(id()) + public id: string; + + @Index({ unique: true }) + @Column("varchar", { + length: 128, + }) + public name: string; + + @Column({ + ...id(), + array: true, + }) + public mentionedUserIds: User["id"][]; + + @Index() + @Column("integer", { + default: 0, + }) + public mentionedUsersCount: number; + + @Column({ + ...id(), + array: true, + }) + public mentionedLocalUserIds: User["id"][]; + + @Index() + @Column("integer", { + default: 0, + }) + public mentionedLocalUsersCount: number; + + @Column({ + ...id(), + array: true, + }) + public mentionedRemoteUserIds: User["id"][]; + + @Index() + @Column("integer", { + default: 0, + }) + public mentionedRemoteUsersCount: number; + + @Column({ + ...id(), + array: true, + }) + public attachedUserIds: User["id"][]; + + @Index() + @Column("integer", { + default: 0, + }) + public attachedUsersCount: number; + + @Column({ + ...id(), + array: true, + }) + public attachedLocalUserIds: User["id"][]; + + @Index() + @Column("integer", { + default: 0, + }) + public attachedLocalUsersCount: number; + + @Column({ + ...id(), + array: true, + }) + public attachedRemoteUserIds: User["id"][]; + + @Index() + @Column("integer", { + default: 0, + }) + public attachedRemoteUsersCount: number; +} diff --git a/packages/backend/src/models/entities/html-note-cache-entry.ts b/packages/backend/src/models/entities/html-note-cache-entry.ts new file mode 100644 index 0000000..58debc3 --- /dev/null +++ b/packages/backend/src/models/entities/html-note-cache-entry.ts @@ -0,0 +1,21 @@ +import { Entity, PrimaryColumn, Column, ManyToOne, JoinColumn } from "typeorm"; +import { id } from "../id.js"; +import { Note } from "./note.js"; + +@Entity() +export class HtmlNoteCacheEntry { + @PrimaryColumn(id()) + public noteId: Note["id"]; + + @ManyToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public note: Note | null; + + @Column("timestamp with time zone", { nullable: true }) + public updatedAt: Date; + + @Column("text", { nullable: true }) + public content: string | null; +} diff --git a/packages/backend/src/models/entities/html-user-cache-entry.ts b/packages/backend/src/models/entities/html-user-cache-entry.ts new file mode 100644 index 0000000..aa65819 --- /dev/null +++ b/packages/backend/src/models/entities/html-user-cache-entry.ts @@ -0,0 +1,26 @@ +import { Entity, PrimaryColumn, Column, ManyToOne, JoinColumn } from "typeorm"; +import { User } from "@/models/entities/user.js"; +import { id } from "../id.js"; + +@Entity() +export class HtmlUserCacheEntry { + @PrimaryColumn(id()) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column("timestamp with time zone", { nullable: true }) + public updatedAt: Date; + + @Column("text", { nullable: true }) + public bio: string | null; + + @Column("jsonb", { + default: [], + }) + public fields: MastodonEntity.Field[]; +} diff --git a/packages/backend/src/models/entities/instance.ts b/packages/backend/src/models/entities/instance.ts new file mode 100644 index 0000000..7b7701d --- /dev/null +++ b/packages/backend/src/models/entities/instance.ts @@ -0,0 +1,173 @@ +import { Entity, PrimaryColumn, Index, Column } from "typeorm"; +import { id } from "../id.js"; + +@Entity() +export class Instance { + @PrimaryColumn(id()) + public id: string; + + /** + * このインスタンスを捕捉した日時 + */ + @Index() + @Column("timestamp with time zone", { + comment: "The caught date of the Instance.", + }) + public caughtAt: Date; + + /** + * ホスト + */ + @Index({ unique: true }) + @Column("varchar", { + length: 512, + comment: "The host of the Instance.", + }) + public host: string; + + /** + * インスタンスのユーザー数 + */ + @Column("integer", { + default: 0, + comment: "The count of the users of the Instance.", + }) + public usersCount: number; + + /** + * インスタンスの投稿数 + */ + @Column("integer", { + default: 0, + comment: "The count of the notes of the Instance.", + }) + public notesCount: number; + + /** + * このインスタンスのユーザーからフォローされている、自インスタンスのユーザーの数 + */ + @Column("integer", { + default: 0, + }) + public followingCount: number; + + /** + * このインスタンスのユーザーをフォローしている、自インスタンスのユーザーの数 + */ + @Column("integer", { + default: 0, + }) + public followersCount: number; + + /** + * 直近のリクエスト送信日時 + */ + @Column("timestamp with time zone", { + nullable: true, + }) + public latestRequestSentAt: Date | null; + + /** + * 直近のリクエスト送信時のHTTPステータスコード + */ + @Column("integer", { + nullable: true, + }) + public latestStatus: number | null; + + /** + * 直近のリクエスト受信日時 + */ + @Column("timestamp with time zone", { + nullable: true, + }) + public latestRequestReceivedAt: Date | null; + + /** + * このインスタンスと最後にやり取りした日時 + */ + @Column("timestamp with time zone") + public lastCommunicatedAt: Date; + + /** + * このインスタンスと不通かどうか + */ + @Column("boolean", { + default: false, + }) + public isNotResponding: boolean; + + /** + * このインスタンスへの配信を停止するか + */ + @Index() + @Column("boolean", { + default: false, + }) + public isSuspended: boolean; + + @Column("varchar", { + length: 64, + nullable: true, + comment: "The software of the Instance.", + }) + public softwareName: string | null; + + @Column("varchar", { + length: 64, + nullable: true, + }) + public softwareVersion: string | null; + + @Column("boolean", { + nullable: true, + }) + public openRegistrations: boolean | null; + + @Column("varchar", { + length: 256, + nullable: true, + }) + public name: string | null; + + @Column("varchar", { + length: 4096, + nullable: true, + }) + public description: string | null; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public maintainerName: string | null; + + @Column("varchar", { + length: 256, + nullable: true, + }) + public maintainerEmail: string | null; + + @Column("varchar", { + length: 4096, + nullable: true, + }) + public iconUrl: string | null; + + @Column("varchar", { + length: 4096, + nullable: true, + }) + public faviconUrl: string | null; + + @Column("varchar", { + length: 64, + nullable: true, + }) + public themeColor: string | null; + + @Column("timestamp with time zone", { + nullable: true, + }) + public infoUpdatedAt: Date | null; +} diff --git a/packages/backend/src/models/entities/interaction-stamp.ts b/packages/backend/src/models/entities/interaction-stamp.ts new file mode 100644 index 0000000..3a34027 --- /dev/null +++ b/packages/backend/src/models/entities/interaction-stamp.ts @@ -0,0 +1,35 @@ +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from "typeorm"; +import { id } from "../id.js"; +import { Note } from "./note.js"; + +@Entity() +@Index(["noteId", "targetNoteId"], { unique: true }) +export class InteractionStamp { + @PrimaryColumn(id()) + public id: string; + + @Column("enum", { + enum: ["quote"], + }) + public type: "quote"; + + @Column(id()) + public targetNoteId: Note["id"]; + + @ManyToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + // local note being quoted (quotee) + public targetNote?: Note | null; + + @Column(id()) + public noteId: Note["id"]; + + @ManyToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + // remote note quoting (quoter) + public note?: Note | null; +} diff --git a/packages/backend/src/models/entities/memoriet-archive.ts b/packages/backend/src/models/entities/memoriet-archive.ts new file mode 100644 index 0000000..a19a14c --- /dev/null +++ b/packages/backend/src/models/entities/memoriet-archive.ts @@ -0,0 +1,43 @@ +import { Column, Entity, Index, PrimaryColumn } from "typeorm"; +import { id } from "../id.js"; +import type { User } from "./user.js"; +import type { DriveFile } from "./drive-file.js"; +import type { noteVisibilities } from "../../types.js"; +import type { MemorietTextLayer } from "./memoriet.js"; + +@Entity("memoriet_archive") +export class MemorietArchive { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone") + public createdAt: Date; + + @Index() + @Column("timestamp with time zone") + public deletedAt: Date; + + @Index() + @Column(id()) + public userId: User["id"]; + + @Column("text", { nullable: true }) + public text: string | null; + + @Column("varchar", { length: 128, nullable: true }) + public cw: string | null; + + @Column({ + ...id(), + array: true, + default: "{}", + }) + public fileIds: DriveFile["id"][]; + + @Column("jsonb", { default: [] }) + public textLayers: MemorietTextLayer[]; + + @Column("varchar", { length: 32, default: "home" }) + public visibility: (typeof noteVisibilities)[number]; +} diff --git a/packages/backend/src/models/entities/memoriet-view.ts b/packages/backend/src/models/entities/memoriet-view.ts new file mode 100644 index 0000000..5a93482 --- /dev/null +++ b/packages/backend/src/models/entities/memoriet-view.ts @@ -0,0 +1,42 @@ +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, +} from "typeorm"; +import { id } from "../id.js"; +import { User } from "./user.js"; +import { Memoriet } from "./memoriet.js"; + +@Entity("memoriet_view") +@Index(["memorietId", "viewerId"], { unique: true }) +export class MemorietView { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone") + public createdAt: Date; + + @Index() + @Column("timestamp with time zone") + public viewedAt: Date; + + @Index() + @Column(id()) + public memorietId: Memoriet["id"]; + + @ManyToOne(() => Memoriet, { onDelete: "CASCADE" }) + @JoinColumn() + public memoriet: Memoriet | null; + + @Index() + @Column(id()) + public viewerId: User["id"]; + + @ManyToOne(() => User, { onDelete: "CASCADE" }) + @JoinColumn() + public viewer: User | null; +} diff --git a/packages/backend/src/models/entities/memoriet.ts b/packages/backend/src/models/entities/memoriet.ts new file mode 100644 index 0000000..2e04a34 --- /dev/null +++ b/packages/backend/src/models/entities/memoriet.ts @@ -0,0 +1,57 @@ +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, +} from "typeorm"; +import { id } from "../id.js"; +import { User } from "./user.js"; +import { Note } from "./note.js"; + +export type MemorietTextLayer = { + id: string; + text: string; + color: string; + backgroundColor: string | null; + backgroundWidth: number | null; + backgroundHeight: number | null; + fontSize: number; + x: number; + y: number; + rotate: number; +}; + +@Entity("memoriet") +export class Memoriet { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone") + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User["id"]; + + @ManyToOne(() => User, { onDelete: "CASCADE" }) + @JoinColumn() + public user: User | null; + + @Index() + @Column(id()) + public noteId: Note["id"]; + + @ManyToOne(() => Note, { onDelete: "CASCADE" }) + @JoinColumn() + public note: Note | null; + + @Index() + @Column("timestamp with time zone", { nullable: true }) + public expiresAt: Date | null; + + @Column("jsonb", { default: [] }) + public textLayers: MemorietTextLayer[]; +} diff --git a/packages/backend/src/models/entities/messaging-message.ts b/packages/backend/src/models/entities/messaging-message.ts new file mode 100644 index 0000000..d1da00e --- /dev/null +++ b/packages/backend/src/models/entities/messaging-message.ts @@ -0,0 +1,101 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { DriveFile } from "./drive-file.js"; +import { id } from "../id.js"; +import { UserGroup } from "./user-group.js"; + +@Entity() +export class MessagingMessage { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the MessagingMessage.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The sender user ID.", + }) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: "The recipient user ID.", + }) + public recipientId: User["id"] | null; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public recipient: User | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: "The recipient group ID.", + }) + public groupId: UserGroup["id"] | null; + + @ManyToOne((type) => UserGroup, { + onDelete: "CASCADE", + }) + @JoinColumn() + public group: UserGroup | null; + + @Column("varchar", { + length: 4096, + nullable: true, + }) + public text: string | null; + + @Column("boolean", { + default: false, + }) + public isRead: boolean; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public uri: string | null; + + @Column({ + ...id(), + array: true, + default: "{}", + }) + public reads: User["id"][]; + + @Column({ + ...id(), + nullable: true, + }) + public fileId: DriveFile["id"] | null; + + @ManyToOne((type) => DriveFile, { + onDelete: "CASCADE", + }) + @JoinColumn() + public file: DriveFile | null; +} diff --git a/packages/backend/src/models/entities/meta.ts b/packages/backend/src/models/entities/meta.ts new file mode 100644 index 0000000..e880388 --- /dev/null +++ b/packages/backend/src/models/entities/meta.ts @@ -0,0 +1,516 @@ +import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from "typeorm"; +import { id } from "../id.js"; +import { User } from "./user.js"; +import type { Clip } from "./clip.js"; + +@Entity() +export class Meta { + @PrimaryColumn({ + type: "varchar", + length: 32, + }) + public id: string; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public name: string | null; + + @Column("varchar", { + length: 1024, + nullable: true, + }) + public description: string | null; + + /** + * メンテナの名前 + */ + @Column("varchar", { + length: 128, + nullable: true, + }) + public maintainerName: string | null; + + /** + * メンテナの連絡先 + */ + @Column("varchar", { + length: 128, + nullable: true, + }) + public maintainerEmail: string | null; + + @Column("boolean", { + default: false, + }) + public disableRegistration: boolean; + + @Column("boolean", { + default: false, + }) + public disableLocalTimeline: boolean; + + @Column("boolean", { + default: true, + }) + public disableRecommendedTimeline: boolean; + + @Column("boolean", { + default: false, + }) + public disableGlobalTimeline: boolean; + + @Column("varchar", { + length: 256, + default: "⭐", + }) + public defaultReaction: string; + + @Column("varchar", { + length: 64, + array: true, + default: "{}", + }) + public langs: string[]; + + @Column("varchar", { + length: 256, + array: true, + default: "{}", + }) + public pinnedUsers: string[]; + + @Column("varchar", { + length: 256, + array: true, + default: "{}", + }) + public recommendedInstances: string[]; + + @Column("varchar", { + length: 256, + array: true, + default: "{}", + }) + public customMOTD: string[]; + + @Column("varchar", { + length: 256, + array: true, + default: "{}", + }) + public customSplashIcons: string[]; + + @Column("varchar", { + length: 256, + array: true, + default: "{}", + }) + public hiddenTags: string[]; + + @Column("varchar", { + length: 256, + array: true, + default: "{}", + }) + public blockedHosts: string[]; + + @Column("varchar", { + length: 256, + array: true, + default: "{}", + }) + public silencedHosts: string[]; + + @Column("boolean", { + default: true, + }) + public secureMode: boolean; + + @Column("boolean", { + default: false, + }) + public privateMode: boolean; + + @Column("varchar", { + length: 256, + array: true, + default: "{}", + }) + public allowedHosts: string[]; + + @Column("varchar", { + length: 512, + array: true, + default: "{/featured,/channels,/explore,/pages,/about-iceshrimp}", + }) + public pinnedPages: string[]; + + @Column({ + ...id(), + nullable: true, + }) + public pinnedClipId: Clip["id"] | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public themeColor: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + default: "/twemoji/1f440.svg", + }) + public mascotImageUrl: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public bannerUrl: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public backgroundImageUrl: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public logoImageUrl: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + default: "/twemoji/1f480.svg", + }) + public errorImageUrl: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public iconUrl: string | null; + + @Column("boolean", { + default: false, + }) + public cacheRemoteFiles: boolean; + + @Column("boolean", { + default: false, + }) + public emailRequiredForSignup: boolean; + + @Column("boolean", { + default: false, + }) + public enableHcaptcha: boolean; + + @Column("varchar", { + length: 64, + nullable: true, + }) + public hcaptchaSiteKey: string | null; + + @Column("varchar", { + length: 64, + nullable: true, + }) + public hcaptchaSecretKey: string | null; + + @Column("boolean", { + default: false, + }) + public enableRecaptcha: boolean; + + @Column("varchar", { + length: 64, + nullable: true, + }) + public recaptchaSiteKey: string | null; + + @Column("varchar", { + length: 64, + nullable: true, + }) + public recaptchaSecretKey: string | null; + + @Column("integer", { + default: 1024, + comment: "Drive capacity of a local user (MB)", + }) + public localDriveCapacityMb: number; + + @Column("integer", { + default: 32, + comment: "Drive capacity of a remote user (MB)", + }) + public remoteDriveCapacityMb: number; + + @Column("integer", { + default: 16, + comment: "Lua4Frozen database capacity of a local user (MB)", + }) + public lua4frozenDatabaseCapacityMb: number; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public summalyProxy: string | null; + + @Column("boolean", { + default: false, + }) + public enableEmail: boolean; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public email: string | null; + + @Column("boolean", { + default: false, + }) + public smtpSecure: boolean; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public smtpHost: string | null; + + @Column("integer", { + nullable: true, + }) + public smtpPort: number | null; + + @Column("varchar", { + length: 1024, + nullable: true, + }) + public smtpUser: string | null; + + @Column("varchar", { + length: 1024, + nullable: true, + }) + public smtpPass: string | null; + + @Column("varchar", { + length: 128, + nullable: false, + }) + public swPublicKey: string; + + @Column("varchar", { + length: 128, + nullable: false, + }) + public swPrivateKey: string; + + @Column("boolean", { + default: false, + }) + public enableGithubIntegration: boolean; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public githubClientId: string | null; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public githubClientSecret: string | null; + + @Column("boolean", { + default: false, + }) + public enableDiscordIntegration: boolean; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public discordClientId: string | null; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public discordClientSecret: string | null; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public deeplAuthKey: string | null; + + @Column("boolean", { + default: false, + }) + public deeplIsPro: boolean; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public libreTranslateApiUrl: string | null; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public libreTranslateApiKey: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public ToSUrl: string | null; + + @Column("varchar", { + length: 512, + default: "https://iceshrimp.dev/iceshrimp/iceshrimp", + nullable: false, + }) + public repositoryUrl: string; + + @Column("varchar", { + length: 512, + default: "https://iceshrimp.dev/iceshrimp/iceshrimp/issues/new", + nullable: true, + }) + public feedbackUrl: string | null; + + @Column("varchar", { + length: 8192, + nullable: true, + }) + public defaultLightTheme: string | null; + + @Column("varchar", { + length: 8192, + nullable: true, + }) + public defaultDarkTheme: string | null; + + @Column("boolean", { + default: false, + }) + public useObjectStorage: boolean; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public objectStorageBucket: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public objectStoragePrefix: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public objectStorageBaseUrl: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public objectStorageEndpoint: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public objectStorageRegion: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public objectStorageAccessKey: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public objectStorageSecretKey: string | null; + + @Column("integer", { + nullable: true, + }) + public objectStoragePort: number | null; + + @Column("boolean", { + default: true, + }) + public objectStorageUseSSL: boolean; + + @Column("boolean", { + default: true, + }) + public objectStorageUseProxy: boolean; + + @Column("boolean", { + default: false, + }) + public objectStorageSetPublicRead: boolean; + + @Column("boolean", { + default: true, + }) + public objectStorageS3ForcePathStyle: boolean; + + @Column("boolean", { + default: false, + }) + public enableIpLogging: boolean; + + @Column("boolean", { + default: true, + }) + public enableActiveEmailValidation: boolean; + + @Column("jsonb", { + default: {}, + }) + public experimentalFeatures: Record; + + @Column("boolean", { + default: false, + }) + public enableServerMachineStats: boolean; + + @Column("boolean", { + default: true, + }) + public enableIdenticonGeneration: boolean; + + @Column("varchar", { + length: 256, + nullable: true, + }) + public donationLink: string | null; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public autofollowedAccount: string | null; +} diff --git a/packages/backend/src/models/entities/moderation-log.ts b/packages/backend/src/models/entities/moderation-log.ts new file mode 100644 index 0000000..26bf1cd --- /dev/null +++ b/packages/backend/src/models/entities/moderation-log.ts @@ -0,0 +1,39 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +export class ModerationLog { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the ModerationLog.", + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column("varchar", { + length: 128, + }) + public type: string; + + @Column("jsonb") + public info: Record; +} diff --git a/packages/backend/src/models/entities/muting.ts b/packages/backend/src/models/entities/muting.ts new file mode 100644 index 0000000..ad0dd24 --- /dev/null +++ b/packages/backend/src/models/entities/muting.ts @@ -0,0 +1,67 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { UserGroup } from "./user-group.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["muterId", "muteeId"], { unique: true, where: '"groupId" IS NULL' }) +@Index(["groupId", "muteeId"], { unique: true, where: '"groupId" IS NOT NULL' }) +export class Muting { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the Muting.", + }) + public createdAt: Date; + + @Index() + @Column("timestamp with time zone", { + nullable: true, + }) + public expiresAt: Date | null; + + @Index() + @Column({ + ...id(), + comment: "The mutee user ID.", + }) + public muteeId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public mutee: User | null; + + @Index() + @Column({ ...id(), nullable: true }) + public groupId: UserGroup["id"] | null; + + @ManyToOne((type) => UserGroup, { + onDelete: "CASCADE", + }) + @JoinColumn() + public group: UserGroup | null; + + @Index() + @Column({ + ...id(), + comment: "The muter user ID.", + }) + public muterId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public muter: User | null; +} diff --git a/packages/backend/src/models/entities/note-edit.ts b/packages/backend/src/models/entities/note-edit.ts new file mode 100644 index 0000000..8761e2b --- /dev/null +++ b/packages/backend/src/models/entities/note-edit.ts @@ -0,0 +1,53 @@ +import { + Entity, + JoinColumn, + Column, + ManyToOne, + PrimaryColumn, + Index, +} from "typeorm"; +import { Note } from "./note.js"; +import { id } from "../id.js"; +import { DriveFile } from "./drive-file.js"; + +@Entity() +export class NoteEdit { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + comment: "The ID of note.", + }) + public noteId: Note["id"]; + + @ManyToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public note: Note | null; + + @Column("text", { + nullable: true, + }) + public text: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public cw: string | null; + + @Column({ + ...id(), + array: true, + default: "{}", + }) + public fileIds: DriveFile["id"][]; + + @Column("timestamp with time zone", { + comment: "The updated date of the Note.", + }) + public updatedAt: Date; +} diff --git a/packages/backend/src/models/entities/note-favorite.ts b/packages/backend/src/models/entities/note-favorite.ts new file mode 100644 index 0000000..2c301b9 --- /dev/null +++ b/packages/backend/src/models/entities/note-favorite.ts @@ -0,0 +1,54 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { Note } from "./note.js"; +import { User } from "./user.js"; +import { UserGroup } from "./user-group.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["userId", "noteId"], { unique: true, where: '"groupId" IS NULL' }) +@Index(["groupId", "noteId"], { unique: true, where: '"groupId" IS NOT NULL' }) +export class NoteFavorite { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the NoteFavorite.", + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ ...id(), nullable: true }) + public groupId: UserGroup["id"] | null; + + @ManyToOne((type) => UserGroup, { + onDelete: "CASCADE", + }) + @JoinColumn() + public group: UserGroup | null; + + @Column(id()) + public noteId: Note["id"]; + + @ManyToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public note: Note | null; +} diff --git a/packages/backend/src/models/entities/note-reaction.ts b/packages/backend/src/models/entities/note-reaction.ts new file mode 100644 index 0000000..8717de4 --- /dev/null +++ b/packages/backend/src/models/entities/note-reaction.ts @@ -0,0 +1,63 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { Note } from "./note.js"; +import { UserGroup } from "./user-group.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["userId", "noteId"], { unique: true, where: '"groupId" IS NULL' }) +@Index(["groupId", "noteId"], { unique: true, where: '"groupId" IS NOT NULL' }) +export class NoteReaction { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the NoteReaction.", + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user?: User | null; + + @Index() + @Column({ ...id(), nullable: true }) + public groupId: UserGroup["id"] | null; + + @ManyToOne((type) => UserGroup, { + onDelete: "CASCADE", + }) + @JoinColumn() + public group?: UserGroup | null; + + @Index() + @Column(id()) + public noteId: Note["id"]; + + @ManyToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public note?: Note | null; + + // TODO: 対象noteのuserIdを非正規化したい(「受け取ったリアクション一覧」のようなものを(JOIN無しで)実装したいため) + + @Column("varchar", { + length: 260, + }) + public reaction: string; +} diff --git a/packages/backend/src/models/entities/note-thread-muting.ts b/packages/backend/src/models/entities/note-thread-muting.ts new file mode 100644 index 0000000..704b328 --- /dev/null +++ b/packages/backend/src/models/entities/note-thread-muting.ts @@ -0,0 +1,39 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { Note } from "./note.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["userId", "threadId"], { unique: true }) +export class NoteThreadMuting { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", {}) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + }) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column("varchar", { + length: 256, + }) + public threadId: string; +} diff --git a/packages/backend/src/models/entities/note-unread.ts b/packages/backend/src/models/entities/note-unread.ts new file mode 100644 index 0000000..95695cb --- /dev/null +++ b/packages/backend/src/models/entities/note-unread.ts @@ -0,0 +1,70 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { Note } from "./note.js"; +import { id } from "../id.js"; +import type { Channel } from "./channel.js"; + +@Entity() +@Index(["userId", "noteId"], { unique: true }) +export class NoteUnread { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column(id()) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column(id()) + public noteId: Note["id"]; + + @ManyToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public note: Note | null; + + /** + * メンションか否か + */ + @Index() + @Column("boolean") + public isMentioned: boolean; + + /** + * ダイレクト投稿か否か + */ + @Index() + @Column("boolean") + public isSpecified: boolean; + + //#region Denormalized fields + @Index() + @Column({ + ...id(), + comment: "[Denormalized]", + }) + public noteUserId: User["id"]; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: "[Denormalized]", + }) + public noteChannelId: Channel["id"] | null; + //#endregion +} diff --git a/packages/backend/src/models/entities/note-watching.ts b/packages/backend/src/models/entities/note-watching.ts new file mode 100644 index 0000000..724b084 --- /dev/null +++ b/packages/backend/src/models/entities/note-watching.ts @@ -0,0 +1,59 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { Note } from "./note.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["userId", "noteId"], { unique: true }) +export class NoteWatching { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the NoteWatching.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The watcher ID.", + }) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: "The target Note ID.", + }) + public noteId: Note["id"]; + + @ManyToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public note: Note | null; + + //#region Denormalized fields + @Index() + @Column({ + ...id(), + comment: "[Denormalized]", + }) + public noteUserId: Note["userId"]; + //#endregion +} diff --git a/packages/backend/src/models/entities/note.ts b/packages/backend/src/models/entities/note.ts new file mode 100644 index 0000000..91a6f7b --- /dev/null +++ b/packages/backend/src/models/entities/note.ts @@ -0,0 +1,313 @@ +import { + Entity, + Index, + JoinColumn, + Column, + PrimaryColumn, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { UserGroup } from "./user-group.js"; +import type { DriveFile } from "./drive-file.js"; +import { id } from "../id.js"; +import { noteVisibilities } from "../../types.js"; +import { Channel } from "./channel.js"; + +@Entity() +@Index("IDX_NOTE_TAGS", { synchronize: false }) +@Index("IDX_NOTE_MENTIONS", { synchronize: false }) +@Index("IDX_NOTE_VISIBLE_USER_IDS", { synchronize: false }) +@Index("IDX_note_userId_id", ["userId", "id"]) +@Index("IDX_note_id_userHost", ["id", "userHost"]) +@Index("IDX_note_createdAt_userId", ["createdAt", "userId"]) +export class Note { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the Note.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: "The ID of reply target.", + }) + public replyId: Note["id"] | null; + + @ManyToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public reply: Note | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: "The ID of renote target.", + }) + public renoteId: Note["id"] | null; + + @ManyToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public renote: Note | null; + + @Index() + @Column("varchar", { + length: 256, + nullable: true, + }) + public threadId: string | null; + + @Index('note_text_fts_idx', { synchronize: false }) + @Column("text", { + nullable: true, + }) + public text: string | null; + + @Column("varchar", { + length: 256, + nullable: true, + }) + public name: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public cw: string | null; + + @Index() + @Column({ + ...id(), + comment: "The ID of author.", + }) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: "The ID of acting group.", + }) + public groupId: UserGroup["id"] | null; + + @ManyToOne((type) => UserGroup, { + onDelete: "SET NULL", + }) + @JoinColumn() + public group: UserGroup | null; + + @Column("boolean", { + default: false, + }) + public localOnly: boolean; + + @Column("smallint", { + default: 0, + }) + public renoteCount: number; + + @Column("smallint", { + default: 0, + }) + public repliesCount: number; + + @Column("integer", { + default: 0, + }) + public viewCount: number; + + @Column("jsonb", { + default: {}, + }) + public reactions: Record; + + /** + * public ... 公開 + * home ... ホームタイムライン(ユーザーページのタイムライン含む)のみに流す + * hidden ... only visible on profile (doesnt federate, like local only, but can be fetched via AP like home) <- for now only used for post imports + * followers ... フォロワーのみ + * specified ... visibleUserIds で指定したユーザーのみ + */ + @Column("enum", { enum: noteVisibilities }) + public visibility: typeof noteVisibilities[number]; + + @Index({ unique: true }) + @Column("varchar", { + length: 512, + nullable: true, + comment: "The URI of a note. it will be null when the note is local.", + }) + public uri: string | null; + + @Index("IDX_note_url") + @Column("varchar", { + length: 512, + nullable: true, + comment: + "The human readable url of a note. it will be null when the note is local.", + }) + public url: string | null; + + @Column("integer", { + default: 0, + select: false, + }) + public score: number; + + @Index() + @Column({ + ...id(), + array: true, + default: "{}", + }) + public fileIds: DriveFile["id"][]; + + @Index() + @Column("varchar", { + length: 256, + array: true, + default: "{}", + }) + public attachedFileTypes: string[]; + + @Index() + @Column({ + ...id(), + array: true, + default: "{}", + }) + public visibleUserIds: User["id"][]; + + @Index() + @Column({ + ...id(), + array: true, + default: "{}", + }) + public mentions: User["id"][]; + + @Column("text", { + default: "[]", + }) + public mentionedRemoteUsers: string; + + @Column("varchar", { + length: 128, + array: true, + default: "{}", + }) + public emojis: string[]; + + @Index() + @Column("varchar", { + length: 128, + array: true, + default: "{}", + }) + public tags: string[]; + + @Column("boolean", { + default: false, + }) + public hasPoll: boolean; + + @Index() + @Column({ + ...id(), + nullable: true, + comment: "The ID of source channel.", + }) + public channelId: Channel["id"] | null; + + @ManyToOne((type) => Channel, { + onDelete: "CASCADE", + }) + @JoinColumn() + public channel: Channel | null; + + @Column("varchar", { + length: 512, + nullable: true, + }) + public quoteAuthorization: string | null; + + @Column("boolean", { + default: false + }) + public canQuote: boolean; + + //#region Denormalized fields + @Index() + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public userHost: string | null; + + @Column({ + ...id(), + nullable: true, + comment: "[Denormalized]", + }) + public replyUserId: User["id"] | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public replyUserHost: string | null; + + @Column({ + ...id(), + nullable: true, + comment: "[Denormalized]", + }) + public renoteUserId: User["id"] | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public renoteUserHost: string | null; + + @Column("timestamp with time zone", { + nullable: true, + comment: "The updated date of the Note.", + }) + public updatedAt: Date | null; + //#endregion + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} + +export type IMentionedRemoteUser = { + uri: string; + url?: string; + username: string; + host: string; +}; + +export type IMentionedRemoteUsers = IMentionedRemoteUser[]; diff --git a/packages/backend/src/models/entities/notification.ts b/packages/backend/src/models/entities/notification.ts new file mode 100644 index 0000000..d4ec779 --- /dev/null +++ b/packages/backend/src/models/entities/notification.ts @@ -0,0 +1,194 @@ +import { + Entity, + Index, + JoinColumn, + ManyToOne, + Column, + PrimaryColumn, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; +import { Note } from "./note.js"; +import { FollowRequest } from "./follow-request.js"; +import { UserGroupInvitation } from "./user-group-invitation.js"; +import { AccessToken } from "./access-token.js"; +import { notificationTypes } from "@/types.js"; +import { Bite } from "./bite.js"; + +@Entity() +export class Notification { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the Notification.", + }) + public createdAt: Date; + + /** + * Notification Recipient ID + */ + @Index() + @Column({ + ...id(), + comment: "The ID of recipient user of the Notification.", + }) + public notifieeId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public notifiee: User | null; + + /** + * Notification sender (initiator) + */ + @Index() + @Column({ + ...id(), + nullable: true, + comment: "The ID of sender user of the Notification.", + }) + public notifierId: User["id"] | null; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public notifier: User | null; + + /** + * Notification types: + * follow - Follow request + * mention - User was referenced in a post. + * reply - A post that a user made (or was watching) has been replied to. + * renote - A post that a user made (or was watching) has been renoted. + * quote - A post that a user made (or was watching) has been quoted and renoted. + * reaction - (自分または自分がWatchしている)投稿にリアクションされた + * pollVote - (自分または自分がWatchしている)投稿のアンケートに投票された + * pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した + * receiveFollowRequest - フォローリクエストされた + * followRequestAccepted - A follow request has been accepted. + * groupInvited - グループに招待された + * app - App notifications. + */ + @Index() + @Column("enum", { + enum: notificationTypes, + comment: "The type of the Notification.", + }) + public type: typeof notificationTypes[number]; + + /** + * Whether the notification was read. + */ + @Index() + @Column("boolean", { + default: false, + comment: "Whether the notification was read.", + }) + public isRead: boolean; + + @Column({ + ...id(), + nullable: true, + }) + public noteId: Note["id"] | null; + + @ManyToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public note: Note | null; + + @Column({ + ...id(), + nullable: true, + }) + public followRequestId: FollowRequest["id"] | null; + + @ManyToOne((type) => FollowRequest, { + onDelete: "CASCADE", + }) + @JoinColumn() + public followRequest: FollowRequest | null; + + @Column({ + ...id(), + nullable: true, + }) + public userGroupInvitationId: UserGroupInvitation["id"] | null; + + @ManyToOne((type) => UserGroupInvitation, { + onDelete: "CASCADE", + }) + @JoinColumn() + public userGroupInvitation: UserGroupInvitation | null; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public reaction: string | null; + + @Column("integer", { + nullable: true, + }) + public choice: number | null; + + /** + * App notification body + */ + @Column("varchar", { + length: 2048, + nullable: true, + }) + public customBody: string | null; + + /** + * App notification header + * (If omitted, it is expected to be displayed with the app name) + */ + @Column("varchar", { + length: 256, + nullable: true, + }) + public customHeader: string | null; + + /** + * App notification icon (URL) + * (If omitted, it is expected to be displayed as an app icon) + */ + @Column("varchar", { + length: 1024, + nullable: true, + }) + public customIcon: string | null; + + /** + * App notification app (token for) + */ + @Index() + @Column({ + ...id(), + nullable: true, + }) + public appAccessTokenId: AccessToken["id"] | null; + + @ManyToOne((type) => AccessToken, { + onDelete: "CASCADE", + }) + @JoinColumn() + public appAccessToken: AccessToken | null; + + @Index() + @Column({ ...id(), nullable: true }) + public biteId: Bite["id"] | null; + + @ManyToOne((type) => Bite, { + onDelete: "CASCADE", nullable: true + }) + public bite: Bite | null; +} diff --git a/packages/backend/src/models/entities/oauth-app.ts b/packages/backend/src/models/entities/oauth-app.ts new file mode 100644 index 0000000..137dba4 --- /dev/null +++ b/packages/backend/src/models/entities/oauth-app.ts @@ -0,0 +1,53 @@ +import { Entity, PrimaryColumn, Column, Index } from "typeorm"; +import { id } from "../id.js"; + +@Entity('oauth_app') +export class OAuthApp { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the OAuth application", + }) + public createdAt: Date; + + @Index({ unique: true }) + @Column("varchar", { + length: 64, + comment: "The client id of the OAuth application", + }) + public clientId: string; + + @Column("varchar", { + length: 64, + comment: "The client secret of the OAuth application", + }) + public clientSecret: string; + + @Column("varchar", { + length: 128, + comment: "The name of the OAuth application", + }) + public name: string; + + @Column("varchar", { + length: 256, + nullable: true, + comment: "The website of the OAuth application", + }) + public website: string | null; + + @Column("varchar", { + length: 64, + array: true, + comment: "The scopes requested by the OAuth application", + }) + public scopes: string[]; + + @Column("varchar", { + length: 512, + array: true, + comment: "The redirect URIs of the OAuth application", + }) + public redirectUris: string[]; +} diff --git a/packages/backend/src/models/entities/oauth-token.ts b/packages/backend/src/models/entities/oauth-token.ts new file mode 100644 index 0000000..4e7bd39 --- /dev/null +++ b/packages/backend/src/models/entities/oauth-token.ts @@ -0,0 +1,65 @@ +import { Entity, PrimaryColumn, Column, Index, ManyToOne, JoinColumn } from "typeorm"; +import { id } from "../id.js"; +import { OAuthApp } from "@/models/entities/oauth-app.js"; +import { User } from "@/models/entities/user.js"; + +@Entity('oauth_token') +export class OAuthToken { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the OAuth token", + }) + public createdAt: Date; + + @Column(id()) + public appId: OAuthApp["id"]; + + @ManyToOne(() => OAuthApp, { + onDelete: "CASCADE", + }) + @JoinColumn() + public app: OAuthApp; + + @Column(id()) + public userId: User["id"]; + + @ManyToOne(() => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User; + + @Index() + @Column("varchar", { + length: 64, + comment: "The auth code for the OAuth token", + }) + public code: string; + + @Index() + @Column("varchar", { + length: 64, + comment: "The OAuth token", + }) + public token: string; + + @Column("boolean", { + comment: "Whether or not the token has been activated", + }) + public active: boolean; + + @Column("varchar", { + length: 64, + array: true, + comment: "The scopes requested by the OAuth token", + }) + public scopes: string[]; + + @Column("varchar", { + length: 512, + comment: "The redirect URI of the OAuth token", + }) + public redirectUri: string; +} diff --git a/packages/backend/src/models/entities/page-like.ts b/packages/backend/src/models/entities/page-like.ts new file mode 100644 index 0000000..6304e0b --- /dev/null +++ b/packages/backend/src/models/entities/page-like.ts @@ -0,0 +1,40 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; +import { Page } from "./page.js"; + +@Entity() +@Index(["userId", "pageId"], { unique: true }) +export class PageLike { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone") + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public pageId: Page["id"]; + + @ManyToOne((type) => Page, { + onDelete: "CASCADE", + }) + @JoinColumn() + public page: Page | null; +} diff --git a/packages/backend/src/models/entities/page.ts b/packages/backend/src/models/entities/page.ts new file mode 100644 index 0000000..d0733c8 --- /dev/null +++ b/packages/backend/src/models/entities/page.ts @@ -0,0 +1,133 @@ +import { + Entity, + Index, + JoinColumn, + Column, + PrimaryColumn, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; +import { DriveFile } from "./drive-file.js"; + +@Entity() +@Index(["userId", "name"], { unique: true }) +export class Page { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the Page.", + }) + public createdAt: Date; + + @Index() + @Column("timestamp with time zone", { + comment: "The updated date of the Page.", + }) + public updatedAt: Date; + + @Column("varchar", { + length: 256, + }) + public title: string; + + @Index() + @Column("varchar", { + length: 256, + }) + public name: string; + + @Column("varchar", { + length: 256, + nullable: true, + }) + public summary: string | null; + + @Column("boolean") + public alignCenter: boolean; + + @Column("boolean") + public isPublic: boolean; + + @Column("boolean", { + default: false, + }) + public hideTitleWhenPinned: boolean; + + @Column("varchar", { + length: 32, + }) + public font: string; + + @Index() + @Column({ + ...id(), + comment: "The ID of author.", + }) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column({ + ...id(), + nullable: true, + }) + public eyeCatchingImageId: DriveFile["id"] | null; + + @ManyToOne((type) => DriveFile, { + onDelete: "CASCADE", + }) + @JoinColumn() + public eyeCatchingImage: DriveFile | null; + + @Column("jsonb", { + default: [], + }) + public content: Record[]; + + @Column("jsonb", { + default: [], + }) + public variables: Record[]; + + @Column("varchar", { + length: 16384, + default: "", + }) + public script: string; + + /** + * public ... 公開 + * followers ... フォロワーのみ + * specified ... visibleUserIds で指定したユーザーのみ + */ + @Column("enum", { enum: ["public", "followers", "specified"] }) + public visibility: "public" | "followers" | "specified"; + + @Index() + @Column({ + ...id(), + array: true, + default: "{}", + }) + public visibleUserIds: User["id"][]; + + @Column("integer", { + default: 0, + }) + public likedCount: number; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/password-reset-request.ts b/packages/backend/src/models/entities/password-reset-request.ts new file mode 100644 index 0000000..ab0bccb --- /dev/null +++ b/packages/backend/src/models/entities/password-reset-request.ts @@ -0,0 +1,37 @@ +import { + PrimaryColumn, + Entity, + Index, + Column, + ManyToOne, + JoinColumn, +} from "typeorm"; +import { id } from "../id.js"; +import { User } from "./user.js"; + +@Entity() +export class PasswordResetRequest { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone") + public createdAt: Date; + + @Index({ unique: true }) + @Column("varchar", { + length: 256, + }) + public token: string; + + @Index() + @Column({ + ...id(), + }) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; +} diff --git a/packages/backend/src/models/entities/plan.ts b/packages/backend/src/models/entities/plan.ts new file mode 100644 index 0000000..ca177c4 --- /dev/null +++ b/packages/backend/src/models/entities/plan.ts @@ -0,0 +1,36 @@ +import { PrimaryColumn, Entity, Index, Column } from "typeorm"; +import { id } from "../id.js"; + +@Entity() +export class Plan { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the Plan.", + }) + public createdAt: Date; + + @Column("timestamp with time zone", { + nullable: true, + }) + public updatedAt: Date | null; + + @Index({ unique: true }) + @Column("varchar", { + length: 128, + }) + public name: string; + + @Column("varchar", { + length: 64, + }) + public icon: string; + + @Column("varchar", { + length: 512, + default: "", + }) + public description: string; +} diff --git a/packages/backend/src/models/entities/poll-vote.ts b/packages/backend/src/models/entities/poll-vote.ts new file mode 100644 index 0000000..d59a720 --- /dev/null +++ b/packages/backend/src/models/entities/poll-vote.ts @@ -0,0 +1,47 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { Note } from "./note.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["userId", "noteId", "choice"], { unique: true }) +export class PollVote { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the PollVote.", + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column(id()) + public noteId: Note["id"]; + + @ManyToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public note: Note | null; + + @Column("integer") + public choice: number; +} diff --git a/packages/backend/src/models/entities/poll.ts b/packages/backend/src/models/entities/poll.ts new file mode 100644 index 0000000..c117d4e --- /dev/null +++ b/packages/backend/src/models/entities/poll.ts @@ -0,0 +1,82 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + OneToOne, +} from "typeorm"; +import { id } from "../id.js"; +import { Note } from "./note.js"; +import type { User } from "./user.js"; +import { noteVisibilities } from "../../types.js"; + +@Entity() +export class Poll { + @PrimaryColumn(id()) + public noteId: Note["id"]; + + @OneToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public note: Note | null; + + @Column("timestamp with time zone", { + nullable: true, + }) + public expiresAt: Date | null; + + @Column("boolean") + public multiple: boolean; + + @Column("varchar", { + length: 256, + array: true, + default: "{}", + }) + public choices: string[]; + + @Column("integer", { + array: true, + }) + public votes: number[]; + + //#region Denormalized fields + @Column("enum", { + enum: noteVisibilities, + comment: "[Denormalized]", + }) + public noteVisibility: typeof noteVisibilities[number]; + + @Index() + @Column({ + ...id(), + comment: "[Denormalized]", + }) + public userId: User["id"]; + + @Index() + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public userHost: string | null; + //#endregion + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} + +export type IPoll = { + choices: string[]; + votes?: number[]; + multiple: boolean; + expiresAt: Date | null; +}; diff --git a/packages/backend/src/models/entities/promo-note.ts b/packages/backend/src/models/entities/promo-note.ts new file mode 100644 index 0000000..8970cbd --- /dev/null +++ b/packages/backend/src/models/entities/promo-note.ts @@ -0,0 +1,45 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + OneToOne, +} from "typeorm"; +import { Note } from "./note.js"; +import type { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +export class PromoNote { + @PrimaryColumn(id()) + public noteId: Note["id"]; + + @OneToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public note: Note | null; + + @Column("timestamp with time zone") + public expiresAt: Date; + + @Column("integer", { + default: 0, + }) + public totalCredits: number; + + @Column("integer", { + default: 0, + }) + public remainingCredits: number; + + //#region Denormalized fields + @Index() + @Column({ + ...id(), + comment: "[Denormalized]", + }) + public userId: User["id"]; + //#endregion +} diff --git a/packages/backend/src/models/entities/promo-read.ts b/packages/backend/src/models/entities/promo-read.ts new file mode 100644 index 0000000..61d3992 --- /dev/null +++ b/packages/backend/src/models/entities/promo-read.ts @@ -0,0 +1,47 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { Note } from "./note.js"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["userId", "noteId", "readDay"], { unique: true }) +export class PromoRead { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the PromoRead.", + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public noteId: Note["id"]; + + @ManyToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public note: Note | null; + + @Column("varchar", { + length: 10, + }) + public readDay: string; +} diff --git a/packages/backend/src/models/entities/registration-tickets.ts b/packages/backend/src/models/entities/registration-tickets.ts new file mode 100644 index 0000000..549f05d --- /dev/null +++ b/packages/backend/src/models/entities/registration-tickets.ts @@ -0,0 +1,17 @@ +import { PrimaryColumn, Entity, Index, Column } from "typeorm"; +import { id } from "../id.js"; + +@Entity() +export class RegistrationTicket { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone") + public createdAt: Date; + + @Index({ unique: true }) + @Column("varchar", { + length: 64, + }) + public code: string; +} diff --git a/packages/backend/src/models/entities/registry-item.ts b/packages/backend/src/models/entities/registry-item.ts new file mode 100644 index 0000000..d044222 --- /dev/null +++ b/packages/backend/src/models/entities/registry-item.ts @@ -0,0 +1,69 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +// TODO: 同じdomain、同じscope、同じkeyのレコードは二つ以上存在しないように制約付けたい +@Entity() +export class RegistryItem { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the RegistryItem.", + }) + public createdAt: Date; + + @Column("timestamp with time zone", { + comment: "The updated date of the RegistryItem.", + }) + public updatedAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The owner ID.", + }) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column("varchar", { + length: 1024, + comment: "The key of the RegistryItem.", + }) + public key: string; + + @Column("jsonb", { + default: {}, + nullable: true, + comment: "The value of the RegistryItem.", + }) + public value: any | null; + + @Index() + @Column("varchar", { + length: 1024, + array: true, + default: "{}", + }) + public scope: string[]; + + // サードパーティアプリに開放するときのためのカラム + @Index() + @Column("varchar", { + length: 512, + nullable: true, + }) + public domain: string | null; +} diff --git a/packages/backend/src/models/entities/relay.ts b/packages/backend/src/models/entities/relay.ts new file mode 100644 index 0000000..c7509dc --- /dev/null +++ b/packages/backend/src/models/entities/relay.ts @@ -0,0 +1,20 @@ +import { PrimaryColumn, Entity, Index, Column } from "typeorm"; +import { id } from "../id.js"; + +@Entity() +export class Relay { + @PrimaryColumn(id()) + public id: string; + + @Index({ unique: true }) + @Column("varchar", { + length: 512, + nullable: false, + }) + public inbox: string; + + @Column("enum", { + enum: ["requesting", "accepted", "rejected"], + }) + public status: "requesting" | "accepted" | "rejected"; +} diff --git a/packages/backend/src/models/entities/renote-muting.ts b/packages/backend/src/models/entities/renote-muting.ts new file mode 100644 index 0000000..e885649 --- /dev/null +++ b/packages/backend/src/models/entities/renote-muting.ts @@ -0,0 +1,49 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { id } from "../id.js"; +import { User } from "./user.js"; + +@Entity() +@Index(["muterId", "muteeId"], { unique: true }) +export class RenoteMuting { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the Muting.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The mutee user ID.", + }) + public muteeId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public mutee: User | null; + + @Index() + @Column({ + ...id(), + comment: "The muter user ID.", + }) + public muterId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public muter: User | null; +} diff --git a/packages/backend/src/models/entities/reversi-game.ts b/packages/backend/src/models/entities/reversi-game.ts new file mode 100644 index 0000000..a1df18f --- /dev/null +++ b/packages/backend/src/models/entities/reversi-game.ts @@ -0,0 +1,98 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryColumn, +} from "typeorm"; +import { id } from "../id.js"; +import { User } from "./user.js"; + +@Entity("reversi_game") +export class ReversiGame { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + nullable: true, + comment: "The started date of the ReversiGame.", + }) + public startedAt: Date | null; + + @Column("timestamp with time zone", { + nullable: true, + comment: "The ended date of the ReversiGame.", + }) + public endedAt: Date | null; + + @Column(id()) + public user1Id: User["id"]; + + @ManyToOne(() => User, { onDelete: "CASCADE" }) + @JoinColumn() + public user1: User | null; + + @Column(id()) + public user2Id: User["id"]; + + @ManyToOne(() => User, { onDelete: "CASCADE" }) + @JoinColumn() + public user2: User | null; + + @Column("boolean", { default: false }) + public user1Ready: boolean; + + @Column("boolean", { default: false }) + public user2Ready: boolean; + + @Column("integer", { nullable: true }) + public black: number | null; + + @Column("boolean", { default: false }) + public isStarted: boolean; + + @Column("boolean", { default: false }) + public isEnded: boolean; + + @Column({ ...id(), nullable: true }) + public winnerId: User["id"] | null; + + @Column({ ...id(), nullable: true }) + public surrenderedUserId: User["id"] | null; + + @Column({ ...id(), nullable: true }) + public timeoutUserId: User["id"] | null; + + @Column("smallint", { default: 90 }) + public timeLimitForEachTurn: number; + + @Column("jsonb", { default: [] }) + public logs: number[][]; + + @Column("varchar", { array: true, length: 64 }) + public map: string[]; + + @Column("varchar", { length: 32 }) + public bw: string; + + @Column("boolean", { default: false }) + public noIrregularRules: boolean; + + @Column("boolean", { default: false }) + public isLlotheo: boolean; + + @Column("boolean", { default: false }) + public canPutEverywhere: boolean; + + @Column("boolean", { default: false }) + public loopedBoard: boolean; + + @Column("jsonb", { nullable: true, default: null }) + public form1: any | null; + + @Column("jsonb", { nullable: true, default: null }) + public form2: any | null; + + @Column("varchar", { length: 32, nullable: true }) + public crc32: string | null; +} diff --git a/packages/backend/src/models/entities/reversi-matching.ts b/packages/backend/src/models/entities/reversi-matching.ts new file mode 100644 index 0000000..5372bc2 --- /dev/null +++ b/packages/backend/src/models/entities/reversi-matching.ts @@ -0,0 +1,20 @@ +import { Column, Entity, PrimaryColumn } from "typeorm"; +import { id } from "../id.js"; +import { User } from "./user.js"; + +@Entity("reversi_matching") +export class ReversiMatching { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the ReversiMatching.", + }) + public createdAt: Date; + + @Column(id()) + public parentId: User["id"]; + + @Column(id()) + public childId: User["id"]; +} diff --git a/packages/backend/src/models/entities/scheduled-note.ts b/packages/backend/src/models/entities/scheduled-note.ts new file mode 100644 index 0000000..7f56477 --- /dev/null +++ b/packages/backend/src/models/entities/scheduled-note.ts @@ -0,0 +1,51 @@ +import { + Column, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryColumn, +} from "typeorm"; +import { id } from "../id.js"; +import { User } from "./user.js"; +import { Note } from "./note.js"; + +export type ScheduledNoteStatus = "scheduled" | "processing" | "published" | "failed"; + +@Entity("scheduled_note") +export class ScheduledNote { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone") + public createdAt: Date; + + @Index() + @Column("timestamp with time zone") + public scheduledAt: Date; + + @Index() + @Column(id()) + public userId: User["id"]; + + @ManyToOne(() => User, { onDelete: "CASCADE" }) + @JoinColumn() + public user: User | null; + + @Column("varchar", { length: 16, default: "scheduled" }) + public status: ScheduledNoteStatus; + + @Column("jsonb") + public data: Record; + + @Column({ ...id(), nullable: true }) + public noteId: Note["id"] | null; + + @ManyToOne(() => Note, { onDelete: "SET NULL" }) + @JoinColumn() + public note: Note | null; + + @Column("text", { nullable: true }) + public error: string | null; +} diff --git a/packages/backend/src/models/entities/shogi-game.ts b/packages/backend/src/models/entities/shogi-game.ts new file mode 100644 index 0000000..8a23c9b --- /dev/null +++ b/packages/backend/src/models/entities/shogi-game.ts @@ -0,0 +1,68 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryColumn, +} from "typeorm"; +import { id } from "../id.js"; +import { User } from "./user.js"; + +@Entity("shogi_game") +export class ShogiGame { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { nullable: true }) + public startedAt: Date | null; + + @Column("timestamp with time zone", { nullable: true }) + public endedAt: Date | null; + + @Column(id()) + public user1Id: User["id"]; + + @ManyToOne(() => User, { onDelete: "CASCADE" }) + @JoinColumn() + public user1: User | null; + + @Column(id()) + public user2Id: User["id"]; + + @ManyToOne(() => User, { onDelete: "CASCADE" }) + @JoinColumn() + public user2: User | null; + + @Column("boolean", { default: false }) + public user1Ready: boolean; + + @Column("boolean", { default: false }) + public user2Ready: boolean; + + @Column("integer", { nullable: true }) + public sente: number | null; + + @Column("boolean", { default: false }) + public isStarted: boolean; + + @Column("boolean", { default: false }) + public isEnded: boolean; + + @Column({ ...id(), nullable: true }) + public winnerId: User["id"] | null; + + @Column({ ...id(), nullable: true }) + public surrenderedUserId: User["id"] | null; + + @Column("text") + public sfen: string; + + @Column("jsonb", { default: [] }) + public logs: Array<{ + id: string | null; + at: number; + userId: User["id"]; + usi: string; + sfen: string; + }>; +} diff --git a/packages/backend/src/models/entities/signin.ts b/packages/backend/src/models/entities/signin.ts new file mode 100644 index 0000000..517e71c --- /dev/null +++ b/packages/backend/src/models/entities/signin.ts @@ -0,0 +1,42 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +export class Signin { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the Signin.", + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column("varchar", { + length: 128, + }) + public ip: string; + + @Column("jsonb") + public headers: Record; + + @Column("boolean") + public success: boolean; +} diff --git a/packages/backend/src/models/entities/sw-subscription.ts b/packages/backend/src/models/entities/sw-subscription.ts new file mode 100644 index 0000000..f7823fb --- /dev/null +++ b/packages/backend/src/models/entities/sw-subscription.ts @@ -0,0 +1,49 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +export class SwSubscription { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone") + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column("varchar", { + length: 512, + }) + public endpoint: string; + + @Column("varchar", { + length: 256, + }) + public auth: string; + + @Column("varchar", { + length: 128, + }) + public publickey: string; + + @Column("boolean", { + default: false, + }) + public sendReadMessage: boolean; +} diff --git a/packages/backend/src/models/entities/used-username.ts b/packages/backend/src/models/entities/used-username.ts new file mode 100644 index 0000000..d00a259 --- /dev/null +++ b/packages/backend/src/models/entities/used-username.ts @@ -0,0 +1,20 @@ +import { PrimaryColumn, Entity, Column } from "typeorm"; + +@Entity() +export class UsedUsername { + @PrimaryColumn("varchar", { + length: 128, + }) + public username: string; + + @Column("timestamp with time zone") + public createdAt: Date; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/user-emoji.ts b/packages/backend/src/models/entities/user-emoji.ts new file mode 100644 index 0000000..d854edb --- /dev/null +++ b/packages/backend/src/models/entities/user-emoji.ts @@ -0,0 +1,69 @@ +import { Column, Entity, Index, PrimaryColumn } from "typeorm"; +import { id } from "../id.js"; +import { User } from "./user.js"; +import { UserGroup } from "./user-group.js"; + +@Entity() +@Index(["name", "userId"], { unique: true }) +@Index(["name", "userGroupId"], { unique: true }) +export class UserEmoji { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone") + public createdAt: Date; + + @Index() + @Column("varchar", { + length: 128, + }) + public name: string; + + @Index() + @Column({ + ...id(), + nullable: true, + }) + public userId: User["id"] | null; + + @Index() + @Column({ + ...id(), + nullable: true, + }) + public userGroupId: UserGroup["id"] | null; + + @Column("varchar", { + length: 512, + }) + public originalUrl: string; + + @Column("varchar", { + length: 512, + default: "", + }) + public publicUrl: string; + + @Column("varchar", { + length: 64, + nullable: true, + }) + public type: string | null; + + @Column("boolean", { + default: false, + }) + public glyph: boolean; + + @Column("integer", { + nullable: true, + comment: "Image width", + }) + public width: number | null; + + @Column("integer", { + nullable: true, + comment: "Image height", + }) + public height: number | null; +} diff --git a/packages/backend/src/models/entities/user-group-invitation.ts b/packages/backend/src/models/entities/user-group-invitation.ts new file mode 100644 index 0000000..fa2655a --- /dev/null +++ b/packages/backend/src/models/entities/user-group-invitation.ts @@ -0,0 +1,49 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { UserGroup } from "./user-group.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["userId", "userGroupId"], { unique: true }) +export class UserGroupInvitation { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the UserGroupInvitation.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The user ID.", + }) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: "The group ID.", + }) + public userGroupId: UserGroup["id"]; + + @ManyToOne((type) => UserGroup, { + onDelete: "CASCADE", + }) + @JoinColumn() + public userGroup: UserGroup | null; +} diff --git a/packages/backend/src/models/entities/user-group-joining.ts b/packages/backend/src/models/entities/user-group-joining.ts new file mode 100644 index 0000000..78f820d --- /dev/null +++ b/packages/backend/src/models/entities/user-group-joining.ts @@ -0,0 +1,49 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { UserGroup } from "./user-group.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["userId", "userGroupId"], { unique: true }) +export class UserGroupJoining { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the UserGroupJoining.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The user ID.", + }) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: "The group ID.", + }) + public userGroupId: UserGroup["id"]; + + @ManyToOne((type) => UserGroup, { + onDelete: "CASCADE", + }) + @JoinColumn() + public userGroup: UserGroup | null; +} diff --git a/packages/backend/src/models/entities/user-group.ts b/packages/backend/src/models/entities/user-group.ts new file mode 100644 index 0000000..5ea87b9 --- /dev/null +++ b/packages/backend/src/models/entities/user-group.ts @@ -0,0 +1,77 @@ +import { + Entity, + Index, + JoinColumn, + Column, + PrimaryColumn, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +export class UserGroup { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the UserGroup.", + }) + public createdAt: Date; + + @Column("varchar", { + length: 256, + }) + public name: string; + + @Index({ unique: true }) + @Column("varchar", { + length: 64, + nullable: true, + }) + public username: string | null; + + @Index() + @Column({ + ...id(), + comment: "The ID of owner.", + }) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column("boolean", { + default: false, + }) + public isPrivate: boolean; + + @Column("boolean", { + default: true, + }) + public allowCalls: boolean; + + @Column({ + ...id(), + nullable: true, + }) + public iconFileId: string | null; + + @Column({ + ...id(), + nullable: true, + }) + public symbolFileId: string | null; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/user-ip.ts b/packages/backend/src/models/entities/user-ip.ts new file mode 100644 index 0000000..c30e56b --- /dev/null +++ b/packages/backend/src/models/entities/user-ip.ts @@ -0,0 +1,31 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, + PrimaryGeneratedColumn, +} from "typeorm"; +import { id } from "../id.js"; +import { Note } from "./note.js"; +import type { User } from "./user.js"; + +@Entity() +@Index(["userId", "ip"], { unique: true }) +export class UserIp { + @PrimaryGeneratedColumn() + public id: string; + + @Column("timestamp with time zone", {}) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User["id"]; + + @Column("varchar", { + length: 128, + }) + public ip: string; +} diff --git a/packages/backend/src/models/entities/user-keypair.ts b/packages/backend/src/models/entities/user-keypair.ts new file mode 100644 index 0000000..f98384f --- /dev/null +++ b/packages/backend/src/models/entities/user-keypair.ts @@ -0,0 +1,33 @@ +import { PrimaryColumn, Entity, JoinColumn, Column, OneToOne } from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +export class UserKeypair { + @PrimaryColumn(id()) + public userId: User["id"]; + + @OneToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column("varchar", { + length: 4096, + }) + public publicKey: string; + + @Column("varchar", { + length: 4096, + }) + public privateKey: string; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/user-list-joining.ts b/packages/backend/src/models/entities/user-list-joining.ts new file mode 100644 index 0000000..4caa71a --- /dev/null +++ b/packages/backend/src/models/entities/user-list-joining.ts @@ -0,0 +1,49 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { UserList } from "./user-list.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["userId", "userListId"], { unique: true }) +export class UserListJoining { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the UserListJoining.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The user ID.", + }) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: "The list ID.", + }) + public userListId: UserList["id"]; + + @ManyToOne((type) => UserList, { + onDelete: "CASCADE", + }) + @JoinColumn() + public userList: UserList | null; +} diff --git a/packages/backend/src/models/entities/user-list.ts b/packages/backend/src/models/entities/user-list.ts new file mode 100644 index 0000000..1aba6d1 --- /dev/null +++ b/packages/backend/src/models/entities/user-list.ts @@ -0,0 +1,46 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +export class UserList { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the UserList.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The owner ID.", + }) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column("varchar", { + length: 128, + comment: "The name of the UserList.", + }) + public name: string; + + @Column("boolean", { + default: false, + comment: "Whether posts from list members should be hidden from the home timeline." + }) + public hideFromHomeTl: boolean; +} diff --git a/packages/backend/src/models/entities/user-note-pining.ts b/packages/backend/src/models/entities/user-note-pining.ts new file mode 100644 index 0000000..c30fe1e --- /dev/null +++ b/packages/backend/src/models/entities/user-note-pining.ts @@ -0,0 +1,42 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { Note } from "./note.js"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["userId", "noteId"], { unique: true }) +export class UserNotePining { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the UserNotePinings.", + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column(id()) + public noteId: Note["id"]; + + @ManyToOne((type) => Note, { + onDelete: "CASCADE", + }) + @JoinColumn() + public note: Note | null; +} diff --git a/packages/backend/src/models/entities/user-pending.ts b/packages/backend/src/models/entities/user-pending.ts new file mode 100644 index 0000000..18ae5ad --- /dev/null +++ b/packages/backend/src/models/entities/user-pending.ts @@ -0,0 +1,32 @@ +import { PrimaryColumn, Entity, Index, Column } from "typeorm"; +import { id } from "../id.js"; + +@Entity() +export class UserPending { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone") + public createdAt: Date; + + @Index({ unique: true }) + @Column("varchar", { + length: 128, + }) + public code: string; + + @Column("varchar", { + length: 128, + }) + public username: string; + + @Column("varchar", { + length: 128, + }) + public email: string; + + @Column("varchar", { + length: 128, + }) + public password: string; +} diff --git a/packages/backend/src/models/entities/user-plan.ts b/packages/backend/src/models/entities/user-plan.ts new file mode 100644 index 0000000..d4714f2 --- /dev/null +++ b/packages/backend/src/models/entities/user-plan.ts @@ -0,0 +1,50 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { Plan } from "./plan.js"; +import { id } from "../id.js"; + +@Entity() +@Index(["userId", "planId"], { unique: true }) +export class UserPlan { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the UserPlan.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The user ID.", + }) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), + comment: "The plan ID.", + }) + public planId: Plan["id"]; + + @ManyToOne((type) => Plan, { + onDelete: "CASCADE", + }) + @JoinColumn() + public plan: Plan | null; +} diff --git a/packages/backend/src/models/entities/user-profile.ts b/packages/backend/src/models/entities/user-profile.ts new file mode 100644 index 0000000..45a4d41 --- /dev/null +++ b/packages/backend/src/models/entities/user-profile.ts @@ -0,0 +1,283 @@ +import { + Entity, + Column, + Index, + OneToOne, + JoinColumn, + PrimaryColumn, +} from "typeorm"; +import { ffVisibility, notificationTypes } from "@/types.js"; +import { id } from "../id.js"; +import { User } from "./user.js"; +import { Page } from "./page.js"; + +// TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも +// ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン +@Entity() +export class UserProfile { + @PrimaryColumn(id()) + public userId: User["id"]; + + @OneToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column("varchar", { + length: 128, + nullable: true, + comment: "The location of the User.", + }) + public location: string | null; + + @Column("char", { + length: 10, + nullable: true, + comment: "The birthday (YYYY-MM-DD) of the User.", + }) + public birthday: string | null; + + @Column("varchar", { + length: 2048, + nullable: true, + comment: "The description (bio) of the User.", + }) + public description: string | null; + + @Column("jsonb", { + default: [], + }) + public fields: { + name: string; + value: string; + verified?: boolean; + }[]; + + @Column("jsonb", { + default: [], + }) + public mentions: IMentionedRemoteUsers; + + @Column("varchar", { + length: 32, + nullable: true, + }) + public lang: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "Remote URL of the user.", + }) + public url: string | null; + + @Column("varchar", { + length: 128, + nullable: true, + comment: "The email address of the User.", + }) + public email: string | null; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public emailVerifyCode: string | null; + + @Column("boolean", { + default: false, + }) + public emailVerified: boolean; + + @Column("jsonb", { + default: ["follow", "receiveFollowRequest", "groupInvited"], + }) + public emailNotificationTypes: string[]; + + @Column("boolean", { + default: false, + }) + public publicReactions: boolean; + + @Column("boolean", { + default: false, + }) + public allowCalls: boolean; + + @Column({ + ...id(), + nullable: true, + }) + public symbolFileId: string | null; + + @Column("enum", { + enum: ffVisibility, + default: "public", + }) + public ffVisibility: typeof ffVisibility[number]; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public twoFactorTempSecret: string | null; + + @Column("varchar", { + length: 128, + nullable: true, + }) + public twoFactorSecret: string | null; + + @Column("boolean", { + default: false, + }) + public twoFactorEnabled: boolean; + + @Column("boolean", { + default: false, + }) + public securityKeysAvailable: boolean; + + @Column("boolean", { + default: false, + }) + public usePasswordLessLogin: boolean; + + @Column("varchar", { + length: 128, + nullable: true, + comment: + "The password hash of the User. It will be null if the origin of the user is local.", + }) + public password: string | null; + + @Column("varchar", { + length: 8192, + default: "", + }) + public moderationNote: string | null; + + // TODO: そのうち消す + @Column("jsonb", { + default: {}, + comment: "The client-specific data of the User.", + }) + public clientData: Record; + + // TODO: そのうち消す + @Column("jsonb", { + default: {}, + comment: "The room data of the User.", + }) + public room: Record; + + @Column("boolean", { + default: false, + }) + public autoAcceptFollowed: boolean; + + @Column("boolean", { + default: false, + comment: "Whether reject index by crawler.", + }) + public noCrawle: boolean; + + @Column("boolean", { + default: true, + }) + public preventAiLearning: boolean; + + @Column("boolean", { + default: false, + }) + public alwaysMarkNsfw: boolean; + + @Column("boolean", { + default: false, + }) + public carefulBot: boolean; + + @Column("boolean", { + default: true, + }) + public injectFeaturedNote: boolean; + + @Column("boolean", { + default: true, + }) + public receiveAnnouncementEmail: boolean; + + @Column({ + ...id(), + nullable: true, + }) + public pinnedPageId: Page["id"] | null; + + @OneToOne((type) => Page, { + onDelete: "SET NULL", + }) + @JoinColumn() + public pinnedPage: Page | null; + + @Column("jsonb", { + default: {}, + }) + public integrations: Record; + + @Index() + @Column("boolean", { + default: false, + select: false, + }) + public enableWordMute: boolean; + + @Column("jsonb", { + default: [], + }) + public mutedWords: string[][]; + + @Column("jsonb", { + default: [], + comment: "List of instances muted by the user.", + }) + public mutedInstances: string[]; + + @Column("enum", { + enum: notificationTypes, + array: true, + default: [], + }) + public mutingNotificationTypes: typeof notificationTypes[number][]; + + @Column("jsonb", { + default: {}, + comment: "Language map of user pronouns" + }) + public pronouns: Record; + + //#region Denormalized fields + @Index() + @Column("varchar", { + length: 512, + nullable: true, + comment: "[Denormalized]", + }) + public userHost: string | null; + //#endregion + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} + +type IMentionedRemoteUsers = { + uri: string; + url?: string; + username: string; + host: string; +}[] diff --git a/packages/backend/src/models/entities/user-publickey.ts b/packages/backend/src/models/entities/user-publickey.ts new file mode 100644 index 0000000..e39b084 --- /dev/null +++ b/packages/backend/src/models/entities/user-publickey.ts @@ -0,0 +1,41 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + OneToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +export class UserPublickey { + @PrimaryColumn(id()) + public userId: User["id"]; + + @OneToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Index({ unique: true }) + @Column("varchar", { + length: 512, + }) + public keyId: string; + + @Column("varchar", { + length: 4096, + }) + public keyPem: string; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/user-security-key.ts b/packages/backend/src/models/entities/user-security-key.ts new file mode 100644 index 0000000..511cab4 --- /dev/null +++ b/packages/backend/src/models/entities/user-security-key.ts @@ -0,0 +1,55 @@ +import { + PrimaryColumn, + Entity, + JoinColumn, + Column, + ManyToOne, + Index, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +export class UserSecurityKey { + @PrimaryColumn("varchar", { + comment: "Variable-length id given to navigator.credentials.get()", + }) + public id: string; + + @Index() + @Column(id()) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column("varchar", { + comment: + "Variable-length public key used to verify attestations (hex-encoded).", + }) + public publicKey: string; + + @Column("timestamp with time zone", { + comment: + "The date of the last time the UserSecurityKey was successfully validated.", + }) + public lastUsed: Date; + + @Column("varchar", { + comment: "User-defined name for this key", + length: 30, + }) + public name: string; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} diff --git a/packages/backend/src/models/entities/user.ts b/packages/backend/src/models/entities/user.ts new file mode 100644 index 0000000..4d7b312 --- /dev/null +++ b/packages/backend/src/models/entities/user.ts @@ -0,0 +1,339 @@ +import { + Entity, + Column, + Index, + OneToOne, + JoinColumn, + PrimaryColumn, +} from "typeorm"; +import { id } from "../id.js"; +import { DriveFile } from "./drive-file.js"; + +@Entity() +@Index(["usernameLower", "host"], { unique: true }) +export class User { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the User.", + }) + public createdAt: Date; + + @Index() + @Column("timestamp with time zone", { + nullable: true, + comment: "The updated date of the User.", + }) + public updatedAt: Date | null; + + @Column("timestamp with time zone", { + nullable: true, + }) + public lastFetchedAt: Date | null; + + @Index() + @Column("timestamp with time zone", { + nullable: true, + }) + public lastActiveDate: Date | null; + + @Column("boolean", { + default: false, + }) + public hideOnlineStatus: boolean; + + @Column("varchar", { + length: 128, + comment: "The username of the User.", + }) + public username: string; + + @Index() + @Column("varchar", { + length: 128, + select: false, + comment: "The username (lowercased) of the User.", + }) + public usernameLower: string; + + @Column("varchar", { + length: 128, + nullable: true, + comment: "The name of the User.", + }) + public name: string | null; + + @Column("integer", { + default: 0, + comment: "The count of followers.", + }) + public followersCount: number; + + @Column("integer", { + default: 0, + comment: "The count of following.", + }) + public followingCount: number; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "The URI of the new account of the User", + }) + public movedToUri: string | null; + + @Column("simple-array", { + nullable: true, + comment: "URIs the user is known as too", + }) + public alsoKnownAs: string[] | null; + + @Column("integer", { + default: 0, + comment: "The count of notes.", + }) + public notesCount: number; + + @Column({ + ...id(), + nullable: true, + comment: "The ID of avatar DriveFile.", + }) + public avatarId: DriveFile["id"] | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "The URL of the avatar DriveFile", + }) + public avatarUrl: string | null; + + @Column("varchar", { + length: 128, + nullable: true, + comment: "The blurhash of the avatar DriveFile", + }) + public avatarBlurhash: string | null; + + @OneToOne((type) => DriveFile, { + onDelete: "SET NULL", + }) + @JoinColumn() + public avatar: DriveFile | null; + + @Column({ + ...id(), + nullable: true, + comment: "The ID of banner DriveFile.", + }) + public bannerId: DriveFile["id"] | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: "The URL of the banner DriveFile", + }) + public bannerUrl: string | null; + + @Column("varchar", { + length: 128, + nullable: true, + comment: "The blurhash of the banner DriveFile", + }) + public bannerBlurhash: string | null; + + @OneToOne((type) => DriveFile, { + onDelete: "SET NULL", + }) + @JoinColumn() + public banner: DriveFile | null; + + @Index() + @Column("varchar", { + length: 128, + array: true, + default: "{}", + }) + public tags: string[]; + + @Index() + @Column("boolean", { + default: false, + comment: "Whether the User is suspended.", + }) + public isSuspended: boolean; + + @Column("boolean", { + default: false, + comment: "Whether the User is silenced.", + }) + public isSilenced: boolean; + + @Column("boolean", { + default: false, + comment: "Whether the User is locked.", + }) + public isLocked: boolean; + + @Column("boolean", { + default: false, + comment: "Whether the User is a bot.", + }) + public isBot: boolean; + + @Column("boolean", { + default: false, + comment: "Whether the User is a cat.", + }) + public isCat: boolean; + + @Column("boolean", { + default: true, + comment: "Whether to speak as a cat if isCat.", + }) + public speakAsCat: boolean; + + @Column("boolean", { + default: false, + comment: "Whether the User is the admin.", + }) + public isAdmin: boolean; + + @Column("boolean", { + default: false, + comment: "Whether the User is a moderator.", + }) + public isModerator: boolean; + + @Column("boolean", { + default: false, + comment: "Whether the User has an administrator-issued verified badge.", + }) + public isVerified: boolean; + + @Column("varchar", { + length: 1, + array: true, + default: "{}", + comment: "Self-selected minor-safety badges.", + }) + public minorBadges: string[]; + + @Index() + @Column("boolean", { + default: true, + comment: "Whether the User is explorable.", + }) + public isExplorable: boolean; + + // アカウントが削除されたかどうかのフラグだが、完全に削除される際は物理削除なので実質削除されるまでの「削除が進行しているかどうか」のフラグ + @Column("boolean", { + default: false, + comment: "Whether the User is deleted.", + }) + public isDeleted: boolean; + + @Column("varchar", { + length: 128, + array: true, + default: "{}", + }) + public emojis: string[]; + + @Index() + @Column("varchar", { + length: 512, + nullable: true, + comment: + "The host of the User. It will be null if the origin of the user is local.", + }) + public host: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: + "The inbox URL of the User. It will be null if the origin of the user is local.", + }) + public inbox: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: + "The sharedInbox URL of the User. It will be null if the origin of the user is local.", + }) + public sharedInbox: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: + "The featured URL of the User. It will be null if the origin of the user is local.", + }) + public featured: string | null; + + @Index() + @Column("varchar", { + length: 512, + nullable: true, + comment: + "The URI of the User. It will be null if the origin of the user is local.", + }) + public uri: string | null; + + @Column("varchar", { + length: 512, + nullable: true, + comment: + "The URI of the user Follower Collection. It will be null if the origin of the user is local.", + }) + public followersUri: string | null; + + @Index({ unique: true }) + @Column("char", { + length: 16, + nullable: true, + unique: true, + comment: + "The native access token of the User. It will be null if the origin of the user is local.", + }) + public token: string | null; + + @Column("integer", { + nullable: true, + comment: "Overrides user drive capacity limit", + }) + public driveCapacityOverrideMb: number | null; + + @Column("enum", { + nullable: false, + default: "nobody", + enum: ["anyone", "followers", "nobody"] + }) + public canBite: "anyone" | "followers" | "nobody"; + + constructor(data: Partial) { + if (data == null) return; + + for (const [k, v] of Object.entries(data)) { + (this as any)[k] = v; + } + } +} + +export interface ILocalUser extends User { + host: null; +} + +export interface IRemoteUser extends User { + host: string; +} + +export type CacheableLocalUser = ILocalUser; + +export type CacheableRemoteUser = IRemoteUser; + +export type CacheableUser = CacheableLocalUser | CacheableRemoteUser; diff --git a/packages/backend/src/models/entities/verified-badge-request.ts b/packages/backend/src/models/entities/verified-badge-request.ts new file mode 100644 index 0000000..94f8504 --- /dev/null +++ b/packages/backend/src/models/entities/verified-badge-request.ts @@ -0,0 +1,62 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +@Entity() +export class VerifiedBadgeRequest { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the VerifiedBadgeRequest.", + }) + public createdAt: Date; + + @Column("timestamp with time zone", { + nullable: true, + }) + public resolvedAt: Date | null; + + @Index() + @Column(id()) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column({ + ...id(), + nullable: true, + }) + public resolverId: User["id"] | null; + + @ManyToOne((type) => User, { + onDelete: "SET NULL", + }) + @JoinColumn() + public resolver: User | null; + + @Index() + @Column("varchar", { + length: 16, + default: "pending", + }) + public status: "pending" | "approved" | "rejected"; + + @Column("varchar", { + length: 2048, + default: "", + }) + public comment: string; +} diff --git a/packages/backend/src/models/entities/webhook.ts b/packages/backend/src/models/entities/webhook.ts new file mode 100644 index 0000000..47fd799 --- /dev/null +++ b/packages/backend/src/models/entities/webhook.ts @@ -0,0 +1,91 @@ +import { + PrimaryColumn, + Entity, + Index, + JoinColumn, + Column, + ManyToOne, +} from "typeorm"; +import { User } from "./user.js"; +import { id } from "../id.js"; + +export const webhookEventTypes = [ + "mention", + "unfollow", + "follow", + "followed", + "note", + "reply", + "renote", + "reaction", +] as const; + +@Entity() +export class Webhook { + @PrimaryColumn(id()) + public id: string; + + @Column("timestamp with time zone", { + comment: "The created date of the Antenna.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The owner ID.", + }) + public userId: User["id"]; + + @ManyToOne((type) => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public user: User | null; + + @Column("varchar", { + length: 128, + comment: "The name of the Antenna.", + }) + public name: string; + + @Index() + @Column("varchar", { + length: 128, + array: true, + default: "{}", + }) + public on: typeof webhookEventTypes[number][]; + + @Column("varchar", { + length: 1024, + }) + public url: string; + + @Column("varchar", { + length: 1024, + }) + public secret: string; + + @Index() + @Column("boolean", { + default: true, + }) + public active: boolean; + + /** + * 直近のリクエスト送信日時 + */ + @Column("timestamp with time zone", { + nullable: true, + }) + public latestSentAt: Date | null; + + /** + * 直近のリクエスト送信時のHTTPステータスコード + */ + @Column("integer", { + nullable: true, + }) + public latestStatus: number | null; +} diff --git a/packages/backend/src/models/id.ts b/packages/backend/src/models/id.ts new file mode 100644 index 0000000..7e5a787 --- /dev/null +++ b/packages/backend/src/models/id.ts @@ -0,0 +1,4 @@ +export const id = () => ({ + type: "varchar" as const, + length: 32, +}); diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts new file mode 100644 index 0000000..c1e7de6 --- /dev/null +++ b/packages/backend/src/models/index.ts @@ -0,0 +1,168 @@ +import {} from "typeorm"; +import { db } from "@/db/postgre.js"; + +import { Announcement } from "./entities/announcement.js"; +import { AnnouncementRead } from "./entities/announcement-read.js"; +import { Instance } from "./entities/instance.js"; +import { Poll } from "./entities/poll.js"; +import { PollVote } from "./entities/poll-vote.js"; +import { Meta } from "./entities/meta.js"; +import { SwSubscription } from "./entities/sw-subscription.js"; +import { NoteWatching } from "./entities/note-watching.js"; +import { NoteThreadMuting } from "./entities/note-thread-muting.js"; +import { NoteUnread } from "./entities/note-unread.js"; +import { RegistrationTicket } from "./entities/registration-tickets.js"; +import { UserRepository } from "./repositories/user.js"; +import { NoteRepository } from "./repositories/note.js"; +import { DriveFileRepository } from "./repositories/drive-file.js"; +import { DriveFolderRepository } from "./repositories/drive-folder.js"; +import { AccessToken } from "./entities/access-token.js"; +import { UserNotePining } from "./entities/user-note-pining.js"; +import { SigninRepository } from "./repositories/signin.js"; +import { MessagingMessageRepository } from "./repositories/messaging-message.js"; +import { UserListRepository } from "./repositories/user-list.js"; +import { UserListJoining } from "./entities/user-list-joining.js"; +import { UserGroupRepository } from "./repositories/user-group.js"; +import { UserGroupJoining } from "./entities/user-group-joining.js"; +import { UserGroupInvitationRepository } from "./repositories/user-group-invitation.js"; +import { FollowRequestRepository } from "./repositories/follow-request.js"; +import { MutingRepository } from "./repositories/muting.js"; +import { RenoteMutingRepository } from "./repositories/renote-muting.js"; +import { BlockingRepository } from "./repositories/blocking.js"; +import { CallBlockingRepository } from "./repositories/call-blocking.js"; +import { NoteReactionRepository } from "./repositories/note-reaction.js"; +import { NotificationRepository } from "./repositories/notification.js"; +import { NoteFavoriteRepository } from "./repositories/note-favorite.js"; +import { UserPublickey } from "./entities/user-publickey.js"; +import { UserKeypair } from "./entities/user-keypair.js"; +import { AppRepository } from "./repositories/app.js"; +import { FollowingRepository } from "./repositories/following.js"; +import { AbuseUserReportRepository } from "./repositories/abuse-user-report.js"; +import { AuthSessionRepository } from "./repositories/auth-session.js"; +import { UserProfile } from "./entities/user-profile.js"; +import { AttestationChallenge } from "./entities/attestation-challenge.js"; +import { UserSecurityKey } from "./entities/user-security-key.js"; +import { HashtagRepository } from "./repositories/hashtag.js"; +import { PageRepository } from "./repositories/page.js"; +import { PageLikeRepository } from "./repositories/page-like.js"; +import { GalleryPostRepository } from "./repositories/gallery-post.js"; +import { GalleryLikeRepository } from "./repositories/gallery-like.js"; +import { ModerationLogRepository } from "./repositories/moderation-logs.js"; +import { UsedUsername } from "./entities/used-username.js"; +import { ClipRepository } from "./repositories/clip.js"; +import { ClipNote } from "./entities/clip-note.js"; +import { AntennaRepository } from "./repositories/antenna.js"; +import { PromoNote } from "./entities/promo-note.js"; +import { PromoRead } from "./entities/promo-read.js"; +import { EmojiRepository } from "./repositories/emoji.js"; +import { RelayRepository } from "./repositories/relay.js"; +import { ChannelRepository } from "./repositories/channel.js"; +import { ChannelFollowing } from "./entities/channel-following.js"; +import { ChannelNotePining } from "./entities/channel-note-pining.js"; +import { RegistryItem } from "./entities/registry-item.js"; +import { PasswordResetRequest } from "./entities/password-reset-request.js"; +import { UserPending } from "./entities/user-pending.js"; +import { InstanceRepository } from "./repositories/instance.js"; +import { Webhook } from "./entities/webhook.js"; +import { UserIp } from "./entities/user-ip.js"; +import { UserEmoji } from "./entities/user-emoji.js"; +import { NoteEdit } from "./entities/note-edit.js"; +import { OAuthApp } from "@/models/entities/oauth-app.js"; +import { OAuthToken } from "@/models/entities/oauth-token.js"; +import { UserProfileRepository } from "@/models/repositories/user-profile.js"; +import { HtmlNoteCacheEntry } from "@/models/entities/html-note-cache-entry.js"; +import { HtmlUserCacheEntry } from "@/models/entities/html-user-cache-entry.js"; +import { BiteRespository } from "./repositories/bite.js"; +import { InteractionStamp } from "./entities/interaction-stamp.js"; +import { ReversiGameRepository } from "./repositories/reversi-game.js"; +import { ReversiMatching } from "./entities/reversi-matching.js"; +import { ShogiGameRepository } from "./repositories/shogi-game.js"; +import { ScheduledNote } from "./entities/scheduled-note.js"; +import { Memoriet } from "./entities/memoriet.js"; +import { MemorietArchive } from "./entities/memoriet-archive.js"; +import { MemorietView } from "./entities/memoriet-view.js"; +import { VerifiedBadgeRequestRepository } from "./repositories/verified-badge-request.js"; +import { PlanRepository } from "./repositories/plan.js"; +import { UserPlan } from "./entities/user-plan.js"; + +export const Announcements = db.getRepository(Announcement); +export const AnnouncementReads = db.getRepository(AnnouncementRead); +export const Apps = AppRepository; +export const Notes = NoteRepository; +export const NoteEdits = db.getRepository(NoteEdit); +export const NoteFavorites = NoteFavoriteRepository; +export const NoteWatchings = db.getRepository(NoteWatching); +export const NoteThreadMutings = db.getRepository(NoteThreadMuting); +export const NoteReactions = NoteReactionRepository; +export const NoteUnreads = db.getRepository(NoteUnread); +export const Polls = db.getRepository(Poll); +export const PollVotes = db.getRepository(PollVote); +export const Users = UserRepository; +export const UserProfiles = UserProfileRepository; +export const UserKeypairs = db.getRepository(UserKeypair); +export const UserPendings = db.getRepository(UserPending); +export const AttestationChallenges = db.getRepository(AttestationChallenge); +export const UserSecurityKeys = db.getRepository(UserSecurityKey); +export const UserPublickeys = db.getRepository(UserPublickey); +export const UserLists = UserListRepository; +export const UserListJoinings = db.getRepository(UserListJoining); +export const UserGroups = UserGroupRepository; +export const UserGroupJoinings = db.getRepository(UserGroupJoining); +export const UserGroupInvitations = UserGroupInvitationRepository; +export const UserNotePinings = db.getRepository(UserNotePining); +export const UserIps = db.getRepository(UserIp); +export const UserEmojis = db.getRepository(UserEmoji); +export const UsedUsernames = db.getRepository(UsedUsername); +export const Followings = FollowingRepository; +export const FollowRequests = FollowRequestRepository; +export const Instances = InstanceRepository; +export const Emojis = EmojiRepository; +export const DriveFiles = DriveFileRepository; +export const DriveFolders = DriveFolderRepository; +export const Notifications = NotificationRepository; +export const Metas = db.getRepository(Meta); +export const Mutings = MutingRepository; +export const RenoteMutings = RenoteMutingRepository; +export const Blockings = BlockingRepository; +export const CallBlockings = CallBlockingRepository; +export const SwSubscriptions = db.getRepository(SwSubscription); +export const Hashtags = HashtagRepository; +export const AbuseUserReports = AbuseUserReportRepository; +export const RegistrationTickets = db.getRepository(RegistrationTicket); +export const AuthSessions = AuthSessionRepository; +export const AccessTokens = db.getRepository(AccessToken); +export const Signins = SigninRepository; +export const MessagingMessages = MessagingMessageRepository; +export const Pages = PageRepository; +export const PageLikes = PageLikeRepository; +export const GalleryPosts = GalleryPostRepository; +export const GalleryLikes = GalleryLikeRepository; +export const ModerationLogs = ModerationLogRepository; +export const Clips = ClipRepository; +export const ClipNotes = db.getRepository(ClipNote); +export const Antennas = AntennaRepository; +export const PromoNotes = db.getRepository(PromoNote); +export const PromoReads = db.getRepository(PromoRead); +export const Relays = RelayRepository; +export const Channels = ChannelRepository; +export const ChannelFollowings = db.getRepository(ChannelFollowing); +export const ChannelNotePinings = db.getRepository(ChannelNotePining); +export const RegistryItems = db.getRepository(RegistryItem); +export const Webhooks = db.getRepository(Webhook); +export const PasswordResetRequests = db.getRepository(PasswordResetRequest); +export const OAuthApps = db.getRepository(OAuthApp); +export const OAuthTokens = db.getRepository(OAuthToken); +export const HtmlUserCacheEntries = db.getRepository(HtmlUserCacheEntry); +export const HtmlNoteCacheEntries = db.getRepository(HtmlNoteCacheEntry); +export const Bites = BiteRespository; +export const InteractionStamps = db.getRepository(InteractionStamp); +export const ReversiGames = ReversiGameRepository; +export const ReversiMatchings = db.getRepository(ReversiMatching); +export const ShogiGames = ShogiGameRepository; +export const ScheduledNotes = db.getRepository(ScheduledNote); +export const Memoriets = db.getRepository(Memoriet); +export const MemorietArchives = db.getRepository(MemorietArchive); +export const MemorietViews = db.getRepository(MemorietView); +export const VerifiedBadgeRequests = VerifiedBadgeRequestRepository; +export const Plans = PlanRepository; +export const UserPlans = db.getRepository(UserPlan); diff --git a/packages/backend/src/models/repositories/abuse-user-report.ts b/packages/backend/src/models/repositories/abuse-user-report.ts new file mode 100644 index 0000000..07afef4 --- /dev/null +++ b/packages/backend/src/models/repositories/abuse-user-report.ts @@ -0,0 +1,39 @@ +import { db } from "@/db/postgre.js"; +import { Users } from "../index.js"; +import { AbuseUserReport } from "@/models/entities/abuse-user-report.js"; +import { awaitAll } from "@/prelude/await-all.js"; + +export const AbuseUserReportRepository = db + .getRepository(AbuseUserReport) + .extend({ + async pack(src: AbuseUserReport["id"] | AbuseUserReport) { + const report = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: report.id, + createdAt: report.createdAt.toISOString(), + comment: report.comment, + resolved: report.resolved, + reporterId: report.reporterId, + targetUserId: report.targetUserId, + assigneeId: report.assigneeId, + reporter: Users.pack(report.reporter || report.reporterId, null, { + detail: true, + }), + targetUser: Users.pack(report.targetUser || report.targetUserId, null, { + detail: true, + }), + assignee: report.assigneeId + ? Users.pack(report.assignee || report.assigneeId, null, { + detail: true, + }) + : null, + forwarded: report.forwarded, + }); + }, + + packMany(reports: any[]) { + return Promise.all(reports.map((x) => this.pack(x))); + }, + }); diff --git a/packages/backend/src/models/repositories/antenna.ts b/packages/backend/src/models/repositories/antenna.ts new file mode 100644 index 0000000..bcbc4c0 --- /dev/null +++ b/packages/backend/src/models/repositories/antenna.ts @@ -0,0 +1,33 @@ +import { db } from "@/db/postgre.js"; +import { Antenna } from "@/models/entities/antenna.js"; +import type { Packed } from "@/misc/schema.js"; +import { UserGroupJoinings } from "../index.js"; + +export const AntennaRepository = db.getRepository(Antenna).extend({ + async pack(src: Antenna["id"] | Antenna): Promise> { + const antenna = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + const userGroupJoining = antenna.userGroupJoiningId + ? await UserGroupJoinings.findOneBy({ id: antenna.userGroupJoiningId }) + : null; + + return { + id: antenna.id, + createdAt: antenna.createdAt.toISOString(), + name: antenna.name, + keywords: antenna.keywords, + excludeKeywords: antenna.excludeKeywords, + src: antenna.src, + userListId: antenna.userListId, + userGroupId: userGroupJoining ? userGroupJoining.userGroupId : null, + users: antenna.users, + instances: antenna.instances, + caseSensitive: antenna.caseSensitive, + notify: antenna.notify, + withReplies: antenna.withReplies, + withFile: antenna.withFile, + hasUnreadNote: false, + }; + }, +}); diff --git a/packages/backend/src/models/repositories/app.ts b/packages/backend/src/models/repositories/app.ts new file mode 100644 index 0000000..af3dfb8 --- /dev/null +++ b/packages/backend/src/models/repositories/app.ts @@ -0,0 +1,45 @@ +import { db } from "@/db/postgre.js"; +import { App } from "@/models/entities/app.js"; +import { AccessTokens } from "../index.js"; +import type { Packed } from "@/misc/schema.js"; +import type { User } from "../entities/user.js"; + +export const AppRepository = db.getRepository(App).extend({ + async pack( + src: App["id"] | App, + me?: { id: User["id"] } | null | undefined, + options?: { + detail?: boolean; + includeSecret?: boolean; + includeProfileImageIds?: boolean; + }, + ): Promise> { + const opts = Object.assign( + { + detail: false, + includeSecret: false, + includeProfileImageIds: false, + }, + options, + ); + + const app = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return { + id: app.id, + name: app.name, + callbackUrl: app.callbackUrl, + permission: app.permission, + ...(opts.includeSecret ? { secret: app.secret } : {}), + ...(me + ? { + isAuthorized: await AccessTokens.countBy({ + appId: app.id, + userId: me.id, + }).then((count) => count > 0), + } + : {}), + }; + }, +}); diff --git a/packages/backend/src/models/repositories/auth-session.ts b/packages/backend/src/models/repositories/auth-session.ts new file mode 100644 index 0000000..d3e1d45 --- /dev/null +++ b/packages/backend/src/models/repositories/auth-session.ts @@ -0,0 +1,21 @@ +import { db } from "@/db/postgre.js"; +import { Apps } from "../index.js"; +import { AuthSession } from "@/models/entities/auth-session.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import type { User } from "@/models/entities/user.js"; + +export const AuthSessionRepository = db.getRepository(AuthSession).extend({ + async pack( + src: AuthSession["id"] | AuthSession, + me?: { id: User["id"] } | null | undefined, + ) { + const session = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: session.id, + app: Apps.pack(session.appId, me), + token: session.token, + }); + }, +}); diff --git a/packages/backend/src/models/repositories/bite.ts b/packages/backend/src/models/repositories/bite.ts new file mode 100644 index 0000000..c8a4139 --- /dev/null +++ b/packages/backend/src/models/repositories/bite.ts @@ -0,0 +1,106 @@ +import { db } from "@/db/postgre.js"; +import { Bite } from "../entities/bite.js"; +import { Packed } from "@/misc/schema.js"; +import { Bites, Notes, Users } from "../index.js"; +import { User } from "../entities/user.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import config from "@/config/index.js"; + +export const BiteRespository = db.getRepository(Bite).extend({ + targetType(bite: Bite): "user" | "bite" | "note" { + if (bite.targetUserId) return "user"; + if (bite.targetBiteId) return "bite"; + return "note"; + }, + + async pack( + src: Bite | Bite["id"], + me?: { id: User["id"] } | null | undefined, + ): Promise> { + const bite = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + return await awaitAll({ + id: bite.id, + user: Users.pack(bite.user ?? bite.userId, me, { detail: false }), + targetType: BiteRespository.targetType(bite), + target: this.packTarget(bite, me), + replied: bite.replied, + }); + }, + + async packTarget( + bite: Bite, + me?: { id: User["id"] } | null | undefined, + ): Promise | Packed<"Bite">> { + switch (BiteRespository.targetType(bite)) { + case "user": + return await Users.pack(bite.targetUser ?? bite.targetUserId!, me, { + detail: false, + }); + case "bite": + return await this.pack(bite.targetBite ?? bite.targetBiteId!, me); + case "note": + return await Notes.pack(bite.targetNote ?? bite.targetNoteId!, me, { + detail: false, + }) + } + }, + + async targetUri(bite: Bite): Promise { + switch (BiteRespository.targetType(bite)) { + case "user": { + bite.targetUser = + bite.targetUser ?? + (await Users.findOneOrFail({ where: { id: bite.targetUserId! } })); + return ( + bite.targetUser.uri ?? `${config.url}/users/${bite.targetUserId}` + ); + } + case "bite": { + bite.targetBite = + bite.targetBite ?? + (await Bites.findOneOrFail({ where: { id: bite.targetBiteId! } })); + return ( + bite.targetBite.uri ?? `${config.url}/bites/${bite.targetBiteId}` + ); + } + case "note": { + bite.targetNote = + bite.targetNote ?? + (await Notes.findOneOrFail({ where: { id: bite.targetNoteId! } })); + return bite.targetNote.uri ?? `${config.url}/notes/${bite.targetBiteId}`; + } + } + }, + + async targetUserUri(bite: Bite): Promise { + switch (BiteRespository.targetType(bite)) { + case "user": + if (!bite.targetUser) + bite.targetUser = await Users.findOneByOrFail({ + id: bite.targetUserId!, + }); + return bite.targetUser!.uri ?? `${config.url}/users/${bite.targetUserId!}`; + case "bite": + bite.targetBite = + bite.targetBite ?? + (await Bites.findOneOrFail({ + where: { id: bite.targetBiteId! }, + relations: ["user"], + })); + bite.targetBite.user = bite.targetBite.user ?? + (await Users.findOneByOrFail({ id: bite.targetBite.userId })); + return bite.targetBite.user.uri ?? `${config.url}/users/${bite.targetBite.userId}`; + case "note": + bite.targetNote = + bite.targetNote ?? + (await Notes.findOneOrFail({ + where: { id: bite.targetNoteId! }, + relations: ["user"], + })); + bite.targetNote.user = bite.targetNote.user ?? + (await Users.findOneByOrFail({ id: bite.targetNote.userId })); + return bite.targetNote.user.uri ?? `${config.url}/users/${bite.targetNote.userId}`; + } + }, +}); diff --git a/packages/backend/src/models/repositories/blocking.ts b/packages/backend/src/models/repositories/blocking.ts new file mode 100644 index 0000000..3dfa74e --- /dev/null +++ b/packages/backend/src/models/repositories/blocking.ts @@ -0,0 +1,29 @@ +import { db } from "@/db/postgre.js"; +import { Users } from "../index.js"; +import { Blocking } from "@/models/entities/blocking.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import type { Packed } from "@/misc/schema.js"; +import type { User } from "@/models/entities/user.js"; + +export const BlockingRepository = db.getRepository(Blocking).extend({ + async pack( + src: Blocking["id"] | Blocking, + me?: { id: User["id"] } | null | undefined, + ): Promise> { + const blocking = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: blocking.id, + createdAt: blocking.createdAt.toISOString(), + blockeeId: blocking.blockeeId, + blockee: Users.pack(blocking.blockeeId, me, { + detail: true, + }), + }); + }, + + packMany(blockings: any[], me: { id: User["id"] }) { + return Promise.all(blockings.map((x) => this.pack(x, me))); + }, +}); diff --git a/packages/backend/src/models/repositories/call-blocking.ts b/packages/backend/src/models/repositories/call-blocking.ts new file mode 100644 index 0000000..4b89af2 --- /dev/null +++ b/packages/backend/src/models/repositories/call-blocking.ts @@ -0,0 +1,25 @@ +import { db } from "@/db/postgre.js"; +import { Users } from "../index.js"; +import { CallBlocking } from "@/models/entities/call-blocking.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import type { User } from "@/models/entities/user.js"; + +export const CallBlockingRepository = db.getRepository(CallBlocking).extend({ + async pack(src: CallBlocking["id"] | CallBlocking, me?: { id: User["id"] } | null | undefined) { + const blocking = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: blocking.id, + createdAt: blocking.createdAt.toISOString(), + blockeeId: blocking.blockeeId, + blockee: Users.pack(blocking.blockeeId, me, { + detail: true, + }), + }); + }, + + packMany(blockings: CallBlocking[], me: { id: User["id"] }) { + return Promise.all(blockings.map((x) => this.pack(x, me))); + }, +}); diff --git a/packages/backend/src/models/repositories/channel.ts b/packages/backend/src/models/repositories/channel.ts new file mode 100644 index 0000000..7800a65 --- /dev/null +++ b/packages/backend/src/models/repositories/channel.ts @@ -0,0 +1,55 @@ +import { db } from "@/db/postgre.js"; +import { Channel } from "@/models/entities/channel.js"; +import type { Packed } from "@/misc/schema.js"; +import { DriveFiles, ChannelFollowings, NoteUnreads } from "../index.js"; +import type { User } from "@/models/entities/user.js"; + +export const ChannelRepository = db.getRepository(Channel).extend({ + async pack( + src: Channel["id"] | Channel, + me?: { id: User["id"] } | null | undefined, + ): Promise> { + const channel = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + const meId = me ? me.id : null; + + const banner = channel.bannerId + ? await DriveFiles.findOneBy({ id: channel.bannerId }) + : null; + + const hasUnreadNote = meId + ? (await NoteUnreads.findOneBy({ + noteChannelId: channel.id, + userId: meId, + })) != null + : undefined; + + const following = meId + ? await ChannelFollowings.findOneBy({ + followerId: meId, + followeeId: channel.id, + }) + : null; + + return { + id: channel.id, + createdAt: channel.createdAt.toISOString(), + lastNotedAt: channel.lastNotedAt + ? channel.lastNotedAt.toISOString() + : null, + name: channel.name, + description: channel.description, + userId: channel.userId, + bannerUrl: banner ? DriveFiles.getPublicUrl(banner, false) : null, + usersCount: channel.usersCount, + notesCount: channel.notesCount, + + ...(me + ? { + isFollowing: following != null, + hasUnreadNote, + } + : {}), + }; + }, +}); diff --git a/packages/backend/src/models/repositories/clip.ts b/packages/backend/src/models/repositories/clip.ts new file mode 100644 index 0000000..0c21691 --- /dev/null +++ b/packages/backend/src/models/repositories/clip.ts @@ -0,0 +1,26 @@ +import { db } from "@/db/postgre.js"; +import { Clip } from "@/models/entities/clip.js"; +import type { Packed } from "@/misc/schema.js"; +import { Users } from "../index.js"; +import { awaitAll } from "@/prelude/await-all.js"; + +export const ClipRepository = db.getRepository(Clip).extend({ + async pack(src: Clip["id"] | Clip): Promise> { + const clip = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: clip.id, + createdAt: clip.createdAt.toISOString(), + userId: clip.userId, + user: Users.pack(clip.user || clip.userId), + name: clip.name, + description: clip.description, + isPublic: clip.isPublic, + }); + }, + + packMany(clips: Clip[]) { + return Promise.all(clips.map((x) => this.pack(x))); + }, +}); diff --git a/packages/backend/src/models/repositories/drive-file.ts b/packages/backend/src/models/repositories/drive-file.ts new file mode 100644 index 0000000..08f2c0e --- /dev/null +++ b/packages/backend/src/models/repositories/drive-file.ts @@ -0,0 +1,278 @@ +import { db } from "@/db/postgre.js"; +import { DriveFile } from "@/models/entities/drive-file.js"; +import type { User } from "@/models/entities/user.js"; +import { toPuny } from "@/misc/convert-host.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import type { Packed } from "@/misc/schema.js"; +import config from "@/config/index.js"; +import { appendQuery, query } from "@/prelude/url.js"; +import { DriveFolders, Users } from "../index.js"; +import { deepClone } from "@/misc/clone.js"; +import { fetchMetaSync } from "@/misc/fetch-meta.js"; + +type PackOptions = { + detail?: boolean; + self?: boolean; + withUser?: boolean; +}; + +export const DriveFileRepository = db.getRepository(DriveFile).extend({ + validateFileName(name: string): boolean { + return ( + name.trim().length > 0 && + name.length <= 200 && + name.indexOf("\\") === -1 && + name.indexOf("/") === -1 && + name.indexOf("..") === -1 + ); + }, + + getPublicProperties(file: DriveFile): DriveFile["properties"] { + if (file.properties.orientation != null) { + const properties = deepClone(file.properties); + if (file.properties.orientation >= 5) { + [properties.width, properties.height] = [ + properties.height, + properties.width, + ]; + } + properties.orientation = undefined; + return properties; + } + + return file.properties; + }, + + isImage(file: DriveFile): boolean { + return !!file.type && + [ + "image/png", + "image/apng", + "image/gif", + "image/jpeg", + "image/webp", + "image/svg+xml", + "image/avif", + ].includes(file.type); + }, + + isStreamableMedia(file: DriveFile): boolean { + return file.type.startsWith("video/") || file.type.startsWith("audio/"); + }, + + withStreamQuery(file: DriveFile, url: string | null): string | null { + if (url == null || file.allowDownload || !this.isStreamableMedia(file)) return url; + return appendQuery(url, query({ stream: "1" })); + }, + + getPublicUrl(file: DriveFile, thumbnail = false): string | null { + // リモートかつメディアプロキシ + if ( + file.uri != null && + file.userHost != null && + config.mediaProxy != null + ) { + return appendQuery( + config.mediaProxy, + query({ + url: file.uri, + thumbnail: thumbnail ? "1" : undefined, + }), + ); + } + + if (file.isLink && config.proxyRemoteFiles) { + const url = this.getDatabasePrefetchUrl(file, thumbnail); + if (url != null) return `${config.url}/proxy/${encodeURIComponent(new URL(url).pathname)}?${query({ url: url })}`; + } + + const url = thumbnail + ? file.thumbnailUrl || (this.isImage(file) ? file.webpublicUrl || file.url : null) + : file.webpublicUrl || file.url; + return thumbnail ? url : this.withStreamQuery(file, url); + }, + + getDatabasePrefetchUrl(file: DriveFile, thumbnail = false): string | null { + return thumbnail + ? file.thumbnailUrl ?? file.webpublicUrl ?? file.url + : file.webpublicUrl ?? file.url; + }, + + getFinalUrl(url: string): string { + if (!config.proxyRemoteFiles) return url; + if (!url.startsWith('https://') && !url.startsWith('http://')) return url; + if (url.startsWith(`${config.url}/files`)) return url; + if (url.startsWith(`${config.url}/static-assets`)) return url; + if (url.startsWith(`${config.url}/identicon`)) return url; + if (url.startsWith(`${config.url}/avatar`)) return url; + + const meta = fetchMetaSync(); + const baseUrl = meta ? meta.objectStorageBaseUrl ?? `${meta.objectStorageUseSSL ? "https" : "http"}://${meta.objectStorageEndpoint}${meta.objectStoragePort ? `:${meta.objectStoragePort}` : ""}/${meta.objectStorageBucket}` : null; + if (baseUrl !== null && url.startsWith(baseUrl)) return url; + + return `${config.url}/proxy/${encodeURIComponent(new URL(url).pathname)}?${query({ url: url })}`; + }, + + getFinalUrlMaybe(url?: string | null): string | null { + if (url == null) return null; + return this.getFinalUrl(url); + }, + + async calcDriveUsageOf( + user: User["id"] | { id: User["id"] }, + ): Promise { + const id = typeof user === "object" ? user.id : user; + + const { sum } = await this.createQueryBuilder("file") + .where("file.userId = :id", { id: id }) + .andWhere("file.isLink = FALSE") + .andWhere("file.isDatabase = FALSE") + .select("SUM(file.size)", "sum") + .getRawOne(); + + return parseInt(sum, 10) || 0; + }, + + async calcDatabaseUsageOf( + user: User["id"] | { id: User["id"] }, + ): Promise { + const id = typeof user === "object" ? user.id : user; + + const { sum } = await this.createQueryBuilder("file") + .where("file.userId = :id", { id: id }) + .andWhere("file.isLink = FALSE") + .andWhere("file.isDatabase = TRUE") + .select("SUM(file.size)", "sum") + .getRawOne(); + + return parseInt(sum, 10) || 0; + }, + + async calcDriveUsageOfHost(host: string): Promise { + const { sum } = await this.createQueryBuilder("file") + .where("file.userHost = :host", { host: toPuny(host) }) + .andWhere("file.isLink = FALSE") + .andWhere("file.isDatabase = FALSE") + .select("SUM(file.size)", "sum") + .getRawOne(); + + return parseInt(sum, 10) || 0; + }, + + async calcDriveUsageOfLocal(): Promise { + const { sum } = await this.createQueryBuilder("file") + .where("file.userHost IS NULL") + .andWhere("file.isLink = FALSE") + .andWhere("file.isDatabase = FALSE") + .select("SUM(file.size)", "sum") + .getRawOne(); + + return parseInt(sum, 10) || 0; + }, + + async calcDriveUsageOfRemote(): Promise { + const { sum } = await this.createQueryBuilder("file") + .where("file.userHost IS NOT NULL") + .andWhere("file.isLink = FALSE") + .andWhere("file.isDatabase = FALSE") + .select("SUM(file.size)", "sum") + .getRawOne(); + + return parseInt(sum, 10) || 0; + }, + + async pack( + src: DriveFile["id"] | DriveFile, + options?: PackOptions, + ): Promise> { + const opts = Object.assign( + { + detail: false, + self: false, + }, + options, + ); + + const file = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll>({ + id: file.id, + createdAt: file.createdAt.toISOString(), + name: file.name, + type: file.type, + md5: file.md5, + size: file.size, + isSensitive: file.isSensitive, + allowDownload: file.allowDownload, + isDatabase: file.isDatabase, + blurhash: file.blurhash, + properties: opts.self ? file.properties : this.getPublicProperties(file), + url: opts.self ? file.url : this.getPublicUrl(file, false), + thumbnailUrl: this.getPublicUrl(file, true), + comment: file.comment, + folderId: file.folderId, + folder: + opts.detail && file.folderId + ? DriveFolders.pack(file.folderId, { + detail: true, + }) + : null, + userId: opts.withUser ? file.userId : null, + user: opts.withUser && file.userId ? Users.pack(file.userId) : null, + }); + }, + + async packNullable( + src: DriveFile["id"] | DriveFile, + options?: PackOptions, + ): Promise | null> { + const opts = Object.assign( + { + detail: false, + self: false, + }, + options, + ); + + const file = + typeof src === "object" ? src : await this.findOneBy({ id: src }); + if (file == null) return null; + + return await awaitAll>({ + id: file.id, + createdAt: file.createdAt.toISOString(), + name: file.name, + type: file.type, + md5: file.md5, + size: file.size, + isSensitive: file.isSensitive, + allowDownload: file.allowDownload, + isDatabase: file.isDatabase, + blurhash: file.blurhash, + properties: opts.self ? file.properties : this.getPublicProperties(file), + url: opts.self ? file.url : this.getPublicUrl(file, false), + thumbnailUrl: this.getPublicUrl(file, true), + comment: file.comment, + folderId: file.folderId, + folder: + opts.detail && file.folderId + ? DriveFolders.pack(file.folderId, { + detail: true, + }) + : null, + userId: opts.withUser ? file.userId : null, + user: opts.withUser && file.userId ? Users.pack(file.userId) : null, + }); + }, + + async packMany( + files: (DriveFile["id"] | DriveFile)[], + options?: PackOptions, + ): Promise[]> { + const items = await Promise.all( + files.map((f) => this.packNullable(f, options)), + ); + return items.filter((x): x is Packed<"DriveFile"> => x != null); + }, +}); diff --git a/packages/backend/src/models/repositories/drive-folder.ts b/packages/backend/src/models/repositories/drive-folder.ts new file mode 100644 index 0000000..9823561 --- /dev/null +++ b/packages/backend/src/models/repositories/drive-folder.ts @@ -0,0 +1,50 @@ +import { db } from "@/db/postgre.js"; +import { DriveFolders, DriveFiles } from "../index.js"; +import { DriveFolder } from "@/models/entities/drive-folder.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import type { Packed } from "@/misc/schema.js"; + +export const DriveFolderRepository = db.getRepository(DriveFolder).extend({ + async pack( + src: DriveFolder["id"] | DriveFolder, + options?: { + detail: boolean; + }, + ): Promise> { + const opts = Object.assign( + { + detail: false, + }, + options, + ); + + const folder = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: folder.id, + createdAt: folder.createdAt.toISOString(), + name: folder.name, + parentId: folder.parentId, + + ...(opts.detail + ? { + foldersCount: DriveFolders.countBy({ + parentId: folder.id, + }), + filesCount: DriveFiles.countBy({ + folderId: folder.id, + }), + + ...(folder.parentId + ? { + parent: this.pack(folder.parentId, { + detail: true, + }), + } + : {}), + } + : {}), + }); + }, +}); diff --git a/packages/backend/src/models/repositories/emoji.ts b/packages/backend/src/models/repositories/emoji.ts new file mode 100644 index 0000000..68584cf --- /dev/null +++ b/packages/backend/src/models/repositories/emoji.ts @@ -0,0 +1,29 @@ +import { db } from "@/db/postgre.js"; +import { Emoji } from "@/models/entities/emoji.js"; +import type { Packed } from "@/misc/schema.js"; + +export const EmojiRepository = db.getRepository(Emoji).extend({ + async pack(src: Emoji["id"] | Emoji): Promise> { + const emoji = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return { + id: emoji.id, + aliases: emoji.aliases, + name: emoji.name, + category: emoji.category, + host: emoji.host, + // || emoji.originalUrl してるのは後方互換性のため + url: emoji.publicUrl || emoji.originalUrl, + license: emoji.license, + glyph: emoji.glyph, + glyphUrl: emoji.glyph ? emoji.originalUrl : null, + width: emoji.width, + height: emoji.height, + }; + }, + + packMany(emojis: any[]) { + return Promise.all(emojis.map((x) => this.pack(x))); + }, +}); diff --git a/packages/backend/src/models/repositories/follow-request.ts b/packages/backend/src/models/repositories/follow-request.ts new file mode 100644 index 0000000..cef6ea7 --- /dev/null +++ b/packages/backend/src/models/repositories/follow-request.ts @@ -0,0 +1,20 @@ +import { db } from "@/db/postgre.js"; +import { FollowRequest } from "@/models/entities/follow-request.js"; +import { Users } from "../index.js"; +import type { User } from "@/models/entities/user.js"; + +export const FollowRequestRepository = db.getRepository(FollowRequest).extend({ + async pack( + src: FollowRequest["id"] | FollowRequest, + me?: { id: User["id"] } | null | undefined, + ) { + const request = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return { + id: request.id, + follower: await Users.pack(request.followerId, me), + followee: await Users.pack(request.followeeId, me), + }; + }, +}); diff --git a/packages/backend/src/models/repositories/following.ts b/packages/backend/src/models/repositories/following.ts new file mode 100644 index 0000000..b102365 --- /dev/null +++ b/packages/backend/src/models/repositories/following.ts @@ -0,0 +1,90 @@ +import { db } from "@/db/postgre.js"; +import { Users } from "../index.js"; +import { Following } from "@/models/entities/following.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import type { Packed } from "@/misc/schema.js"; +import type { User } from "@/models/entities/user.js"; + +type LocalFollowerFollowing = Following & { + followerHost: null; + followerInbox: null; + followerSharedInbox: null; +}; + +type RemoteFollowerFollowing = Following & { + followerHost: string; + followerInbox: string; + followerSharedInbox: string; +}; + +type LocalFolloweeFollowing = Following & { + followeeHost: null; + followeeInbox: null; + followeeSharedInbox: null; +}; + +type RemoteFolloweeFollowing = Following & { + followeeHost: string; + followeeInbox: string; + followeeSharedInbox: string; +}; + +export const FollowingRepository = db.getRepository(Following).extend({ + isLocalFollower(following: Following): following is LocalFollowerFollowing { + return following.followerHost == null; + }, + + isRemoteFollower(following: Following): following is RemoteFollowerFollowing { + return following.followerHost != null; + }, + + isLocalFollowee(following: Following): following is LocalFolloweeFollowing { + return following.followeeHost == null; + }, + + isRemoteFollowee(following: Following): following is RemoteFolloweeFollowing { + return following.followeeHost != null; + }, + + async pack( + src: Following["id"] | Following, + me?: { id: User["id"] } | null | undefined, + opts?: { + populateFollowee?: boolean; + populateFollower?: boolean; + }, + ): Promise> { + const following = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + if (opts == null) opts = {}; + + return await awaitAll({ + id: following.id, + createdAt: following.createdAt.toISOString(), + followeeId: following.followeeId, + followerId: following.followerId, + followee: opts.populateFollowee + ? Users.pack(following.followee || following.followeeId, me, { + detail: true, + }) + : undefined, + follower: opts.populateFollower + ? Users.pack(following.follower || following.followerId, me, { + detail: true, + }) + : undefined, + }); + }, + + packMany( + followings: any[], + me?: { id: User["id"] } | null | undefined, + opts?: { + populateFollowee?: boolean; + populateFollower?: boolean; + }, + ) { + return Promise.all(followings.map((x) => this.pack(x, me, opts))); + }, +}); diff --git a/packages/backend/src/models/repositories/gallery-like.ts b/packages/backend/src/models/repositories/gallery-like.ts new file mode 100644 index 0000000..c8920d1 --- /dev/null +++ b/packages/backend/src/models/repositories/gallery-like.ts @@ -0,0 +1,19 @@ +import { db } from "@/db/postgre.js"; +import { GalleryLike } from "@/models/entities/gallery-like.js"; +import { GalleryPosts } from "../index.js"; + +export const GalleryLikeRepository = db.getRepository(GalleryLike).extend({ + async pack(src: GalleryLike["id"] | GalleryLike, me?: any) { + const like = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return { + id: like.id, + post: await GalleryPosts.pack(like.post || like.postId, me), + }; + }, + + packMany(likes: any[], me: any) { + return Promise.all(likes.map((x) => this.pack(x, me))); + }, +}); diff --git a/packages/backend/src/models/repositories/gallery-post.ts b/packages/backend/src/models/repositories/gallery-post.ts new file mode 100644 index 0000000..b4206b0 --- /dev/null +++ b/packages/backend/src/models/repositories/gallery-post.ts @@ -0,0 +1,41 @@ +import { db } from "@/db/postgre.js"; +import { GalleryPost } from "@/models/entities/gallery-post.js"; +import type { Packed } from "@/misc/schema.js"; +import { Users, DriveFiles, GalleryLikes } from "../index.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import type { User } from "@/models/entities/user.js"; + +export const GalleryPostRepository = db.getRepository(GalleryPost).extend({ + async pack( + src: GalleryPost["id"] | GalleryPost, + me?: { id: User["id"] } | null | undefined, + ): Promise> { + const meId = me ? me.id : null; + const post = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: post.id, + createdAt: post.createdAt.toISOString(), + updatedAt: post.updatedAt.toISOString(), + userId: post.userId, + user: Users.pack(post.user || post.userId, me), + title: post.title, + description: post.description, + fileIds: post.fileIds, + files: DriveFiles.packMany(post.fileIds), + tags: post.tags.length > 0 ? post.tags : undefined, + isSensitive: post.isSensitive, + likedCount: post.likedCount, + isLiked: meId + ? await GalleryLikes.findOneBy({ postId: post.id, userId: meId }).then( + (x) => x != null, + ) + : undefined, + }); + }, + + packMany(posts: GalleryPost[], me?: { id: User["id"] } | null | undefined) { + return Promise.all(posts.map((x) => this.pack(x, me))); + }, +}); diff --git a/packages/backend/src/models/repositories/hashtag.ts b/packages/backend/src/models/repositories/hashtag.ts new file mode 100644 index 0000000..7bd76c1 --- /dev/null +++ b/packages/backend/src/models/repositories/hashtag.ts @@ -0,0 +1,21 @@ +import { db } from "@/db/postgre.js"; +import { Hashtag } from "@/models/entities/hashtag.js"; +import type { Packed } from "@/misc/schema.js"; + +export const HashtagRepository = db.getRepository(Hashtag).extend({ + async pack(src: Hashtag): Promise> { + return { + tag: src.name, + mentionedUsersCount: src.mentionedUsersCount, + mentionedLocalUsersCount: src.mentionedLocalUsersCount, + mentionedRemoteUsersCount: src.mentionedRemoteUsersCount, + attachedUsersCount: src.attachedUsersCount, + attachedLocalUsersCount: src.attachedLocalUsersCount, + attachedRemoteUsersCount: src.attachedRemoteUsersCount, + }; + }, + + packMany(hashtags: Hashtag[]) { + return Promise.all(hashtags.map((x) => this.pack(x))); + }, +}); diff --git a/packages/backend/src/models/repositories/instance.ts b/packages/backend/src/models/repositories/instance.ts new file mode 100644 index 0000000..f042ed6 --- /dev/null +++ b/packages/backend/src/models/repositories/instance.ts @@ -0,0 +1,46 @@ +import { db } from "@/db/postgre.js"; +import { Instance } from "@/models/entities/instance.js"; +import type { Packed } from "@/misc/schema.js"; +import { + shouldBlockInstance, + shouldSilenceInstance, +} from "@/misc/should-block-instance.js"; + +export const InstanceRepository = db.getRepository(Instance).extend({ + async pack(instance: Instance, privileged: boolean = true): Promise> { + return { + id: instance.id, + caughtAt: instance.caughtAt.toISOString(), + host: instance.host, + usersCount: instance.usersCount, + notesCount: instance.notesCount, + followingCount: instance.followingCount, + followersCount: instance.followersCount, + latestRequestSentAt: instance.latestRequestSentAt + ? instance.latestRequestSentAt.toISOString() + : null, + lastCommunicatedAt: instance.lastCommunicatedAt.toISOString(), + isNotResponding: instance.isNotResponding, + isSuspended: privileged ? instance.isSuspended : false, + isBlocked: privileged ? await shouldBlockInstance(instance.host) : false, + isSilenced: privileged ? await shouldSilenceInstance(instance.host) : false, + softwareName: instance.softwareName, + softwareVersion: instance.softwareVersion, + openRegistrations: instance.openRegistrations, + name: instance.name, + description: instance.description, + maintainerName: instance.maintainerName, + maintainerEmail: instance.maintainerEmail, + iconUrl: instance.iconUrl, + faviconUrl: instance.faviconUrl, + themeColor: instance.themeColor, + infoUpdatedAt: instance.infoUpdatedAt + ? instance.infoUpdatedAt.toISOString() + : null, + }; + }, + + packMany(instances: Instance[], privileged: boolean = true) { + return Promise.all(instances.map((x) => this.pack(x, privileged))); + }, +}); diff --git a/packages/backend/src/models/repositories/messaging-message.ts b/packages/backend/src/models/repositories/messaging-message.ts new file mode 100644 index 0000000..6c0987b --- /dev/null +++ b/packages/backend/src/models/repositories/messaging-message.ts @@ -0,0 +1,48 @@ +import { db } from "@/db/postgre.js"; +import { MessagingMessage } from "@/models/entities/messaging-message.js"; +import { Users, DriveFiles, UserGroups } from "../index.js"; +import type { Packed } from "@/misc/schema.js"; +import type { User } from "@/models/entities/user.js"; + +export const MessagingMessageRepository = db + .getRepository(MessagingMessage) + .extend({ + async pack( + src: MessagingMessage["id"] | MessagingMessage, + me?: { id: User["id"] } | null | undefined, + options?: { + populateRecipient?: boolean; + populateGroup?: boolean; + }, + ): Promise> { + const opts = options || { + populateRecipient: true, + populateGroup: true, + }; + + const message = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return { + id: message.id, + createdAt: message.createdAt.toISOString(), + text: message.text, + userId: message.userId, + user: await Users.pack(message.user || message.userId, me), + recipientId: message.recipientId, + recipient: + message.recipientId && opts.populateRecipient + ? await Users.pack(message.recipient || message.recipientId, me) + : undefined, + groupId: message.groupId, + group: + message.groupId && opts.populateGroup + ? await UserGroups.pack(message.group || message.groupId) + : undefined, + fileId: message.fileId, + file: message.fileId ? await DriveFiles.pack(message.fileId) : null, + isRead: message.isRead, + reads: message.reads, + }; + }, + }); diff --git a/packages/backend/src/models/repositories/moderation-logs.ts b/packages/backend/src/models/repositories/moderation-logs.ts new file mode 100644 index 0000000..3858b95 --- /dev/null +++ b/packages/backend/src/models/repositories/moderation-logs.ts @@ -0,0 +1,26 @@ +import { db } from "@/db/postgre.js"; +import { Users } from "../index.js"; +import { ModerationLog } from "@/models/entities/moderation-log.js"; +import { awaitAll } from "@/prelude/await-all.js"; + +export const ModerationLogRepository = db.getRepository(ModerationLog).extend({ + async pack(src: ModerationLog["id"] | ModerationLog) { + const log = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: log.id, + createdAt: log.createdAt.toISOString(), + type: log.type, + info: log.info, + userId: log.userId, + user: Users.pack(log.user || log.userId, null, { + detail: true, + }), + }); + }, + + packMany(reports: any[]) { + return Promise.all(reports.map((x) => this.pack(x))); + }, +}); diff --git a/packages/backend/src/models/repositories/muting.ts b/packages/backend/src/models/repositories/muting.ts new file mode 100644 index 0000000..4d0201d --- /dev/null +++ b/packages/backend/src/models/repositories/muting.ts @@ -0,0 +1,30 @@ +import { db } from "@/db/postgre.js"; +import { Users } from "../index.js"; +import { Muting } from "@/models/entities/muting.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import type { Packed } from "@/misc/schema.js"; +import type { User } from "@/models/entities/user.js"; + +export const MutingRepository = db.getRepository(Muting).extend({ + async pack( + src: Muting["id"] | Muting, + me?: { id: User["id"] } | null | undefined, + ): Promise> { + const muting = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: muting.id, + createdAt: muting.createdAt.toISOString(), + expiresAt: muting.expiresAt ? muting.expiresAt.toISOString() : null, + muteeId: muting.muteeId, + mutee: Users.pack(muting.muteeId, me, { + detail: true, + }), + }); + }, + + packMany(mutings: any[], me: { id: User["id"] }) { + return Promise.all(mutings.map((x) => this.pack(x, me))); + }, +}); diff --git a/packages/backend/src/models/repositories/note-favorite.ts b/packages/backend/src/models/repositories/note-favorite.ts new file mode 100644 index 0000000..ba43e3c --- /dev/null +++ b/packages/backend/src/models/repositories/note-favorite.ts @@ -0,0 +1,31 @@ +import { db } from "@/db/postgre.js"; +import { NoteFavorite } from "@/models/entities/note-favorite.js"; +import { Notes } from "../index.js"; +import type { User } from "@/models/entities/user.js"; + +export const NoteFavoriteRepository = db.getRepository(NoteFavorite).extend({ + async pack( + src: NoteFavorite["id"] | NoteFavorite, + me?: { id: User["id"] } | null | undefined, + ) { + const favorite = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return { + id: favorite.id, + createdAt: favorite.createdAt.toISOString(), + noteId: favorite.noteId, + // may throw error + note: await Notes.pack(favorite.note || favorite.noteId, me), + }; + }, + + packMany(favorites: any[], me: { id: User["id"] }) { + return Promise.allSettled(favorites.map((x) => this.pack(x, me))).then( + (promises) => + promises.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ), + ); + }, +}); diff --git a/packages/backend/src/models/repositories/note-reaction.ts b/packages/backend/src/models/repositories/note-reaction.ts new file mode 100644 index 0000000..6d1dfbd --- /dev/null +++ b/packages/backend/src/models/repositories/note-reaction.ts @@ -0,0 +1,56 @@ +import { db } from "@/db/postgre.js"; +import { NoteReaction } from "@/models/entities/note-reaction.js"; +import { Notes, Users } from "../index.js"; +import type { Packed } from "@/misc/schema.js"; +import { convertLegacyReaction } from "@/misc/reaction-lib.js"; +import type { User } from "@/models/entities/user.js"; + +export const NoteReactionRepository = db.getRepository(NoteReaction).extend({ + async pack( + src: NoteReaction["id"] | NoteReaction, + me?: { id: User["id"] } | null | undefined, + options?: { + withNote: boolean; + }, + ): Promise> { + const opts = Object.assign( + { + withNote: false, + }, + options, + ); + + const reaction = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return { + id: reaction.id, + createdAt: reaction.createdAt.toISOString(), + user: await Users.pack(reaction.user ?? reaction.userId, me), + type: convertLegacyReaction(reaction.reaction), + ...(opts.withNote + ? { + // may throw error + note: await Notes.pack(reaction.note ?? reaction.noteId, me), + } + : {}), + }; + }, + + async packMany( + src: NoteReaction[], + me?: { id: User["id"] } | null | undefined, + options?: { + withNote: booleam; + }, + ): Promise[]> { + const reactions = await Promise.allSettled( + src.map((reaction) => this.pack(reaction, me, options)), + ); + + // filter out rejected promises, only keep fulfilled values + return reactions.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ); + }, +}); diff --git a/packages/backend/src/models/repositories/note.ts b/packages/backend/src/models/repositories/note.ts new file mode 100644 index 0000000..541373e --- /dev/null +++ b/packages/backend/src/models/repositories/note.ts @@ -0,0 +1,471 @@ +import { In } from "typeorm"; +import * as mfm from "mfm-js"; +import { Note } from "@/models/entities/note.js"; +import type { User } from "@/models/entities/user.js"; +import { + Users, + PollVotes, + DriveFiles, + NoteReactions, + Followings, + Polls, + Channels, + Notes, UserProfiles, Blockings, UserGroups, +} from "../index.js"; +import type { Packed } from "@/misc/schema.js"; +import { nyaize } from "@/misc/nyaize.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import { + convertLegacyReaction, + convertLegacyReactions, + decodeReaction, +} from "@/misc/reaction-lib.js"; +import type { NoteReaction } from "@/models/entities/note-reaction.js"; +import { + aggregateNoteEmojis, + populateEmojis, + prefetchEmojis, +} from "@/misc/populate-emojis.js"; +import { db } from "@/db/postgre.js"; +import { IdentifiableError } from "@/misc/identifiable-error.js"; +import { PackedUserCache } from "@/models/repositories/user.js"; +import { isFiltered } from "@/misc/is-filtered.js"; + +export async function populatePoll(note: Note, meId: User["id"] | null) { + const poll = await Polls.findOneByOrFail({ noteId: note.id }); + const choices = poll.choices.map((c) => ({ + text: c, + votes: poll.votes[poll.choices.indexOf(c)], + isVoted: false, + })); + + if (meId) { + if (poll.multiple) { + const votes = await PollVotes.findBy({ + userId: meId, + noteId: note.id, + }); + + const myChoices = votes.map((v) => v.choice); + for (const myChoice of myChoices) { + choices[myChoice].isVoted = true; + } + } else { + const vote = await PollVotes.findOneBy({ + userId: meId, + noteId: note.id, + }); + + if (vote) { + choices[vote.choice].isVoted = true; + } + } + } + + return { + multiple: poll.multiple, + expiresAt: poll.expiresAt, + choices, + }; +} + +async function populateMyReaction( + note: Note, + meId: User["id"], + _hint_?: { + myReactions: Map; + }, +) { + if (_hint_?.myReactions) { + const reaction = _hint_.myReactions.get(note.id); + if (reaction) { + return convertLegacyReaction(reaction.reaction); + } else if (reaction === null) { + return undefined; + } + // 実装上抜けがあるだけかもしれないので、「ヒントに含まれてなかったら(=undefinedなら)return」のようにはしない + } + + const reaction = await NoteReactions.findOneBy({ + userId: meId, + noteId: note.id, + }); + + if (reaction) { + return convertLegacyReaction(reaction.reaction); + } + + return undefined; +} + +async function populateIsRenoted( + note: Note, + meId: User["id"], + _hint_?: { + myRenotes: Map; + }, +) { + return _hint_?.myRenotes + ? _hint_.myRenotes.get(note.id) ? true : undefined + : Notes.exist({ where: { renoteId: note.id, userId: meId } }) + .then(res => res ? true : undefined); +} + +export const NoteRepository = db.getRepository(Note).extend({ + async isVisibleForMe(note: Note, meId: User["id"] | null): Promise { + if (meId != null && meId !== note.userId) { + const blocked = await Blockings.count({ + where: [ + { + blockeeId: meId, + blockerId: note.userId, + groupId: null, + }, + ...(note.groupId + ? [ + { + blockeeId: meId, + groupId: note.groupId, + }, + ] + : []), + ], + take: 1 + }); + + if (blocked !== 0) { + return false; + } + + const minorBadgeBlocked = await Users.createQueryBuilder("author") + .where("author.id = :authorId", { authorId: note.userId }) + .andWhere("'E' = ANY(author.\"minorBadges\")") + .andWhere( + `EXISTS (` + + `SELECT 1 FROM "user" viewer ` + + `WHERE viewer.id = :meId ` + + `AND viewer."isAdmin" = FALSE ` + + `AND viewer."isModerator" = FALSE ` + + `AND ('K' = ANY(viewer."minorBadges") OR 'T' = ANY(viewer."minorBadges"))` + + `)`, + { meId }, + ) + .getCount(); + + if (minorBadgeBlocked !== 0) { + return false; + } + } + + // This code must always be synchronized with the checks in generateVisibilityQuery. + // visibility が specified かつ自分が指定されていなかったら非表示 + if (note.visibility === "specified") { + if (meId == null) { + return false; + } else if (meId === note.userId) { + return true; + } else { + // 指定されているかどうか + return note.visibleUserIds.some((id: any) => meId === id); + } + } + + // visibility が followers かつ自分が投稿者のフォロワーでなかったら非表示 + if (note.visibility === "followers") { + if (meId == null) { + return false; + } else if (meId === note.userId) { + return true; + } else if (note.reply && meId === note.reply.userId) { + // 自分の投稿に対するリプライ + return true; + } else if (note.mentions?.some((id) => meId === id)) { + // 自分へのメンション + return true; + } else { + // フォロワーかどうか + const [following, user] = await Promise.all([ + Followings.count({ + where: { + followeeId: note.userId, + followerId: meId, + }, + take: 1, + }), + Users.findOneByOrFail({ id: meId }), + ]); + + /* If we know the following, everyhting is fine. + + But if we do not know the following, it might be that both the + author of the note and the author of the like are remote users, + in which case we can never know the following. Instead we have + to assume that the users are following each other. + */ + return following > 0 || (note.userHost != null && user.host != null); + } + } + + return true; + }, + + async pack( + src: Note["id"] | Note, + me?: { id: User["id"] } | null | undefined, + options?: { + detail?: boolean; + allowAdservice?: boolean; + _hint_?: { + myReactions: Map; + myRenotes: Map; + }; + }, + userCache: PackedUserCache = Users.getFreshPackedUserCache(), + ): Promise> { + const opts = Object.assign( + { + detail: true, + }, + options, + ); + + const meId = me ? me.id : null; + const note = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + const host = note.userHost; + + if ( + !opts.allowAdservice && + !(note as any)._prId_ && + note.tags.includes("adservice") && + note.userId !== meId + ) { + throw new IdentifiableError( + "9725d0ce-ba28-4dde-95a7-2cbb2c15de24", + "No such note.", + ); + } + + if (!(await this.isVisibleForMe(note, meId))) { + throw new IdentifiableError( + "9725d0ce-ba28-4dde-95a7-2cbb2c15de24", + "No such note.", + ); + } + + let text = note.text; + + if (note.name && (note.url ?? note.uri)) { + text = `【${note.name}】\n${(note.text || "").trim()}\n\n${ + note.url ?? note.uri + }`; + } + + const channel = note.channelId + ? note.channel + ? note.channel + : await Channels.findOneBy({ id: note.channelId }) + : null; + + const reactionEmojiNames = Object.keys(note.reactions) + .filter((x) => x?.startsWith(":")) + .map((x) => decodeReaction(x).reaction) + .map((x) => x.replace(/:/g, "")); + + const noteEmoji = populateEmojis( + note.emojis.concat(reactionEmojiNames), + host, + ); + const reactionEmoji = populateEmojis(reactionEmojiNames, host); + const packed: Packed<"Note"> = await awaitAll({ + id: note.id, + createdAt: note.createdAt.toISOString(), + userId: note.userId, + user: Users.packCached(note.user ?? note.userId, userCache, me, { + detail: false, + }), + groupId: note.groupId, + group: note.groupId ? UserGroups.pack(note.group ?? note.groupId) : null, + text: text, + cw: note.cw, + visibility: note.visibility, + localOnly: note.localOnly || undefined, + visibleUserIds: + note.visibility === "specified" ? note.visibleUserIds : undefined, + renoteCount: note.renoteCount, + repliesCount: note.repliesCount, + viewCount: note.viewCount, + reactions: convertLegacyReactions(note.reactions), + reactionEmojis: reactionEmoji, + emojis: noteEmoji, + tags: note.tags.length > 0 ? note.tags : undefined, + fileIds: note.fileIds, + files: DriveFiles.packMany(note.fileIds), + replyId: note.replyId, + renoteId: note.renoteId, + channelId: note.channelId || undefined, + channel: channel + ? { + id: channel.id, + name: channel.name, + } + : undefined, + mentions: note.mentions.length > 0 ? note.mentions : undefined, + uri: note.uri || undefined, + url: note.url || undefined, + updatedAt: note.updatedAt?.toISOString() || undefined, + poll: note.hasPoll ? populatePoll(note, meId) : undefined, + quoteAuthorization: note.quoteAuthorization || undefined, + canBite: false, + ...(meId + ? { + myReaction: populateMyReaction(note, meId, options?._hint_), + isRenoted: populateIsRenoted(note, meId, options?._hint_), + isFiltered: isFiltered(note, me), + } + : {}), + + ...(opts.detail + ? { + reply: note.replyId + ? this.tryPack(note.reply || note.replyId, me, { + detail: false, + _hint_: options?._hint_, + }, userCache) + : undefined, + + renote: note.renoteId + ? this.pack(note.renote || note.renoteId, me, { + detail: true, + _hint_: options?._hint_, + }, userCache) + : undefined, + } + : {}), + }); + + if (packed.user.isCat && packed.user.speakAsCat && packed.text) { + const tokens = packed.text ? mfm.parse(packed.text) : []; + function nyaizeNode(node: mfm.MfmNode) { + if (node.type === "quote") return; + if (node.type === "text") node.props.text = nyaize(node.props.text); + + if (node.children) { + for (const child of node.children) { + nyaizeNode(child); + } + } + } + + for (const node of tokens) nyaizeNode(node); + + packed.text = mfm.toString(tokens); + } + + if (me) { + if (packed.user.canBite === "anyone") { + packed.canBite = true; + } else if (packed.user.canBite === "followers") { + const isFollowing = await Followings.exist({ + where: { + followerId: me.id, + followeeId: packed.userId, + }, + take: 1, + }); + packed.canBite = isFollowing; + } else { + packed.canBite = false; + } + } + + if ((note as any)._prId_) { + (packed as any)._prId_ = (note as any)._prId_; + } + + return packed; + }, + + async tryPack( + src: Note["id"] | Note, + me?: { id: User["id"] } | null | undefined, + options?: { + detail?: boolean; + _hint_?: { + myReactions: Map; + myRenotes: Map; + }; + }, + userCache: PackedUserCache = Users.getFreshPackedUserCache(), + ): Promise | undefined> { + try { + return await this.pack(src, me, options, userCache); + } catch { + return undefined; + } + }, + + async packMany( + notes: Note[], + me?: { id: User["id"] } | null | undefined, + options?: { + detail?: boolean; + }, + userCache: PackedUserCache = Users.getFreshPackedUserCache(), + ) { + if (notes.length === 0) return []; + + const meId = me ? me.id : null; + const myReactionsMap = new Map(); + const myRenotesMap = new Map(); + if (meId) { + const renoteIds = notes + .filter((n) => n.renoteId != null) + .map((n) => n.renoteId!); + const targets = [...notes.map((n) => n.id), ...renoteIds]; + const myReactions = await NoteReactions.findBy({ + userId: meId, + noteId: In(targets), + }); + const myRenotes = await Notes.createQueryBuilder('note') + .select('note.renoteId') + .where('note.userId = :meId', { meId }) + .andWhere('note.renoteId IN (:...targets)', { targets }) + .andWhere('note.text IS NULL') + .andWhere('note.hasPoll = FALSE') + .andWhere(`note.fileIds = '{}'`) + .getMany(); + + for (const target of targets) { + myReactionsMap.set( + target, + myReactions.find((reaction) => reaction.noteId === target) || null, + ); + + myRenotesMap.set( + target, + !!myRenotes.find(p => p.renoteId == target), + ); + } + } + + await prefetchEmojis(aggregateNoteEmojis(notes)); + + const promises = await Promise.allSettled( + notes.map((n) => + this.pack(n, me, { + ...options, + _hint_: { + myReactions: myReactionsMap, + myRenotes: myRenotesMap + }, + }, userCache), + ), + ); + + // filter out rejected promises, only keep fulfilled values + return promises.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ); + }, +}); diff --git a/packages/backend/src/models/repositories/notification.ts b/packages/backend/src/models/repositories/notification.ts new file mode 100644 index 0000000..4ed10bb --- /dev/null +++ b/packages/backend/src/models/repositories/notification.ts @@ -0,0 +1,209 @@ +import { In, Repository } from "typeorm"; +import { Notification } from "@/models/entities/notification.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import type { Packed } from "@/misc/schema.js"; +import type { Note } from "@/models/entities/note.js"; +import type { NoteReaction } from "@/models/entities/note-reaction.js"; +import type { User } from "@/models/entities/user.js"; +import { aggregateNoteEmojis, prefetchEmojis } from "@/misc/populate-emojis.js"; +import { notificationTypes } from "@/types.js"; +import { db } from "@/db/postgre.js"; +import { + Users, + Notes, + UserGroupInvitations, + AccessTokens, + NoteReactions, + Bites, +} from "../index.js"; + +export const NotificationRepository = db.getRepository(Notification).extend({ + async pack( + src: Notification["id"] | Notification, + options: { + _hintForEachNotes_?: { + myReactions: Map; + myRenotes: Map; + }; + }, + ): Promise> { + const notification = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + const token = notification.appAccessTokenId + ? await AccessTokens.findOneByOrFail({ + id: notification.appAccessTokenId, + }) + : null; + + return await awaitAll({ + id: notification.id, + createdAt: notification.createdAt.toISOString(), + type: notification.type, + isRead: notification.isRead, + userId: notification.notifierId, + user: notification.notifierId + ? Users.pack(notification.notifier || notification.notifierId) + : null, + ...(notification.type === "mention" + ? { + note: Notes.pack( + notification.note || notification.noteId!, + { id: notification.notifieeId }, + { + detail: true, + _hint_: options._hintForEachNotes_, + }, + ), + } + : {}), + ...(notification.type === "reply" + ? { + note: Notes.pack( + notification.note || notification.noteId!, + { id: notification.notifieeId }, + { + detail: true, + _hint_: options._hintForEachNotes_, + }, + ), + } + : {}), + ...(notification.type === "renote" + ? { + note: Notes.pack( + notification.note || notification.noteId!, + { id: notification.notifieeId }, + { + detail: true, + _hint_: options._hintForEachNotes_, + }, + ), + } + : {}), + ...(notification.type === "quote" + ? { + note: Notes.pack( + notification.note || notification.noteId!, + { id: notification.notifieeId }, + { + detail: true, + _hint_: options._hintForEachNotes_, + }, + ), + } + : {}), + ...(notification.type === "reaction" + ? { + note: Notes.pack( + notification.note || notification.noteId!, + { id: notification.notifieeId }, + { + detail: true, + _hint_: options._hintForEachNotes_, + }, + ), + reaction: notification.reaction, + } + : {}), + ...(notification.type === "pollVote" + ? { + note: Notes.pack( + notification.note || notification.noteId!, + { id: notification.notifieeId }, + { + detail: true, + _hint_: options._hintForEachNotes_, + }, + ), + choice: notification.choice, + } + : {}), + ...(notification.type === "pollEnded" + ? { + note: Notes.pack( + notification.note || notification.noteId!, + { id: notification.notifieeId }, + { + detail: true, + _hint_: options._hintForEachNotes_, + }, + ), + } + : {}), + ...(notification.type === "groupInvited" + ? { + invitation: UserGroupInvitations.pack( + notification.userGroupInvitationId!, + ), + } + : {}), + ...(notification.type === "app" + ? { + body: notification.customBody, + header: notification.customHeader || token?.name, + icon: notification.customIcon || token?.iconUrl, + } + : {}), + ...(notification.type === "bite" + ? { + bite: Bites.pack( + notification.bite || notification.biteId!, + { id: notification.notifieeId }, + ), + } + : {}), + }); + }, + + async packMany(notifications: Notification[], meId: User["id"]) { + if (notifications.length === 0) return []; + + const notes = notifications + .filter((x) => x.note != null) + .map((x) => x.note!); + const noteIds = notes.map((n) => n.id); + const myReactionsMap = new Map(); + const myRenotesMap = new Map(); + const renoteIds = notes + .filter((n) => n.renoteId != null) + .map((n) => n.renoteId!); + const targets = [...noteIds, ...renoteIds]; + const myReactions = await NoteReactions.findBy({ + userId: meId, + noteId: In(targets), + }); + const myRenotes = targets.length > 0 + ? await Notes.createQueryBuilder('note') + .select('note.renoteId') + .where('note.userId = :meId', { meId }) + .andWhere('note.renoteId IN (:...targets)', { targets }) + .getMany() + : []; + + for (const target of targets) { + myReactionsMap.set( + target, + myReactions.find((reaction) => reaction.noteId === target) || null, + ); + + myRenotesMap.set( + target, + !!myRenotes.find(p => p.renoteId == target), + ); + } + + await prefetchEmojis(aggregateNoteEmojis(notes)); + + const results = await Promise.all( + notifications.map((x) => + this.pack(x, { + _hintForEachNotes_: { + myReactions: myReactionsMap, + myRenotes: myRenotesMap + }, + }).catch((e) => null), + ), + ); + return results.filter((x) => x != null); + }, +}); diff --git a/packages/backend/src/models/repositories/page-like.ts b/packages/backend/src/models/repositories/page-like.ts new file mode 100644 index 0000000..f78ef81 --- /dev/null +++ b/packages/backend/src/models/repositories/page-like.ts @@ -0,0 +1,23 @@ +import { db } from "@/db/postgre.js"; +import { PageLike } from "@/models/entities/page-like.js"; +import type { User } from "@/models/entities/user.js"; +import { Pages } from "../index.js"; + +export const PageLikeRepository = db.getRepository(PageLike).extend({ + async pack( + src: PageLike["id"] | PageLike, + me?: { id: User["id"] } | null | undefined, + ) { + const like = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return { + id: like.id, + page: await Pages.pack(like.page || like.pageId, me), + }; + }, + + packMany(likes: PageLike[], me: { id: User["id"] }) { + return Promise.all(likes.map((x) => this.pack(x, me))); + }, +}); diff --git a/packages/backend/src/models/repositories/page.ts b/packages/backend/src/models/repositories/page.ts new file mode 100644 index 0000000..d9241c3 --- /dev/null +++ b/packages/backend/src/models/repositories/page.ts @@ -0,0 +1,99 @@ +import { db } from "@/db/postgre.js"; +import { Page } from "@/models/entities/page.js"; +import type { Packed } from "@/misc/schema.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import type { DriveFile } from "@/models/entities/drive-file.js"; +import type { User } from "@/models/entities/user.js"; +import { Users, DriveFiles, PageLikes } from "../index.js"; + +export const PageRepository = db.getRepository(Page).extend({ + async pack( + src: Page["id"] | Page, + me?: { id: User["id"] } | null | undefined, + ): Promise> { + const meId = me ? me.id : null; + const page = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + const attachedFiles: Promise[] = []; + const collectFile = (xs: any[]) => { + for (const x of xs) { + if (x.type === "image") { + attachedFiles.push( + DriveFiles.findOneBy({ + id: x.fileId, + userId: page.userId, + }), + ); + } + if (x.children) { + collectFile(x.children); + } + } + }; + collectFile(page.content); + + // 後方互換性のため + let migrated = false; + const migrate = (xs: any[]) => { + for (const x of xs) { + if (x.type === "input") { + if (x.inputType === "text") { + x.type = "textInput"; + } + if (x.inputType === "number") { + x.type = "numberInput"; + if (x.default) x.default = parseInt(x.default, 10); + } + migrated = true; + } + if (x.children) { + migrate(x.children); + } + } + }; + migrate(page.content); + if (migrated) { + this.update(page.id, { + content: page.content, + }); + } + + return await awaitAll({ + id: page.id, + createdAt: page.createdAt.toISOString(), + updatedAt: page.updatedAt.toISOString(), + userId: page.userId, + user: Users.pack(page.user || page.userId, me), // { detail: true } すると無限ループするので注意 + content: page.content, + variables: page.variables, + title: page.title, + isPublic: page.isPublic, + name: page.name, + summary: page.summary, + hideTitleWhenPinned: page.hideTitleWhenPinned, + alignCenter: page.alignCenter, + font: page.font, + script: page.script, + eyeCatchingImageId: page.eyeCatchingImageId, + eyeCatchingImage: page.eyeCatchingImageId + ? await DriveFiles.pack(page.eyeCatchingImageId) + : null, + attachedFiles: DriveFiles.packMany( + ( + await Promise.all(attachedFiles) + ).filter((x): x is DriveFile => x != null), + ), + likedCount: page.likedCount, + isLiked: meId + ? await PageLikes.findOneBy({ pageId: page.id, userId: meId }).then( + (x) => x != null, + ) + : undefined, + }); + }, + + packMany(pages: Page[], me?: { id: User["id"] } | null | undefined) { + return Promise.all(pages.map((x) => this.pack(x, me))); + }, +}); diff --git a/packages/backend/src/models/repositories/plan.ts b/packages/backend/src/models/repositories/plan.ts new file mode 100644 index 0000000..7eccbc3 --- /dev/null +++ b/packages/backend/src/models/repositories/plan.ts @@ -0,0 +1,24 @@ +import { db } from "@/db/postgre.js"; +import { Plan } from "@/models/entities/plan.js"; + +export const PlanRepository = db.getRepository(Plan).extend({ + pack(src: Plan["id"] | Plan) { + return Promise.resolve().then(async () => { + const plan = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return { + id: plan.id, + createdAt: plan.createdAt.toISOString(), + updatedAt: plan.updatedAt?.toISOString() ?? null, + name: plan.name, + icon: plan.icon, + description: plan.description, + }; + }); + }, + + packMany(plans: Plan[]) { + return Promise.all(plans.map((x) => this.pack(x))); + }, +}); diff --git a/packages/backend/src/models/repositories/relay.ts b/packages/backend/src/models/repositories/relay.ts new file mode 100644 index 0000000..6338614 --- /dev/null +++ b/packages/backend/src/models/repositories/relay.ts @@ -0,0 +1,4 @@ +import { db } from "@/db/postgre.js"; +import { Relay } from "@/models/entities/relay.js"; + +export const RelayRepository = db.getRepository(Relay).extend({}); diff --git a/packages/backend/src/models/repositories/renote-muting.ts b/packages/backend/src/models/repositories/renote-muting.ts new file mode 100644 index 0000000..18fd343 --- /dev/null +++ b/packages/backend/src/models/repositories/renote-muting.ts @@ -0,0 +1,29 @@ +import { db } from "@/db/postgre.js"; +import { Packed } from "@/misc/schema.js"; +import { RenoteMuting } from "@/models/entities/renote-muting.js"; +import { User } from "@/models/entities/user.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import { Users } from "../index.js"; + +export const RenoteMutingRepository = db.getRepository(RenoteMuting).extend({ + async pack( + src: RenoteMuting["id"] | RenoteMuting, + me?: { id: User["id"] } | null | undefined, + ): Promise> { + const muting = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: muting.id, + createdAt: muting.createdAt.toISOString(), + muteeId: muting.muteeId, + mutee: Users.pack(muting.muteeId, me, { + detail: true, + }), + }); + }, + + packMany(mutings: any[], me: { id: User["id"] }) { + return Promise.all(mutings.map((x) => this.pack(x, me))); + }, +}); diff --git a/packages/backend/src/models/repositories/reversi-game.ts b/packages/backend/src/models/repositories/reversi-game.ts new file mode 100644 index 0000000..42738da --- /dev/null +++ b/packages/backend/src/models/repositories/reversi-game.ts @@ -0,0 +1,82 @@ +import { db } from "@/db/postgre.js"; +import { ReversiGame } from "@/models/entities/reversi-game.js"; +import type { Packed } from "@/misc/schema.js"; +import { Users } from "../index.js"; +import { genId } from "@/misc/gen-id.js"; + +function createdAtFromId(id: string): string { + const time = parseInt(id.slice(0, 8), 36); + if (Number.isNaN(time)) return new Date().toISOString(); + return new Date(time + 946684800000).toISOString(); +} + +function assertBw(bw: string): "random" | "1" | "2" { + return bw === "1" || bw === "2" ? bw : "random"; +} + +export const ReversiGameRepository = db.getRepository(ReversiGame).extend({ + async packDetail(src: ReversiGame["id"] | ReversiGame): Promise { + const game = + typeof src === "object" + ? src + : await this.findOneOrFail({ + where: { id: src }, + relations: { user1: true, user2: true }, + }); + + const user1 = await Users.pack(game.user1 ?? game.user1Id, null, { + detail: false, + }); + const user2 = await Users.pack(game.user2 ?? game.user2Id, null, { + detail: false, + }); + + return { + id: game.id, + createdAt: createdAtFromId(game.id), + startedAt: game.startedAt?.toISOString() ?? null, + endedAt: game.endedAt?.toISOString() ?? null, + isStarted: game.isStarted, + isEnded: game.isEnded, + form1: game.form1, + form2: game.form2, + user1Ready: game.user1Ready, + user2Ready: game.user2Ready, + user1Id: game.user1Id, + user2Id: game.user2Id, + user1, + user2, + winnerId: game.winnerId, + winner: game.winnerId ? [user1, user2].find((u) => u.id === game.winnerId) ?? null : null, + surrenderedUserId: game.surrenderedUserId, + timeoutUserId: game.timeoutUserId, + black: game.black, + bw: assertBw(game.bw), + isLlotheo: game.isLlotheo, + canPutEverywhere: game.canPutEverywhere, + loopedBoard: game.loopedBoard, + timeLimitForEachTurn: game.timeLimitForEachTurn, + noIrregularRules: game.noIrregularRules, + logs: game.logs, + map: game.map, + }; + }, + + async packLite(src: ReversiGame["id"] | ReversiGame): Promise { + const detail = await this.packDetail(src); + const { + logs, + map, + form1, + form2, + user1Ready, + user2Ready, + ...lite + } = detail; + return lite; + }, + + genId, +}); + +export type PackedReversiGame = Packed<"UserLite"> & Record; diff --git a/packages/backend/src/models/repositories/shogi-game.ts b/packages/backend/src/models/repositories/shogi-game.ts new file mode 100644 index 0000000..5a57e13 --- /dev/null +++ b/packages/backend/src/models/repositories/shogi-game.ts @@ -0,0 +1,61 @@ +import { db } from "@/db/postgre.js"; +import { genId } from "@/misc/gen-id.js"; +import type { Packed } from "@/misc/schema.js"; +import { ShogiGame } from "@/models/entities/shogi-game.js"; +import { Users } from "../index.js"; + +function createdAtFromId(id: string): string { + const time = parseInt(id.slice(0, 8), 36); + if (Number.isNaN(time)) return new Date().toISOString(); + return new Date(time + 946684800000).toISOString(); +} + +export const ShogiGameRepository = db.getRepository(ShogiGame).extend({ + async packDetail(src: ShogiGame["id"] | ShogiGame): Promise { + const game = + typeof src === "object" + ? src + : await this.findOneOrFail({ + where: { id: src }, + relations: { user1: true, user2: true }, + }); + + const user1 = await Users.pack(game.user1 ?? game.user1Id, null, { + detail: false, + }); + const user2 = await Users.pack(game.user2 ?? game.user2Id, null, { + detail: false, + }); + + return { + id: game.id, + createdAt: createdAtFromId(game.id), + startedAt: game.startedAt?.toISOString() ?? null, + endedAt: game.endedAt?.toISOString() ?? null, + isStarted: game.isStarted, + isEnded: game.isEnded, + user1Ready: game.user1Ready, + user2Ready: game.user2Ready, + user1Id: game.user1Id, + user2Id: game.user2Id, + user1, + user2, + winnerId: game.winnerId, + winner: game.winnerId ? [user1, user2].find((u) => u.id === game.winnerId) ?? null : null, + surrenderedUserId: game.surrenderedUserId, + sente: game.sente, + sfen: game.sfen, + logs: game.logs, + }; + }, + + async packLite(src: ShogiGame["id"] | ShogiGame): Promise { + const detail = await this.packDetail(src); + const { logs, ...lite } = detail; + return lite; + }, + + genId, +}); + +export type PackedShogiGame = Packed<"UserLite"> & Record; diff --git a/packages/backend/src/models/repositories/signin.ts b/packages/backend/src/models/repositories/signin.ts new file mode 100644 index 0000000..06cf2c2 --- /dev/null +++ b/packages/backend/src/models/repositories/signin.ts @@ -0,0 +1,8 @@ +import { db } from "@/db/postgre.js"; +import { Signin } from "@/models/entities/signin.js"; + +export const SigninRepository = db.getRepository(Signin).extend({ + async pack(src: Signin) { + return src; + }, +}); diff --git a/packages/backend/src/models/repositories/user-group-invitation.ts b/packages/backend/src/models/repositories/user-group-invitation.ts new file mode 100644 index 0000000..920fb9b --- /dev/null +++ b/packages/backend/src/models/repositories/user-group-invitation.ts @@ -0,0 +1,23 @@ +import { db } from "@/db/postgre.js"; +import { UserGroupInvitation } from "@/models/entities/user-group-invitation.js"; +import { UserGroups } from "../index.js"; + +export const UserGroupInvitationRepository = db + .getRepository(UserGroupInvitation) + .extend({ + async pack(src: UserGroupInvitation["id"] | UserGroupInvitation) { + const invitation = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return { + id: invitation.id, + group: await UserGroups.pack( + invitation.userGroup || invitation.userGroupId, + ), + }; + }, + + packMany(invitations: any[]) { + return Promise.all(invitations.map((x) => this.pack(x))); + }, + }); diff --git a/packages/backend/src/models/repositories/user-group.ts b/packages/backend/src/models/repositories/user-group.ts new file mode 100644 index 0000000..5501b71 --- /dev/null +++ b/packages/backend/src/models/repositories/user-group.ts @@ -0,0 +1,28 @@ +import { db } from "@/db/postgre.js"; +import { UserGroup } from "@/models/entities/user-group.js"; +import { DriveFiles, UserGroupJoinings } from "../index.js"; +import type { Packed } from "@/misc/schema.js"; + +export const UserGroupRepository = db.getRepository(UserGroup).extend({ + async pack(src: UserGroup["id"] | UserGroup): Promise> { + const userGroup = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + const users = await UserGroupJoinings.findBy({ + userGroupId: userGroup.id, + }); + + return { + id: userGroup.id, + createdAt: userGroup.createdAt.toISOString(), + name: userGroup.name, + username: userGroup.username, + ownerId: userGroup.userId, + allowCalls: userGroup.allowCalls, + iconUrl: userGroup.iconFileId + ? (await DriveFiles.pack(userGroup.iconFileId)).url + : null, + userIds: users.map((x) => x.userId), + }; + }, +}); diff --git a/packages/backend/src/models/repositories/user-list.ts b/packages/backend/src/models/repositories/user-list.ts new file mode 100644 index 0000000..8454fa3 --- /dev/null +++ b/packages/backend/src/models/repositories/user-list.ts @@ -0,0 +1,23 @@ +import { db } from "@/db/postgre.js"; +import { UserList } from "@/models/entities/user-list.js"; +import { UserListJoinings } from "../index.js"; +import type { Packed } from "@/misc/schema.js"; + +export const UserListRepository = db.getRepository(UserList).extend({ + async pack(src: UserList["id"] | UserList): Promise> { + const userList = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + const users = await UserListJoinings.findBy({ + userListId: userList.id, + }); + + return { + id: userList.id, + createdAt: userList.createdAt.toISOString(), + name: userList.name, + hideFromHomeTl: userList.hideFromHomeTl, + userIds: users.map((x) => x.userId), + }; + }, +}); diff --git a/packages/backend/src/models/repositories/user-profile.ts b/packages/backend/src/models/repositories/user-profile.ts new file mode 100644 index 0000000..36d2c8c --- /dev/null +++ b/packages/backend/src/models/repositories/user-profile.ts @@ -0,0 +1,63 @@ +import { db } from "@/db/postgre.js"; +import { UserProfile } from "@/models/entities/user-profile.js"; +import mfm from "mfm-js"; +import { extractMentions } from "@/misc/extract-mentions.js"; +import { resolveMentionToUserAndProfile } from "@/remote/resolve-user.js"; +import { IMentionedRemoteUsers } from "@/models/entities/note.js"; +import { unique } from "@/prelude/array.js"; +import config from "@/config/index.js"; +import { Mutex, Semaphore } from "async-mutex"; + +const queue = new Semaphore(5); + +export const UserProfileRepository = db.getRepository(UserProfile).extend({ + // We must never await this without promiseEarlyReturn, otherwise giant webring-style profile mention trees will cause the queue to stop working + async updateMentions(id: UserProfile["userId"], limiter: RecursionLimiter = new RecursionLimiter()){ + const profile = await this.findOneBy({ userId: id }); + if (!profile) return; + const tokens: mfm.MfmNode[] = []; + + if (profile.description) + tokens.push(...mfm.parse(profile.description)); + if (profile.fields.length > 0) + tokens.push(...profile.fields.map(p => mfm.parse(p.value).concat(mfm.parse(p.name))).flat()); + + return queue.runExclusive(async () => { + const partial = { + mentions: await populateMentions(tokens, profile.userHost, limiter) + }; + return UserProfileRepository.update(profile.userId, partial); + }); + }, +}); + +async function populateMentions(tokens: mfm.MfmNode[], objectHost: string | null, limiter: RecursionLimiter): Promise { + const mentions = extractMentions(tokens); + const resolved = await Promise.all(mentions.map(m => resolveMentionToUserAndProfile(m.username, m.host, objectHost, limiter))); + const remote = resolved.filter(p => p && p.data.host !== config.domain && (p.data.host !== null || objectHost !== null)) + .map(p => p!); + const res = remote.map(m => { + return { + uri: m.user.uri!, + url: m.profile?.url ?? undefined, + username: m.data.username, + host: m.data.host! + }; + }); + + return unique(res); +} + +export class RecursionLimiter { + private counter; + private mutex = new Mutex(); + constructor(count: number = 10) { + this.counter = count; + } + + public shouldContinue(): Promise { + return this.mutex.runExclusive(() => { + return this.counter-- > 0; + }); + } +} \ No newline at end of file diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts new file mode 100644 index 0000000..4636502 --- /dev/null +++ b/packages/backend/src/models/repositories/user.ts @@ -0,0 +1,726 @@ +import { In, Not } from "typeorm"; +import Ajv from "ajv"; +import type { ILocalUser, IRemoteUser } from "@/models/entities/user.js"; +import { User } from "@/models/entities/user.js"; +import config from "@/config/index.js"; +import type { Packed } from "@/misc/schema.js"; +import type { Promiseable } from "@/prelude/await-all.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import { populateEmojis } from "@/misc/populate-emojis.js"; +import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from "@/const.js"; +import { Cache } from "@/misc/cache.js"; +import { db } from "@/db/postgre.js"; +import { isActor, getApId } from "@/remote/activitypub/type.js"; +import DbResolver from "@/remote/activitypub/db-resolver.js"; +import Resolver from "@/remote/activitypub/resolver.js"; +import { createPerson } from "@/remote/activitypub/models/person.js"; +import { + AnnouncementReads, + Announcements, + Blockings, + ChannelFollowings, + DriveFiles, + Followings, + FollowRequests, + Instances, + MessagingMessages, + Mutings, + RenoteMutings, + Notes, + NoteUnreads, + Notifications, + Pages, + Plans, + UserGroupJoinings, + UserNotePinings, + UserPlans, + UserProfiles, + UserSecurityKeys, +} from "../index.js"; +import type { Instance } from "../entities/instance.js"; +import AsyncLock from "async-lock"; + +const userInstanceCache = new Cache( + "userInstance", + 60 * 60 * 3, +); + +function isMissingRelationError(err: unknown): boolean { + const error = err as { code?: string; driverError?: { code?: string } }; + return error.code === "42P01" || error.driverError?.code === "42P01"; +} + +type IsUserDetailed = Detailed extends true + ? Packed<"UserDetailed"> + : Packed<"UserLite">; +type IsMeAndIsUserDetailed< + ExpectsMe extends boolean | null, + Detailed extends boolean, +> = Detailed extends true + ? ExpectsMe extends true + ? Packed<"MeDetailed"> + : ExpectsMe extends false + ? Packed<"UserDetailedNotMe"> + : Packed<"UserDetailed"> + : Packed<"UserLite">; + +const ajv = new Ajv(); + +export type PackedUserCache = { + locks: AsyncLock; + results: IsMeAndIsUserDetailed[]; +} + +const localUsernameSchema = { + type: "string", + pattern: /^\w{1,20}$/.toString().slice(1, -1), +} as const; +const passwordSchema = { type: "string", minLength: 1 } as const; +const nameSchema = { type: "string", minLength: 1, maxLength: 50 } as const; +const descriptionSchema = { + type: "string", + minLength: 1, + maxLength: 2048, +} as const; +const locationSchema = { type: "string", minLength: 1, maxLength: 50 } as const; +const birthdaySchema = { + type: "string", + pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1), +} as const; + +function isLocalUser(user: User): user is ILocalUser; +function isLocalUser( + user: T, +): user is T & { host: null }; +/** + * Returns true if the user is local. + * + * @param user The user to check. + * @returns True if the user is local. + */ +function isLocalUser(user: User | { host: User["host"] }): boolean { + return user.host == null; +} + +function isRemoteUser(user: User): user is IRemoteUser; +function isRemoteUser( + user: T, +): user is T & { host: string }; +/** + * Returns true if the user is remote. + * + * @param user The user to check. + * @returns True if the user is remote. + */ +function isRemoteUser(user: User | { host: User["host"] }): boolean { + return !isLocalUser(user); +} + +export const UserRepository = db.getRepository(User).extend({ + localUsernameSchema, + passwordSchema, + nameSchema, + descriptionSchema, + locationSchema, + birthdaySchema, + + //#region Validators + validateLocalUsername: ajv.compile(localUsernameSchema), + validatePassword: ajv.compile(passwordSchema), + validateName: ajv.compile(nameSchema), + validateDescription: ajv.compile(descriptionSchema), + validateLocation: ajv.compile(locationSchema), + validateBirthday: ajv.compile(birthdaySchema), + //#endregion + + async getRelation(me: User["id"], target: User["id"]) { + return awaitAll({ + id: target, + isFollowing: Followings.count({ + where: { + followerId: me, + followeeId: target, + }, + take: 1, + }).then((n) => n > 0), + isFollowed: Followings.count({ + where: { + followerId: target, + followeeId: me, + }, + take: 1, + }).then((n) => n > 0), + hasPendingFollowRequestFromYou: FollowRequests.count({ + where: { + followerId: me, + followeeId: target, + }, + take: 1, + }).then((n) => n > 0), + hasPendingFollowRequestToYou: FollowRequests.count({ + where: { + followerId: target, + followeeId: me, + }, + take: 1, + }).then((n) => n > 0), + isBlocking: Blockings.count({ + where: { + blockerId: me, + blockeeId: target, + }, + take: 1, + }).then((n) => n > 0), + isBlocked: Blockings.count({ + where: { + blockerId: target, + blockeeId: me, + }, + take: 1, + }).then((n) => n > 0), + isMuted: Mutings.count({ + where: { + muterId: me, + muteeId: target, + }, + take: 1, + }).then((n) => n > 0), + isRenoteMuted: RenoteMutings.count({ + where: { + muterId: me, + muteeId: target, + }, + take: 1, + }).then((n) => n > 0), + }); + }, + + async getHasUnreadMessagingMessage(userId: User["id"]): Promise { + const mute = await Mutings.findBy({ + muterId: userId, + }); + + const joinings = await UserGroupJoinings.findBy({ userId: userId }); + + const groupQs = Promise.all( + joinings.map((j) => + MessagingMessages.createQueryBuilder("message") + .where("message.groupId = :groupId", { groupId: j.userGroupId }) + .andWhere("message.userId != :userId", { userId: userId }) + .andWhere("NOT (:userId = ANY(message.reads))", { userId: userId }) + .andWhere("message.createdAt > :joinedAt", { joinedAt: j.createdAt }) // 自分が加入する前の会話については、未読扱いしない + .getOne() + .then((x) => x != null), + ), + ); + + const [withUser, withGroups] = await Promise.all([ + MessagingMessages.count({ + where: { + recipientId: userId, + isRead: false, + ...(mute.length > 0 + ? { userId: Not(In(mute.map((x) => x.muteeId))) } + : {}), + }, + take: 1, + }).then((count) => count > 0), + groupQs, + ]); + + return withUser || withGroups.some((x) => x); + }, + + async getHasUnreadAnnouncement(userId: User["id"]): Promise { + const reads = await AnnouncementReads.findBy({ + userId: userId, + }); + + const count = await Announcements.countBy( + reads.length > 0 + ? { + id: Not(In(reads.map((read) => read.announcementId))), + } + : {}, + ); + + return count > 0; + }, + + async userFromURI(uri: string): Promise { + try { + const dbResolver = new DbResolver(); + let local = await dbResolver.getUserFromApId(uri); + if (local) { + return local; + } + + // fetching Object once from remote + const resolver = new Resolver(); + const object = (await resolver.resolve(uri)) as any; + + // /@user If a URI other than the id is specified, + // the URI is determined here + if (uri !== object.id) { + local = await dbResolver.getUserFromApId(object.id); + if (local != null) return local; + } + + return isActor(object) ? await createPerson(getApId(object)) : null; + } + catch { + return null; + } + }, + + async getHasUnreadAntenna(userId: User["id"]): Promise { + // try { + // const myAntennas = (await getAntennas()).filter( + // (a) => a.userId === userId, + // ); + + // const unread = + // myAntennas.length > 0 + // ? await AntennaNotes.findOneBy({ + // antennaId: In(myAntennas.map((x) => x.id)), + // read: false, + // }) + // : null; + + // return unread != null; + // } catch (e) { + // return false; + // } + return false; // TODO + }, + + async getHasUnreadChannel(userId: User["id"]): Promise { + const channels = await ChannelFollowings.findBy({ followerId: userId }); + + const unread = + channels.length > 0 + ? await NoteUnreads.findOneBy({ + userId: userId, + noteChannelId: In(channels.map((x) => x.followeeId)), + }) + : null; + + return unread != null; + }, + + async getHasUnreadNotification(userId: User["id"]): Promise { + const mute = await Mutings.findBy({ + muterId: userId, + }); + const mutedUserIds = mute.map((m) => m.muteeId); + + const count = await Notifications.count({ + where: { + notifieeId: userId, + ...(mutedUserIds.length > 0 + ? { notifierId: Not(In(mutedUserIds)) } + : {}), + isRead: false, + }, + take: 1, + }); + + return count > 0; + }, + + async getHasPendingReceivedFollowRequest( + userId: User["id"], + ): Promise { + const count = await FollowRequests.countBy({ + followeeId: userId, + }); + + return count > 0; + }, + + getOnlineStatus(user: User): "unknown" | "online" | "active" | "offline" { + if (user.hideOnlineStatus) return "unknown"; + if (user.lastActiveDate == null) return "unknown"; + const elapsed = Date.now() - user.lastActiveDate.getTime(); + return elapsed < USER_ONLINE_THRESHOLD + ? "online" + : elapsed < USER_ACTIVE_THRESHOLD + ? "active" + : "offline"; + }, + + async getAvatarUrl(user: User): Promise { + if (user.avatar) { + return ( + DriveFiles.getPublicUrl(user.avatar, true) || + this.getIdenticonUrl(user.id) + ); + } else if (user.avatarId) { + if (user.avatarUrl) return DriveFiles.getFinalUrl(user.avatarUrl); + const avatar = await DriveFiles.findOneByOrFail({ id: user.avatarId }); + return ( + DriveFiles.getPublicUrl(avatar, true) || this.getIdenticonUrl(user.id) + ); + } else { + return this.getIdenticonUrl(user.id); + } + }, + + getAvatarUrlSync(user: User): string { + if (user.avatarId && user.avatarUrl) { + return DriveFiles.getFinalUrl(user.avatarUrl); + } else if (user.avatar) { + return ( + DriveFiles.getPublicUrl(user.avatar, true) || + this.getIdenticonUrl(user.id) + ); + } else { + return this.getIdenticonUrl(user.id); + } + }, + + getIdenticonUrl(userId: User["id"]): string { + return `${config.url}/identicon/${userId}`; + }, + + getFreshPackedUserCache(): PackedUserCache { + return { + locks: new AsyncLock(), + results: [], + }; + }, + + async getRandomFollower(targetId: string): Promise { + return await this.createQueryBuilder("u") + .select(`u.id`) + .leftJoinAndSelect("following", "f", `f."followerId" = u.id`) + .where(`f."followeeId" = :id`, { id: targetId }) + .getOne(); + }, + + async packCached< + ExpectsMe extends boolean | null = null, + D extends boolean = false, + >( + src: User["id"] | User, + cache: PackedUserCache, + me?: { id: User["id"] } | null | undefined, + options?: { + detail?: D; + includeSecrets?: boolean; + isPrivateMode?: boolean; + }, + ): Promise> { + const id = typeof src === "object" ? src.id : src; + return cache.locks.acquire(id, async () => { + const result = cache.results.find(p => p.id === id); + if (result) return result as IsMeAndIsUserDetailed + return this.pack(src, me, options).then(result => { + cache.results.push(result); + return result; + }); + }); + }, + + async pack< + ExpectsMe extends boolean | null = null, + D extends boolean = false, + >( + src: User["id"] | User, + me?: { id: User["id"] } | null | undefined, + options?: { + detail?: D; + includeSecrets?: boolean; + isPrivateMode?: boolean; + }, + ): Promise> { + const opts = Object.assign( + { + detail: false, + includeSecrets: false, + isPrivateMode: false + }, + options, + ); + + let user: User; + + if (typeof src === "object") { + user = src; + } else { + user = await this.findOneOrFail({ + where: { id: src }, + }); + } + + const meId = me ? me.id : null; + const isMe = meId === user.id; + + const relation = + meId && !isMe && opts.detail + ? await this.getRelation(meId, user.id) + : null; + const pins = opts.detail + ? await UserNotePinings.createQueryBuilder("pin") + .where("pin.userId = :userId", { userId: user.id }) + .innerJoinAndSelect("pin.note", "note") + .orderBy("pin.id", "DESC") + .getMany() + : []; + const profile = opts.detail + ? await UserProfiles.findOneByOrFail({ userId: user.id }) + : null; + + const followingCount = + profile == null + ? null + : profile.ffVisibility === "public" || isMe + ? user.followingCount + : profile.ffVisibility === "followers" && + relation && + relation.isFollowing + ? user.followingCount + : null; + + const followersCount = + profile == null + ? null + : profile.ffVisibility === "public" || isMe + ? user.followersCount + : profile.ffVisibility === "followers" && + relation && + relation.isFollowing + ? user.followersCount + : null; + + const falsy = opts.detail ? false : undefined; + + if (opts.isPrivateMode) { + const packed = { + id: user.id, + username: user.username, + host: user.host, + + ...(opts.detail + ? { + twoFactorEnabled: profile!.twoFactorEnabled, + usePasswordLessLogin: profile!.usePasswordLessLogin, + securityKeys: profile!.twoFactorEnabled + ? UserSecurityKeys.countBy({ + userId: user.id, + }).then((result) => result >= 1) + : false, + } + : {}), + } as Promiseable> as Promiseable< + IsMeAndIsUserDetailed + >; + + return await awaitAll(packed); + } + + const packed = { + id: user.id, + name: user.name, + username: user.username, + host: user.host, + avatarUrl: this.getAvatarUrlSync(user), + avatarBlurhash: user.avatarId ? (user.avatarBlurhash ?? user.avatar?.blurhash ?? null) : null, + avatarColor: null, // 後方互換性のため + isAdmin: user.isAdmin || falsy, + isModerator: user.isModerator || falsy, + isVerified: user.isVerified || falsy, + minorBadges: user.minorBadges ?? [], + plans: UserPlans.find({ + where: { userId: user.id }, + relations: ["plan"], + order: { createdAt: "ASC" }, + }) + .then((joins) => + Plans.packMany( + joins + .map((join) => join.plan) + .filter((plan): plan is NonNullable => plan != null), + ), + ) + .catch((err) => { + if (isMissingRelationError(err)) return []; + throw err; + }), + isBot: user.isBot || falsy, + isLocked: user.isLocked, + isCat: user.isCat || falsy, + speakAsCat: user.speakAsCat || falsy, + instance: user.host + ? userInstanceCache + .fetch( + user.host, + () => Instances.findOneBy({ host: user.host! }), + (v) => v != null, + ) + .then((instance) => + instance + ? { + name: instance.name, + softwareName: instance.softwareName, + softwareVersion: instance.softwareVersion, + iconUrl: instance.iconUrl, + faviconUrl: instance.faviconUrl, + themeColor: instance.themeColor, + } + : undefined, + ) + : undefined, + emojis: populateEmojis(user.emojis, user.host), + onlineStatus: this.getOnlineStatus(user), + driveCapacityOverrideMb: user.driveCapacityOverrideMb, + canBite: user.canBite, + + ...(opts.detail + ? { + url: profile!.url, + uri: user.uri, + movedToUri: user.movedToUri + ? await this.userFromURI(user.movedToUri) + : null, + alsoKnownAs: user.alsoKnownAs, + createdAt: user.createdAt.toISOString(), + updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, + lastFetchedAt: user.lastFetchedAt + ? user.lastFetchedAt.toISOString() + : null, + bannerUrl: user.bannerId ? (DriveFiles.getFinalUrlMaybe(user.bannerUrl) ?? (user.banner + ? DriveFiles.getPublicUrl(user.banner, false) + : null)) : null, + bannerBlurhash: user.bannerId ? (user.bannerBlurhash ?? user.banner?.blurhash ?? null) : null, + bannerColor: null, // 後方互換性のため + isSilenced: user.isSilenced || falsy, + isSuspended: user.isSuspended || falsy, + description: profile!.description, + location: profile!.location, + birthday: profile!.birthday, + lang: profile!.lang, + fields: profile!.fields, + followersCount: followersCount || 0, + followingCount: followingCount || 0, + notesCount: user.notesCount, + pinnedNoteIds: pins.map((pin) => pin.noteId), + pinnedNotes: Notes.packMany( + pins.map((pin) => pin.note!), + me, + { + detail: true, + }, + ), + pinnedPageId: profile!.pinnedPageId, + pinnedPage: profile!.pinnedPageId + ? Pages.pack(profile!.pinnedPageId, me) + : null, + publicReactions: profile!.publicReactions, + allowCalls: profile!.allowCalls, + symbolFileId: profile!.symbolFileId, + ffVisibility: profile!.ffVisibility, + twoFactorEnabled: profile!.twoFactorEnabled, + usePasswordLessLogin: profile!.usePasswordLessLogin, + securityKeys: profile!.twoFactorEnabled + ? UserSecurityKeys.countBy({ + userId: user.id, + }).then((result) => result >= 1) + : false, + pronouns: profile!.pronouns, + } + : {}), + + ...(opts.detail && isMe + ? { + avatarId: user.avatarId, + bannerId: user.bannerId, + injectFeaturedNote: profile!.injectFeaturedNote, + receiveAnnouncementEmail: profile!.receiveAnnouncementEmail, + alwaysMarkNsfw: profile!.alwaysMarkNsfw, + carefulBot: profile!.carefulBot, + autoAcceptFollowed: profile!.autoAcceptFollowed, + noCrawle: profile!.noCrawle, + preventAiLearning: profile!.preventAiLearning, + isExplorable: user.isExplorable, + isDeleted: user.isDeleted, + hideOnlineStatus: user.hideOnlineStatus, + hasUnreadSpecifiedNotes: NoteUnreads.count({ + where: { userId: user.id, isSpecified: true }, + take: 1, + }).then((count) => count > 0), + hasUnreadMentions: NoteUnreads.count({ + where: { userId: user.id, isMentioned: true }, + take: 1, + }).then((count) => count > 0), + hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id), + hasUnreadAntenna: this.getHasUnreadAntenna(user.id), + hasUnreadChannel: this.getHasUnreadChannel(user.id), + hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage( + user.id, + ), + hasUnreadNotification: this.getHasUnreadNotification(user.id), + hasPendingReceivedFollowRequest: + this.getHasPendingReceivedFollowRequest(user.id), + integrations: profile!.integrations, + mutedWords: profile!.mutedWords, + mutedInstances: profile!.mutedInstances, + mutingNotificationTypes: profile!.mutingNotificationTypes, + emailNotificationTypes: profile!.emailNotificationTypes, + } + : {}), + + ...(opts.includeSecrets + ? { + email: profile!.email, + emailVerified: profile!.emailVerified, + securityKeysList: profile!.twoFactorEnabled + ? UserSecurityKeys.find({ + where: { + userId: user.id, + }, + select: { + id: true, + name: true, + lastUsed: true, + }, + }) + : [], + } + : {}), + + ...(relation + ? { + isFollowing: relation.isFollowing, + isFollowed: relation.isFollowed, + hasPendingFollowRequestFromYou: + relation.hasPendingFollowRequestFromYou, + hasPendingFollowRequestToYou: relation.hasPendingFollowRequestToYou, + isBlocking: relation.isBlocking, + isBlocked: relation.isBlocked, + isMuted: relation.isMuted, + isRenoteMuted: relation.isRenoteMuted, + } + : {}), + } as Promiseable> as Promiseable< + IsMeAndIsUserDetailed + >; + + return await awaitAll(packed); + }, + + packMany( + users: (User["id"] | User)[], + me?: { id: User["id"] } | null | undefined, + options?: { + detail?: D; + includeSecrets?: boolean; + }, + cache?: PackedUserCache, + ): Promise[]> { + return Promise.all(users.map((u) => this.packCached(u, cache ?? this.getFreshPackedUserCache(), me, options))); + }, + + isLocalUser, + isRemoteUser, +}); diff --git a/packages/backend/src/models/repositories/verified-badge-request.ts b/packages/backend/src/models/repositories/verified-badge-request.ts new file mode 100644 index 0000000..fe1fca8 --- /dev/null +++ b/packages/backend/src/models/repositories/verified-badge-request.ts @@ -0,0 +1,35 @@ +import { db } from "@/db/postgre.js"; +import { Users } from "../index.js"; +import { VerifiedBadgeRequest } from "@/models/entities/verified-badge-request.js"; +import { awaitAll } from "@/prelude/await-all.js"; + +export const VerifiedBadgeRequestRepository = db + .getRepository(VerifiedBadgeRequest) + .extend({ + async pack(src: VerifiedBadgeRequest["id"] | VerifiedBadgeRequest) { + const request = + typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: request.id, + createdAt: request.createdAt.toISOString(), + resolvedAt: request.resolvedAt?.toISOString() ?? null, + status: request.status, + comment: request.comment, + userId: request.userId, + resolverId: request.resolverId, + user: Users.pack(request.user || request.userId, null, { + detail: true, + }), + resolver: request.resolverId + ? Users.pack(request.resolver || request.resolverId, null, { + detail: true, + }) + : null, + }); + }, + + packMany(requests: any[]) { + return Promise.all(requests.map((x) => this.pack(x))); + }, + }); diff --git a/packages/backend/src/models/schema/antenna.ts b/packages/backend/src/models/schema/antenna.ts new file mode 100644 index 0000000..990e2da --- /dev/null +++ b/packages/backend/src/models/schema/antenna.ts @@ -0,0 +1,118 @@ +export const packedAntennaSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + name: { + type: "string", + optional: false, + nullable: false, + }, + keywords: { + type: "array", + optional: false, + nullable: false, + items: { + type: "array", + optional: false, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + }, + excludeKeywords: { + type: "array", + optional: false, + nullable: false, + items: { + type: "array", + optional: false, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + }, + src: { + type: "string", + optional: false, + nullable: false, + enum: ["home", "all", "users", "list", "group", "instances"], + }, + userListId: { + type: "string", + optional: false, + nullable: true, + format: "id", + }, + userGroupId: { + type: "string", + optional: false, + nullable: true, + format: "id", + }, + users: { + type: "array", + optional: false, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + instances: { + type: "array", + optional: false, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + caseSensitive: { + type: "boolean", + optional: false, + nullable: false, + default: false, + }, + notify: { + type: "boolean", + optional: false, + nullable: false, + }, + withReplies: { + type: "boolean", + optional: false, + nullable: false, + default: false, + }, + withFile: { + type: "boolean", + optional: false, + nullable: false, + }, + hasUnreadNote: { + type: "boolean", + optional: false, + nullable: false, + default: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/app.ts b/packages/backend/src/models/schema/app.ts new file mode 100644 index 0000000..8ec7115 --- /dev/null +++ b/packages/backend/src/models/schema/app.ts @@ -0,0 +1,40 @@ +export const packedAppSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + }, + name: { + type: "string", + optional: false, + nullable: false, + }, + callbackUrl: { + type: "string", + optional: false, + nullable: true, + }, + permission: { + type: "array", + optional: false, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + secret: { + type: "string", + optional: true, + nullable: false, + }, + isAuthorized: { + type: "boolean", + optional: true, + nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/bite.ts b/packages/backend/src/models/schema/bite.ts new file mode 100644 index 0000000..fdab022 --- /dev/null +++ b/packages/backend/src/models/schema/bite.ts @@ -0,0 +1,31 @@ +export const packedBiteSchema = { + type: "object", + properties: { + id: { + type: "string", + format: "id", + optional: false, + nullable: false, + }, + user: { + type: "object", + ref: "UserLite", + }, + targetType: { + type: "string", + enum: ["user", "bite", "note"], + }, + target: { + oneOf: [ + { + type: "object", + ref: "UserLite", + }, + { + type: "object", + ref: "Bite", + }, + ], + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/blocking.ts b/packages/backend/src/models/schema/blocking.ts new file mode 100644 index 0000000..1d491e9 --- /dev/null +++ b/packages/backend/src/models/schema/blocking.ts @@ -0,0 +1,30 @@ +export const packedBlockingSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + blockeeId: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + blockee: { + type: "object", + optional: false, + nullable: false, + ref: "UserDetailed", + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/channel.ts b/packages/backend/src/models/schema/channel.ts new file mode 100644 index 0000000..67833cb --- /dev/null +++ b/packages/backend/src/models/schema/channel.ts @@ -0,0 +1,61 @@ +export const packedChannelSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + lastNotedAt: { + type: "string", + optional: false, + nullable: true, + format: "date-time", + }, + name: { + type: "string", + optional: false, + nullable: false, + }, + description: { + type: "string", + nullable: true, + optional: false, + }, + bannerUrl: { + type: "string", + format: "url", + nullable: true, + optional: false, + }, + notesCount: { + type: "number", + nullable: false, + optional: false, + }, + usersCount: { + type: "number", + nullable: false, + optional: false, + }, + isFollowing: { + type: "boolean", + optional: true, + nullable: false, + }, + userId: { + type: "string", + nullable: true, + optional: false, + format: "id", + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/clip.ts b/packages/backend/src/models/schema/clip.ts new file mode 100644 index 0000000..651303a --- /dev/null +++ b/packages/backend/src/models/schema/clip.ts @@ -0,0 +1,45 @@ +export const packedClipSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + userId: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + user: { + type: "object", + ref: "UserLite", + optional: false, + nullable: false, + }, + name: { + type: "string", + optional: false, + nullable: false, + }, + description: { + type: "string", + optional: false, + nullable: true, + }, + isPublic: { + type: "boolean", + optional: false, + nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/drive-file.ts b/packages/backend/src/models/schema/drive-file.ts new file mode 100644 index 0000000..4b77be4 --- /dev/null +++ b/packages/backend/src/models/schema/drive-file.ts @@ -0,0 +1,132 @@ +export const packedDriveFileSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + name: { + type: "string", + optional: false, + nullable: false, + example: "lenna.jpg", + }, + type: { + type: "string", + optional: false, + nullable: false, + example: "image/jpeg", + }, + md5: { + type: "string", + optional: false, + nullable: false, + format: "md5", + example: "15eca7fba0480996e2245f5185bf39f2", + }, + size: { + type: "number", + optional: false, + nullable: false, + example: 51469, + }, + isSensitive: { + type: "boolean", + optional: false, + nullable: false, + }, + allowDownload: { + type: "boolean", + optional: false, + nullable: false, + }, + blurhash: { + type: "string", + optional: false, + nullable: true, + }, + properties: { + type: "object", + optional: false, + nullable: false, + properties: { + width: { + type: "number", + optional: true, + nullable: false, + example: 1280, + }, + height: { + type: "number", + optional: true, + nullable: false, + example: 720, + }, + orientation: { + type: "number", + optional: true, + nullable: false, + example: 8, + }, + avgColor: { + type: "string", + optional: true, + nullable: false, + example: "rgb(40,65,87)", + }, + }, + }, + url: { + type: "string", + optional: false, + nullable: true, + format: "url", + }, + thumbnailUrl: { + type: "string", + optional: false, + nullable: true, + format: "url", + }, + comment: { + type: "string", + optional: false, + nullable: true, + }, + folderId: { + type: "string", + optional: false, + nullable: true, + format: "id", + example: "xxxxxxxxxx", + }, + folder: { + type: "object", + optional: true, + nullable: true, + ref: "DriveFolder", + }, + userId: { + type: "string", + optional: false, + nullable: true, + format: "id", + example: "xxxxxxxxxx", + }, + user: { + type: "object", + optional: true, + nullable: true, + ref: "UserLite", + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/drive-folder.ts b/packages/backend/src/models/schema/drive-folder.ts new file mode 100644 index 0000000..2298b54 --- /dev/null +++ b/packages/backend/src/models/schema/drive-folder.ts @@ -0,0 +1,46 @@ +export const packedDriveFolderSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + name: { + type: "string", + optional: false, + nullable: false, + }, + foldersCount: { + type: "number", + optional: true, + nullable: false, + }, + filesCount: { + type: "number", + optional: true, + nullable: false, + }, + parentId: { + type: "string", + optional: false, + nullable: true, + format: "id", + example: "xxxxxxxxxx", + }, + parent: { + type: "object", + optional: true, + nullable: true, + ref: "DriveFolder", + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/emoji.ts b/packages/backend/src/models/schema/emoji.ts new file mode 100644 index 0000000..8dbbf2f --- /dev/null +++ b/packages/backend/src/models/schema/emoji.ts @@ -0,0 +1,69 @@ +export const packedEmojiSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + aliases: { + type: "array", + optional: false, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + }, + name: { + type: "string", + optional: false, + nullable: false, + }, + category: { + type: "string", + optional: false, + nullable: true, + }, + host: { + type: "string", + optional: false, + nullable: true, + description: "The local host is represented with `null`.", + }, + url: { + type: "string", + optional: false, + nullable: false, + }, + license: { + type: "string", + optional: false, + nullable: true, + }, + glyph: { + type: "boolean", + optional: false, + nullable: false, + }, + glyphUrl: { + type: "string", + optional: false, + nullable: true, + }, + width: { + type: "number", + optional: false, + nullable: true, + }, + height: { + type: "number", + optional: false, + nullable: true, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/federation-instance.ts b/packages/backend/src/models/schema/federation-instance.ts new file mode 100644 index 0000000..fa67569 --- /dev/null +++ b/packages/backend/src/models/schema/federation-instance.ts @@ -0,0 +1,138 @@ +import config from "@/config/index.js"; + +export const packedFederationInstanceSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + caughtAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + host: { + type: "string", + optional: false, + nullable: false, + example: "iceshrimp.example.com", + }, + usersCount: { + type: "number", + optional: false, + nullable: false, + }, + notesCount: { + type: "number", + optional: false, + nullable: false, + }, + followingCount: { + type: "number", + optional: false, + nullable: false, + }, + followersCount: { + type: "number", + optional: false, + nullable: false, + }, + latestRequestSentAt: { + type: "string", + optional: false, + nullable: true, + format: "date-time", + }, + lastCommunicatedAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + isNotResponding: { + type: "boolean", + optional: false, + nullable: false, + }, + isSuspended: { + type: "boolean", + optional: false, + nullable: false, + }, + isBlocked: { + type: "boolean", + optional: false, + nullable: false, + }, + isSilenced: { + type: "boolean", + optional: false, + nullable: false, + }, + softwareName: { + type: "string", + optional: false, + nullable: true, + example: "iceshrimp", + }, + softwareVersion: { + type: "string", + optional: false, + nullable: true, + example: config.version, + }, + openRegistrations: { + type: "boolean", + optional: false, + nullable: true, + example: true, + }, + name: { + type: "string", + optional: false, + nullable: true, + }, + description: { + type: "string", + optional: false, + nullable: true, + }, + maintainerName: { + type: "string", + optional: false, + nullable: true, + }, + maintainerEmail: { + type: "string", + optional: false, + nullable: true, + }, + iconUrl: { + type: "string", + optional: false, + nullable: true, + format: "url", + }, + faviconUrl: { + type: "string", + optional: false, + nullable: true, + format: "url", + }, + themeColor: { + type: "string", + optional: false, + nullable: true, + }, + infoUpdatedAt: { + type: "string", + optional: false, + nullable: true, + format: "date-time", + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/following.ts b/packages/backend/src/models/schema/following.ts new file mode 100644 index 0000000..f53cafa --- /dev/null +++ b/packages/backend/src/models/schema/following.ts @@ -0,0 +1,42 @@ +export const packedFollowingSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + followeeId: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + followee: { + type: "object", + optional: true, + nullable: false, + ref: "UserDetailed", + }, + followerId: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + follower: { + type: "object", + optional: true, + nullable: false, + ref: "UserDetailed", + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/gallery-post.ts b/packages/backend/src/models/schema/gallery-post.ts new file mode 100644 index 0000000..9ac348e --- /dev/null +++ b/packages/backend/src/models/schema/gallery-post.ts @@ -0,0 +1,83 @@ +export const packedGalleryPostSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + updatedAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + title: { + type: "string", + optional: false, + nullable: false, + }, + description: { + type: "string", + optional: false, + nullable: true, + }, + userId: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + user: { + type: "object", + ref: "UserLite", + optional: false, + nullable: false, + }, + fileIds: { + type: "array", + optional: true, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + }, + files: { + type: "array", + optional: true, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "DriveFile", + }, + }, + tags: { + type: "array", + optional: true, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + isSensitive: { + type: "boolean", + optional: false, + nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/hashtag.ts b/packages/backend/src/models/schema/hashtag.ts new file mode 100644 index 0000000..0f8be62 --- /dev/null +++ b/packages/backend/src/models/schema/hashtag.ts @@ -0,0 +1,41 @@ +export const packedHashtagSchema = { + type: "object", + properties: { + tag: { + type: "string", + optional: false, + nullable: false, + example: "iceshrimp", + }, + mentionedUsersCount: { + type: "number", + optional: false, + nullable: false, + }, + mentionedLocalUsersCount: { + type: "number", + optional: false, + nullable: false, + }, + mentionedRemoteUsersCount: { + type: "number", + optional: false, + nullable: false, + }, + attachedUsersCount: { + type: "number", + optional: false, + nullable: false, + }, + attachedLocalUsersCount: { + type: "number", + optional: false, + nullable: false, + }, + attachedRemoteUsersCount: { + type: "number", + optional: false, + nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/messaging-message.ts b/packages/backend/src/models/schema/messaging-message.ts new file mode 100644 index 0000000..d598e6d --- /dev/null +++ b/packages/backend/src/models/schema/messaging-message.ts @@ -0,0 +1,87 @@ +export const packedMessagingMessageSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + userId: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + user: { + type: "object", + ref: "UserLite", + optional: true, + nullable: false, + }, + text: { + type: "string", + optional: false, + nullable: true, + }, + fileId: { + type: "string", + optional: true, + nullable: true, + format: "id", + }, + file: { + type: "object", + optional: true, + nullable: true, + ref: "DriveFile", + }, + recipientId: { + type: "string", + optional: false, + nullable: true, + format: "id", + }, + recipient: { + type: "object", + optional: true, + nullable: true, + ref: "UserLite", + }, + groupId: { + type: "string", + optional: false, + nullable: true, + format: "id", + }, + group: { + type: "object", + optional: true, + nullable: true, + ref: "UserGroup", + }, + isRead: { + type: "boolean", + optional: true, + nullable: false, + }, + reads: { + type: "array", + optional: true, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/muting.ts b/packages/backend/src/models/schema/muting.ts new file mode 100644 index 0000000..d5815f8 --- /dev/null +++ b/packages/backend/src/models/schema/muting.ts @@ -0,0 +1,36 @@ +export const packedMutingSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + expiresAt: { + type: "string", + optional: false, + nullable: true, + format: "date-time", + }, + muteeId: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + mutee: { + type: "object", + optional: false, + nullable: false, + ref: "UserDetailed", + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/note-edit.ts b/packages/backend/src/models/schema/note-edit.ts new file mode 100644 index 0000000..e877f3f --- /dev/null +++ b/packages/backend/src/models/schema/note-edit.ts @@ -0,0 +1,49 @@ +export const packedNoteEdit = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + updatedAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + note: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + noteId: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + text: { + type: "string", + optional: true, + nullable: true, + }, + cw: { + type: "string", + optional: true, + nullable: true, + }, + fileIds: { + type: "array", + optional: true, + nullable: true, + items: { + type: "string", + format: "id", + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/note-favorite.ts b/packages/backend/src/models/schema/note-favorite.ts new file mode 100644 index 0000000..17a42ba --- /dev/null +++ b/packages/backend/src/models/schema/note-favorite.ts @@ -0,0 +1,30 @@ +export const packedNoteFavoriteSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + note: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + noteId: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/note-reaction.ts b/packages/backend/src/models/schema/note-reaction.ts new file mode 100644 index 0000000..1080bdc --- /dev/null +++ b/packages/backend/src/models/schema/note-reaction.ts @@ -0,0 +1,29 @@ +export const packedNoteReactionSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + user: { + type: "object", + optional: false, + nullable: false, + ref: "UserLite", + }, + type: { + type: "string", + optional: false, + nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/note.ts b/packages/backend/src/models/schema/note.ts new file mode 100644 index 0000000..5a15fc9 --- /dev/null +++ b/packages/backend/src/models/schema/note.ts @@ -0,0 +1,236 @@ +export const packedNoteSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + text: { + type: "string", + optional: false, + nullable: true, + }, + cw: { + type: "string", + optional: true, + nullable: true, + }, + userId: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + user: { + type: "object", + ref: "UserLite", + optional: false, + nullable: false, + }, + groupId: { + type: "string", + optional: false, + nullable: true, + format: "id", + }, + group: { + type: "object", + ref: "UserGroup", + optional: false, + nullable: true, + }, + replyId: { + type: "string", + optional: true, + nullable: true, + format: "id", + example: "xxxxxxxxxx", + }, + renoteId: { + type: "string", + optional: true, + nullable: true, + format: "id", + example: "xxxxxxxxxx", + }, + reply: { + type: "object", + optional: true, + nullable: true, + ref: "Note", + }, + renote: { + type: "object", + optional: true, + nullable: true, + ref: "Note", + }, + visibility: { + type: "string", + optional: false, + nullable: false, + }, + mentions: { + type: "array", + optional: true, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + }, + visibleUserIds: { + type: "array", + optional: true, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + }, + fileIds: { + type: "array", + optional: true, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + }, + files: { + type: "array", + optional: true, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "DriveFile", + }, + }, + tags: { + type: "array", + optional: true, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + poll: { + type: "object", + optional: true, + nullable: true, + }, + channelId: { + type: "string", + optional: true, + nullable: true, + format: "id", + example: "xxxxxxxxxx", + }, + channel: { + type: "object", + optional: true, + nullable: true, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + }, + name: { + type: "string", + optional: false, + nullable: true, + }, + }, + }, + }, + localOnly: { + type: "boolean", + optional: true, + nullable: false, + }, + emojis: { + type: "object", + optional: true, + nullable: true, + }, + reactions: { + type: "object", + optional: false, + nullable: false, + }, + renoteCount: { + type: "number", + optional: false, + nullable: false, + }, + repliesCount: { + type: "number", + optional: false, + nullable: false, + }, + viewCount: { + type: "number", + optional: false, + nullable: false, + }, + uri: { + type: "string", + optional: true, + nullable: false, + }, + url: { + type: "string", + optional: true, + nullable: false, + }, + myReaction: { + type: "object", + optional: true, + nullable: true, + }, + isRenoted: { + type: "boolean", + optional: true, + nullable: true, + }, + isFiltered: { + type: "boolean", + optional: true, + nullable: true, + }, + quoteAuthorization: { + type: "string", + optional: true, + nullable: true, + }, + canBite: { + type: "boolean", + optional: true, + nullable: true, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/notification.ts b/packages/backend/src/models/schema/notification.ts new file mode 100644 index 0000000..cb6bc47 --- /dev/null +++ b/packages/backend/src/models/schema/notification.ts @@ -0,0 +1,85 @@ +import { notificationTypes } from "@/types.js"; + +export const packedNotificationSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + isRead: { + type: "boolean", + optional: false, + nullable: false, + }, + type: { + type: "string", + optional: false, + nullable: false, + enum: [...notificationTypes], + }, + user: { + type: "object", + ref: "UserLite", + optional: true, + nullable: true, + }, + userId: { + type: "string", + optional: true, + nullable: true, + format: "id", + }, + note: { + type: "object", + ref: "Note", + optional: true, + nullable: true, + }, + reaction: { + type: "string", + optional: true, + nullable: true, + }, + choice: { + type: "number", + optional: true, + nullable: true, + }, + invitation: { + type: "object", + optional: true, + nullable: true, + }, + body: { + type: "string", + optional: true, + nullable: true, + }, + header: { + type: "string", + optional: true, + nullable: true, + }, + icon: { + type: "string", + optional: true, + nullable: true, + }, + bite: { + type: "object", + ref: "Bite", + optional: true, + nullable: true, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/page.ts b/packages/backend/src/models/schema/page.ts new file mode 100644 index 0000000..a1b9144 --- /dev/null +++ b/packages/backend/src/models/schema/page.ts @@ -0,0 +1,66 @@ +export const packedPageSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + updatedAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + title: { + type: "string", + optional: false, + nullable: false, + }, + name: { + type: "string", + optional: false, + nullable: false, + }, + summary: { + type: "string", + optional: false, + nullable: true, + }, + content: { + type: "array", + optional: false, + nullable: false, + }, + variables: { + type: "array", + optional: false, + nullable: false, + }, + userId: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + user: { + type: "object", + ref: "UserLite", + optional: false, + nullable: false, + }, + isPublic: { + type: "boolean", + optional: false, + nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/queue.ts b/packages/backend/src/models/schema/queue.ts new file mode 100644 index 0000000..954ac68 --- /dev/null +++ b/packages/backend/src/models/schema/queue.ts @@ -0,0 +1,30 @@ +export const packedQueueCountSchema = { + type: "object", + properties: { + waiting: { + type: "number", + optional: false, + nullable: false, + }, + active: { + type: "number", + optional: false, + nullable: false, + }, + completed: { + type: "number", + optional: false, + nullable: false, + }, + failed: { + type: "number", + optional: false, + nullable: false, + }, + delayed: { + type: "number", + optional: false, + nullable: false, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/renote-muting.ts b/packages/backend/src/models/schema/renote-muting.ts new file mode 100644 index 0000000..2a5824e --- /dev/null +++ b/packages/backend/src/models/schema/renote-muting.ts @@ -0,0 +1,30 @@ +export const packedRenoteMutingSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + muteeId: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + mutee: { + type: "object", + optional: false, + nullable: false, + ref: "UserDetailed", + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/user-group.ts b/packages/backend/src/models/schema/user-group.ts new file mode 100644 index 0000000..a4a85f9 --- /dev/null +++ b/packages/backend/src/models/schema/user-group.ts @@ -0,0 +1,40 @@ +export const packedUserGroupSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + name: { + type: "string", + optional: false, + nullable: false, + }, + ownerId: { + type: "string", + nullable: false, + optional: false, + format: "id", + }, + userIds: { + type: "array", + nullable: false, + optional: true, + items: { + type: "string", + nullable: false, + optional: false, + format: "id", + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/user-list.ts b/packages/backend/src/models/schema/user-list.ts new file mode 100644 index 0000000..3ac3796 --- /dev/null +++ b/packages/backend/src/models/schema/user-list.ts @@ -0,0 +1,39 @@ +export const packedUserListSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + name: { + type: "string", + optional: false, + nullable: false, + }, + hideFromHomeTl: { + type: "boolean", + optional: false, + nullable: false, + }, + userIds: { + type: "array", + nullable: false, + optional: true, + items: { + type: "string", + nullable: false, + optional: false, + format: "id", + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/user.ts b/packages/backend/src/models/schema/user.ts new file mode 100644 index 0000000..6a6722a --- /dev/null +++ b/packages/backend/src/models/schema/user.ts @@ -0,0 +1,652 @@ +export const packedUserLiteSchema = { + type: "object", + properties: { + id: { + type: "string", + nullable: false, + optional: false, + format: "id", + example: "xxxxxxxxxx", + }, + name: { + type: "string", + nullable: true, + optional: false, + example: "藍", + }, + username: { + type: "string", + nullable: false, + optional: false, + example: "calc", + }, + host: { + type: "string", + nullable: true, + optional: false, + example: "misskey.example.com", + description: "The local host is represented with `null`.", + }, + avatarUrl: { + type: "string", + format: "url", + nullable: true, + optional: false, + }, + avatarBlurhash: { + type: "any", + nullable: true, + optional: false, + }, + avatarColor: { + type: "any", + nullable: true, + optional: false, + default: null, + }, + isAdmin: { + type: "boolean", + nullable: false, + optional: true, + default: false, + }, + isModerator: { + type: "boolean", + nullable: false, + optional: true, + default: false, + }, + isVerified: { + type: "boolean", + nullable: false, + optional: true, + default: false, + }, + minorBadges: { + type: "array", + nullable: false, + optional: true, + maxItems: 1, + items: { + type: "string", + nullable: false, + optional: false, + enum: ["K", "T", "E"], + }, + }, + plans: { + type: "array", + nullable: false, + optional: true, + items: { + type: "object", + nullable: false, + optional: false, + properties: { + id: { + type: "string", + nullable: false, + optional: false, + format: "id", + }, + name: { + type: "string", + nullable: false, + optional: false, + }, + icon: { + type: "string", + nullable: false, + optional: false, + }, + description: { + type: "string", + nullable: false, + optional: false, + }, + }, + }, + }, + isBot: { + type: "boolean", + nullable: false, + optional: true, + }, + isCat: { + type: "boolean", + nullable: false, + optional: true, + }, + speakAsCat: { + type: "boolean", + nullable: false, + optional: true, + }, + emojis: { + type: "array", + nullable: false, + optional: false, + items: { + type: "object", + nullable: false, + optional: false, + properties: { + name: { + type: "string", + nullable: false, + optional: false, + }, + url: { + type: "string", + nullable: false, + optional: false, + format: "url", + }, + }, + }, + }, + onlineStatus: { + type: "string", + format: "url", + nullable: true, + optional: false, + enum: ["unknown", "online", "active", "offline"], + }, + canBite: { + type: "string", + enum: ["anyone", "followers", "nobody"], + }, + }, +} as const; + +export const packedUserDetailedNotMeOnlySchema = { + type: "object", + properties: { + url: { + type: "string", + format: "url", + nullable: true, + optional: false, + }, + uri: { + type: "string", + format: "uri", + nullable: true, + optional: false, + }, + movedToUri: { + type: "string", + format: "uri", + nullable: true, + optional: false, + }, + alsoKnownAs: { + type: "array", + format: "uri", + nullable: true, + optional: false, + }, + createdAt: { + type: "string", + nullable: false, + optional: false, + format: "date-time", + }, + updatedAt: { + type: "string", + nullable: true, + optional: false, + format: "date-time", + }, + lastFetchedAt: { + type: "string", + nullable: true, + optional: false, + format: "date-time", + }, + bannerUrl: { + type: "string", + format: "url", + nullable: true, + optional: false, + }, + bannerBlurhash: { + type: "any", + nullable: true, + optional: false, + }, + bannerColor: { + type: "any", + nullable: true, + optional: false, + default: null, + }, + isLocked: { + type: "boolean", + nullable: false, + optional: false, + }, + isSilenced: { + type: "boolean", + nullable: false, + optional: false, + }, + isSuspended: { + type: "boolean", + nullable: false, + optional: false, + example: false, + }, + description: { + type: "string", + nullable: true, + optional: false, + example: "Hi masters, I am Ai!", + }, + location: { + type: "string", + nullable: true, + optional: false, + }, + birthday: { + type: "string", + nullable: true, + optional: false, + example: "2018-03-12", + }, + lang: { + type: "string", + nullable: true, + optional: false, + example: "ja-JP", + }, + fields: { + type: "array", + nullable: false, + optional: false, + items: { + type: "object", + nullable: false, + optional: false, + properties: { + name: { + type: "string", + nullable: false, + optional: false, + }, + value: { + type: "string", + nullable: false, + optional: false, + }, + }, + maxLength: 4, + }, + }, + followersCount: { + type: "number", + nullable: false, + optional: false, + }, + followingCount: { + type: "number", + nullable: false, + optional: false, + }, + notesCount: { + type: "number", + nullable: false, + optional: false, + }, + pinnedNoteIds: { + type: "array", + nullable: false, + optional: false, + items: { + type: "string", + nullable: false, + optional: false, + format: "id", + }, + }, + pinnedNotes: { + type: "array", + nullable: false, + optional: false, + items: { + type: "object", + nullable: false, + optional: false, + ref: "Note", + }, + }, + pinnedPageId: { + type: "string", + nullable: true, + optional: false, + }, + pinnedPage: { + type: "object", + nullable: true, + optional: false, + ref: "Page", + }, + publicReactions: { + type: "boolean", + nullable: false, + optional: false, + }, + twoFactorEnabled: { + type: "boolean", + nullable: false, + optional: false, + default: false, + }, + usePasswordLessLogin: { + type: "boolean", + nullable: false, + optional: false, + default: false, + }, + securityKeys: { + type: "boolean", + nullable: false, + optional: false, + default: false, + }, + pronouns: { + type: "object", + }, + //#region relations + isFollowing: { + type: "boolean", + nullable: false, + optional: true, + }, + isFollowed: { + type: "boolean", + nullable: false, + optional: true, + }, + hasPendingFollowRequestFromYou: { + type: "boolean", + nullable: false, + optional: true, + }, + hasPendingFollowRequestToYou: { + type: "boolean", + nullable: false, + optional: true, + }, + isBlocking: { + type: "boolean", + nullable: false, + optional: true, + }, + isBlocked: { + type: "boolean", + nullable: false, + optional: true, + }, + isMuted: { + type: "boolean", + nullable: false, + optional: true, + }, + isRenoteMuted: { + type: "boolean", + nullable: false, + optional: true, + }, + //#endregion + }, +} as const; + +export const packedMeDetailedOnlySchema = { + type: "object", + properties: { + avatarId: { + type: "string", + nullable: true, + optional: false, + format: "id", + }, + bannerId: { + type: "string", + nullable: true, + optional: false, + format: "id", + }, + injectFeaturedNote: { + type: "boolean", + nullable: true, + optional: false, + }, + receiveAnnouncementEmail: { + type: "boolean", + nullable: true, + optional: false, + }, + alwaysMarkNsfw: { + type: "boolean", + nullable: true, + optional: false, + }, + carefulBot: { + type: "boolean", + nullable: true, + optional: false, + }, + autoAcceptFollowed: { + type: "boolean", + nullable: true, + optional: false, + }, + noCrawle: { + type: "boolean", + nullable: true, + optional: false, + }, + preventAiLearning: { + type: "boolean", + nullable: true, + optional: false, + }, + isExplorable: { + type: "boolean", + nullable: false, + optional: false, + }, + isDeleted: { + type: "boolean", + nullable: false, + optional: false, + }, + hideOnlineStatus: { + type: "boolean", + nullable: false, + optional: false, + }, + allowCalls: { + type: "boolean", + nullable: false, + optional: true, + }, + symbolFileId: { + type: "string", + nullable: true, + optional: true, + format: "id", + }, + hasUnreadSpecifiedNotes: { + type: "boolean", + nullable: false, + optional: false, + }, + hasUnreadMentions: { + type: "boolean", + nullable: false, + optional: false, + }, + hasUnreadAnnouncement: { + type: "boolean", + nullable: false, + optional: false, + }, + hasUnreadAntenna: { + type: "boolean", + nullable: false, + optional: false, + }, + hasUnreadChannel: { + type: "boolean", + nullable: false, + optional: false, + }, + hasUnreadMessagingMessage: { + type: "boolean", + nullable: false, + optional: false, + }, + hasUnreadNotification: { + type: "boolean", + nullable: false, + optional: false, + }, + hasPendingReceivedFollowRequest: { + type: "boolean", + nullable: false, + optional: false, + }, + integrations: { + type: "object", + nullable: true, + optional: false, + }, + mutedWords: { + type: "array", + nullable: false, + optional: false, + items: { + type: "array", + nullable: false, + optional: false, + items: { + type: "string", + nullable: false, + optional: false, + }, + }, + }, + mutedInstances: { + type: "array", + nullable: true, + optional: false, + items: { + type: "string", + nullable: false, + optional: false, + }, + }, + mutingNotificationTypes: { + type: "array", + nullable: true, + optional: false, + items: { + type: "string", + nullable: false, + optional: false, + }, + }, + emailNotificationTypes: { + type: "array", + nullable: true, + optional: false, + items: { + type: "string", + nullable: false, + optional: false, + }, + }, + //#region secrets + email: { + type: "string", + nullable: true, + optional: true, + }, + emailVerified: { + type: "boolean", + nullable: true, + optional: true, + }, + securityKeysList: { + type: "array", + nullable: false, + optional: true, + items: { + type: "object", + nullable: false, + optional: false, + }, + }, + //#endregion + }, +} as const; + +export const packedUserDetailedNotMeSchema = { + type: "object", + allOf: [ + { + type: "object", + ref: "UserLite", + }, + { + type: "object", + ref: "UserDetailedNotMeOnly", + }, + ], +} as const; + +export const packedMeDetailedSchema = { + type: "object", + allOf: [ + { + type: "object", + ref: "UserLite", + }, + { + type: "object", + ref: "UserDetailedNotMeOnly", + }, + { + type: "object", + ref: "MeDetailedOnly", + }, + ], +} as const; + +export const packedUserDetailedSchema = { + oneOf: [ + { + type: "object", + ref: "UserDetailedNotMe", + }, + { + type: "object", + ref: "MeDetailed", + }, + ], +} as const; + +export const packedUserSchema = { + oneOf: [ + { + type: "object", + ref: "UserLite", + }, + { + type: "object", + ref: "UserDetailed", + }, + ], +} as const; diff --git a/packages/backend/src/ormconfig.ts b/packages/backend/src/ormconfig.ts new file mode 100644 index 0000000..a1891e0 --- /dev/null +++ b/packages/backend/src/ormconfig.ts @@ -0,0 +1,15 @@ +import { DataSource } from "typeorm"; +import config from "./config/index.js"; +import { entities } from "./db/postgre.js"; + +export default new DataSource({ + type: "postgres", + host: config.db.host, + port: config.db.port, + username: config.db.user, + password: config.db.pass, + database: config.db.db, + extra: config.db.extra, + entities: entities, + migrations: ["built/migration/*.js"], +}); diff --git a/packages/backend/src/prelude/README.md b/packages/backend/src/prelude/README.md new file mode 100644 index 0000000..bb728cf --- /dev/null +++ b/packages/backend/src/prelude/README.md @@ -0,0 +1,3 @@ +# Prelude +このディレクトリのコードはJavaScriptの表現能力を補うためのコードです。 +Misskey固有の処理とは独立したコードの集まりですが、Misskeyのコードを読みやすくすることを目的としています。 diff --git a/packages/backend/src/prelude/array.ts b/packages/backend/src/prelude/array.ts new file mode 100644 index 0000000..5f68ba3 --- /dev/null +++ b/packages/backend/src/prelude/array.ts @@ -0,0 +1,150 @@ +import type { EndoRelation, Predicate } from "./relation.js"; + +/** + * Count the number of elements that satisfy the predicate + */ + +export function countIf(f: Predicate, xs: T[]): number { + return xs.filter(f).length; +} + +/** + * Count the number of elements that is equal to the element + */ +export function count(a: T, xs: T[]): number { + return countIf((x) => x === a, xs); +} + +/** + * Concatenate an array of arrays + */ +export function concat(xss: T[][]): T[] { + return ([] as T[]).concat(...xss); +} + +/** + * Intersperse the element between the elements of the array + * @param sep The element to be interspersed + */ +export function intersperse(sep: T, xs: T[]): T[] { + return concat(xs.map((x) => [sep, x])).slice(1); +} + +/** + * Returns the array of elements that is not equal to the element + */ +export function erase(a: T, xs: T[]): T[] { + return xs.filter((x) => x !== a); +} + +/** + * Finds the array of all elements in the first array not contained in the second array. + * The order of result values are determined by the first array. + */ +export function difference(xs: T[], ys: T[]): T[] { + return xs.filter((x) => !ys.includes(x)); +} + +/** + * Remove all but the first element from every group of equivalent elements + */ +export function unique(xs: T[]): T[] { + return [...new Set(xs)]; +} + +export function uniqBy(a: T[], key: Function): T[] { + const seen = new Set(); + return a.filter(function(item) { + const k = key(item); + return seen.has(k) ? false : seen.add(k); + }) +} + +export function sum(xs: number[]): number { + return xs.reduce((a, b) => a + b, 0); +} + +export function maximum(xs: number[]): number { + return Math.max(...xs); +} + +/** + * Splits an array based on the equivalence relation. + * The concatenation of the result is equal to the argument. + */ +export function groupBy(f: EndoRelation, xs: T[]): T[][] { + const groups = [] as T[][]; + for (const x of xs) { + if (groups.length !== 0 && f(groups[groups.length - 1][0], x)) { + groups[groups.length - 1].push(x); + } else { + groups.push([x]); + } + } + return groups; +} + +/** + * Splits an array based on the equivalence relation induced by the function. + * The concatenation of the result is equal to the argument. + */ +export function groupOn(f: (x: T) => S, xs: T[]): T[][] { + return groupBy((a, b) => f(a) === f(b), xs); +} + +export function groupByX(collections: T[], keySelector: (x: T) => string) { + return collections.reduce((obj: Record, item: T) => { + const key = keySelector(item); + if (!Object.prototype.hasOwnProperty.call(obj, key)) { + obj[key] = []; + } + + obj[key].push(item); + + return obj; + }, {}); +} + +/** + * Compare two arrays by lexicographical order + */ +export function lessThan(xs: number[], ys: number[]): boolean { + for (let i = 0; i < Math.min(xs.length, ys.length); i++) { + if (xs[i] < ys[i]) return true; + if (xs[i] > ys[i]) return false; + } + return xs.length < ys.length; +} + +/** + * Returns the longest prefix of elements that satisfy the predicate + */ +export function takeWhile(f: Predicate, xs: T[]): T[] { + const ys = []; + for (const x of xs) { + if (f(x)) { + ys.push(x); + } else { + break; + } + } + return ys; +} + +export function cumulativeSum(xs: number[]): number[] { + const ys = Array.from(xs); // deep copy + for (let i = 1; i < ys.length; i++) ys[i] += ys[i - 1]; + return ys; +} + +export function toArray(x: T | T[] | undefined): T[] { + return Array.isArray(x) ? x : x != null ? [x] : []; +} + +export function toSingle(x: T | T[] | undefined): T | undefined { + return Array.isArray(x) ? x[0] : x; +} + +export function toSingleLast(x: T | T[] | undefined): T | undefined { + return Array.isArray(x) ? x.at(-1) : x; +} diff --git a/packages/backend/src/prelude/await-all.ts b/packages/backend/src/prelude/await-all.ts new file mode 100644 index 0000000..ce11eb8 --- /dev/null +++ b/packages/backend/src/prelude/await-all.ts @@ -0,0 +1,23 @@ +export type Promiseable = { + [K in keyof T]: Promise | T[K]; +}; + +export async function awaitAll(obj: Promiseable): Promise { + const target = {} as T; + const keys = Object.keys(obj) as unknown as (keyof T)[]; + const values = Object.values(obj) as any[]; + + const resolvedValues = await Promise.all( + values.map((value) => + !value?.constructor || value.constructor.name !== "Object" + ? value + : awaitAll(value), + ), + ); + + for (let i = 0; i < keys.length; i++) { + target[keys[i]] = resolvedValues[i]; + } + + return target; +} diff --git a/packages/backend/src/prelude/math.ts b/packages/backend/src/prelude/math.ts new file mode 100644 index 0000000..07b94be --- /dev/null +++ b/packages/backend/src/prelude/math.ts @@ -0,0 +1,3 @@ +export function gcd(a: number, b: number): number { + return b === 0 ? a : gcd(b, a % b); +} diff --git a/packages/backend/src/prelude/maybe.ts b/packages/backend/src/prelude/maybe.ts new file mode 100644 index 0000000..df7c4ed --- /dev/null +++ b/packages/backend/src/prelude/maybe.ts @@ -0,0 +1,20 @@ +export interface IMaybe { + isJust(): this is IJust; +} + +export interface IJust extends IMaybe { + get(): T; +} + +export function just(value: T): IJust { + return { + isJust: () => true, + get: () => value, + }; +} + +export function nothing(): IMaybe { + return { + isJust: () => false, + }; +} diff --git a/packages/backend/src/prelude/promise.ts b/packages/backend/src/prelude/promise.ts new file mode 100644 index 0000000..058ed97 --- /dev/null +++ b/packages/backend/src/prelude/promise.ts @@ -0,0 +1,5 @@ +// Returns T if promise settles before timeout, otherwise returns void, finishing execution in the background. +export async function promiseEarlyReturn(promise: Promise, after: number): Promise { + const timer: Promise = new Promise((res) => setTimeout(() => res(undefined), after)); + return Promise.race([promise, timer]); +} \ No newline at end of file diff --git a/packages/backend/src/prelude/relation.ts b/packages/backend/src/prelude/relation.ts new file mode 100644 index 0000000..1f4703f --- /dev/null +++ b/packages/backend/src/prelude/relation.ts @@ -0,0 +1,5 @@ +export type Predicate = (a: T) => boolean; + +export type Relation = (a: T, b: U) => boolean; + +export type EndoRelation = Relation; diff --git a/packages/backend/src/prelude/string.ts b/packages/backend/src/prelude/string.ts new file mode 100644 index 0000000..9588825 --- /dev/null +++ b/packages/backend/src/prelude/string.ts @@ -0,0 +1,15 @@ +export function concat(xs: string[]): string { + return xs.join(""); +} + +export function capitalize(s: string): string { + return toUpperCase(s.charAt(0)) + toLowerCase(s.slice(1)); +} + +export function toUpperCase(s: string): string { + return s.toUpperCase(); +} + +export function toLowerCase(s: string): string { + return s.toLowerCase(); +} diff --git a/packages/backend/src/prelude/symbol.ts b/packages/backend/src/prelude/symbol.ts new file mode 100644 index 0000000..5b88467 --- /dev/null +++ b/packages/backend/src/prelude/symbol.ts @@ -0,0 +1 @@ +export const fallback = Symbol("fallback"); diff --git a/packages/backend/src/prelude/time.ts b/packages/backend/src/prelude/time.ts new file mode 100644 index 0000000..5901b9c --- /dev/null +++ b/packages/backend/src/prelude/time.ts @@ -0,0 +1,54 @@ +const dateTimeIntervals = { + day: 86400000, + hour: 3600000, + ms: 1, +}; + +export function dateUTC(time: number[]): Date { + const d = + time.length === 2 + ? Date.UTC(time[0], time[1]) + : time.length === 3 + ? Date.UTC(time[0], time[1], time[2]) + : time.length === 4 + ? Date.UTC(time[0], time[1], time[2], time[3]) + : time.length === 5 + ? Date.UTC(time[0], time[1], time[2], time[3], time[4]) + : time.length === 6 + ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5]) + : time.length === 7 + ? Date.UTC(time[0], time[1], time[2], time[3], time[4], time[5], time[6]) + : null; + + if (!d) throw new Error("wrong number of arguments"); + + return new Date(d); +} + +export function isTimeSame(a: Date, b: Date): boolean { + return a.getTime() === b.getTime(); +} + +export function isTimeBefore(a: Date, b: Date): boolean { + return a.getTime() - b.getTime() < 0; +} + +export function isTimeAfter(a: Date, b: Date): boolean { + return a.getTime() - b.getTime() > 0; +} + +export function addTime( + x: Date, + value: number, + span: keyof typeof dateTimeIntervals = "ms", +): Date { + return new Date(x.getTime() + value * dateTimeIntervals[span]); +} + +export function subtractTime( + x: Date, + value: number, + span: keyof typeof dateTimeIntervals = "ms", +): Date { + return new Date(x.getTime() - value * dateTimeIntervals[span]); +} diff --git a/packages/backend/src/prelude/url.ts b/packages/backend/src/prelude/url.ts new file mode 100644 index 0000000..9e3f3f7 --- /dev/null +++ b/packages/backend/src/prelude/url.ts @@ -0,0 +1,15 @@ +export function query(obj: Record): string { + const params = Object.entries(obj) + .filter(([, v]) => (Array.isArray(v) ? v.length : v !== undefined)) + .reduce((a, [k, v]) => ((a[k] = v), a), {} as Record); + + return Object.entries(params) + .map((e) => `${e[0]}=${encodeURIComponent(e[1])}`) + .join("&"); +} + +export function appendQuery(url: string, query: string): string { + return `${url}${ + /\?/.test(url) ? (url.endsWith("?") ? "" : "&") : "?" + }${query}`; +} diff --git a/packages/backend/src/prelude/xml.ts b/packages/backend/src/prelude/xml.ts new file mode 100644 index 0000000..9dcc4c9 --- /dev/null +++ b/packages/backend/src/prelude/xml.ts @@ -0,0 +1,38 @@ +const map: Record = { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", +}; + +const beginingOfCDATA = ""; + +export function escapeValue(x: string): string { + let insideOfCDATA = false; + let builder = ""; + for (let i = 0; i < x.length; ) { + if (insideOfCDATA) { + if (x.slice(i, i + beginingOfCDATA.length) === beginingOfCDATA) { + insideOfCDATA = true; + i += beginingOfCDATA.length; + } else { + builder += x[i++]; + } + } else { + if (x.slice(i, i + endOfCDATA.length) === endOfCDATA) { + insideOfCDATA = false; + i += endOfCDATA.length; + } else { + const b = x[i++]; + builder += map[b] || b; + } + } + } + return builder; +} + +export function escapeAttribute(x: string): string { + return Object.entries(map).reduce((a, [k, v]) => a.replace(k, v), x); +} diff --git a/packages/backend/src/queue/index.ts b/packages/backend/src/queue/index.ts new file mode 100644 index 0000000..1ec02cb --- /dev/null +++ b/packages/backend/src/queue/index.ts @@ -0,0 +1,56 @@ +import { deliverQueue, inboxQueue } from "./queues.js"; +import { dbInit } from "./queues/db/index.js"; +import { deliverInit, deliverLogger } from "./queues/deliver.js"; +import { endedPollNotificationInit } from "./queues/ended-poll-notification.js"; +import { inboxInit, inboxLogger } from "./queues/inbox.js"; +import { objectStorageInit } from "./queues/object-storage/index.js"; +import { systemInit } from "./queues/system/index.js"; +import { webhookDeliverInit } from "./queues/webhook-deliver.js"; + +export { + createDeleteDriveFilesJob, + createExportCustomEmojisJob, + createExportNotesJob, + createExportFollowingJob, + createExportMuteJob, + createExportBlockingJob, + createExportUserListsJob, + createImportFollowingJob, + createImportMutingJob, + createImportBlockingJob, + createImportUserListsJob, + createImportCustomEmojisJob, + createDeleteAccountJob, +} from "./queues/db/index.js"; +export { + createDeleteObjectStorageFileJob, + createCleanRemoteFilesJob, +} from "./queues/object-storage/index.js"; +export { systemQueue } from "./queues/system/index.js"; +export { deliverJob as deliver, deliverQueue } from "./queues/deliver.js"; +export { endedPollNotificationQueue } from "./queues/ended-poll-notification.js"; +export { inboxJob as inbox, inboxQueue } from "./queues/inbox.js"; +export { webhookDeliverJob as webhookDeliver, webhookDeliverQueue } from "./queues/webhook-deliver.js"; + +export default async function () { + // initialize queue workers + await dbInit(); + await objectStorageInit(); + await systemInit(); + await deliverInit(); + await endedPollNotificationInit(); + await inboxInit(); + await webhookDeliverInit(); +}; + +export function destroy() { + deliverQueue.once("cleaned", (jobs, status) => { + deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); + }); + deliverQueue.clean(0, Infinity, "delayed"); + + inboxQueue.once("cleaned", (jobs, status) => { + inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`); + }); + inboxQueue.clean(0, Infinity, "delayed"); +} diff --git a/packages/backend/src/queue/logger.ts b/packages/backend/src/queue/logger.ts new file mode 100644 index 0000000..c55dd5e --- /dev/null +++ b/packages/backend/src/queue/logger.ts @@ -0,0 +1,11 @@ +import Logger from "@/services/logger.js"; + +export const queueLogger = new Logger("queue", "orange"); + +export function renderError(e: Error): any { + return { + stack: e.stack, + message: e.message, + name: e.name, + }; +} diff --git a/packages/backend/src/queue/queues.ts b/packages/backend/src/queue/queues.ts new file mode 100644 index 0000000..709db51 --- /dev/null +++ b/packages/backend/src/queue/queues.ts @@ -0,0 +1,25 @@ +import { dbQueue } from "./queues/db/index.js"; +import { deliverQueue } from "./queues/deliver.js"; +import { endedPollNotificationQueue } from "./queues/ended-poll-notification.js"; +import { inboxQueue } from "./queues/inbox.js"; +import { objectStorageQueue } from "./queues/object-storage/index.js"; +import { systemQueue } from "./queues/system/index.js"; +import { webhookDeliverQueue } from "./queues/webhook-deliver.js"; + +export { dbQueue } from "./queues/db/index.js"; +export { deliverQueue } from "./queues/deliver.js"; +export { endedPollNotificationQueue } from "./queues/ended-poll-notification.js"; +export { inboxQueue } from "./queues/inbox.js"; +export { objectStorageQueue } from "./queues/object-storage/index.js"; +export { systemQueue } from "./queues/system/index.js"; +export { webhookDeliverQueue } from "./queues/webhook-deliver.js"; + +export const queues = [ + dbQueue, + deliverQueue, + endedPollNotificationQueue, + inboxQueue, + objectStorageQueue, + systemQueue, + webhookDeliverQueue, +]; diff --git a/packages/backend/src/queue/queues/db/delete-account.ts b/packages/backend/src/queue/queues/db/delete-account.ts new file mode 100644 index 0000000..2c5b290 --- /dev/null +++ b/packages/backend/src/queue/queues/db/delete-account.ts @@ -0,0 +1,103 @@ +import { queueLogger } from "../../logger.js"; +import { DriveFiles, Notes, UserProfiles, Users } from "@/models/index.js"; +import type { DbUserDeleteJobData } from "@/queue/types.js"; +import type { Note } from "@/models/entities/note.js"; +import type { DriveFile } from "@/models/entities/drive-file.js"; +import { MoreThan } from "typeorm"; +import { deleteFileSync } from "@/services/drive/delete-file.js"; +import { sendEmail } from "@/services/send-email.js"; +import { publishInternalEvent } from "@/services/stream.js"; +import { Job } from "bullmq"; + +const logger = queueLogger.createSubLogger("delete-account"); + +export async function deleteAccount( + job: Job, +): Promise { + logger.info(`Deleting account of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (!user) return "skip: User not found"; + const isLocal = Users.isLocalUser(user); + + { + // Delete notes + let cursor: Note["id"] | null = null; + + while (true) { + const notes = (await Notes.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 10, + order: { + id: 1, + }, + })) as Note[]; + + if (notes.length === 0) { + break; + } + + cursor = notes[notes.length - 1].id; + + await Notes.delete(notes.map((note) => note.id)); + } + + logger.succ("All of notes deleted"); + } + + { + // Delete files + let cursor: DriveFile["id"] | null = null; + + while (true) { + const files = (await DriveFiles.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 10, + order: { + id: 1, + }, + })) as DriveFile[]; + + if (files.length === 0) { + break; + } + + cursor = files[files.length - 1].id; + + for (const file of files) { + await deleteFileSync(file); + } + } + + logger.succ("All of files deleted"); + } + + { + // Send email notification + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + if (profile.email && profile.emailVerified) { + sendEmail( + profile.email, + "Account deleted", + "Your account has been deleted.", + "Your account has been deleted.", + ); + } + } + + // soft指定されている場合は物理削除しない + if (job.data.soft) { + // nop + } else { + await Users.delete(job.data.user.id); + publishInternalEvent(isLocal ? "localUserDeleted" : "remoteUserDeleted", { id: user.id }); + } + + return "Account deleted"; +} diff --git a/packages/backend/src/queue/queues/db/delete-drive-files.ts b/packages/backend/src/queue/queues/db/delete-drive-files.ts new file mode 100644 index 0000000..ebccee0 --- /dev/null +++ b/packages/backend/src/queue/queues/db/delete-drive-files.ts @@ -0,0 +1,58 @@ +import { queueLogger } from "../../logger.js"; +import { deleteFileSync } from "@/services/drive/delete-file.js"; +import { Users, DriveFiles } from "@/models/index.js"; +import { MoreThan } from "typeorm"; +import type { DbUserJobData } from "@/queue/types.js"; +import { Job } from "bullmq"; + +const logger = queueLogger.createSubLogger("delete-drive-files"); + +export async function deleteDriveFiles( + job: Job, +): Promise { + logger.info(`Deleting drive files of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + return "skip: User not found"; + } + + let deletedCount = 0; + let cursor: any = null; + + while (true) { + const files = await DriveFiles.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }); + + if (files.length === 0) { + job.updateProgress(100); + break; + } + + cursor = files[files.length - 1].id; + + for (const file of files) { + await deleteFileSync(file); + deletedCount++; + } + + const total = await DriveFiles.countBy({ + userId: user.id, + }); + + job.updateProgress(deletedCount / total); + } + + logger.succ( + `All drive files (${deletedCount}) of ${user.id} has been deleted.`, + ); + return "Success"; +} diff --git a/packages/backend/src/queue/queues/db/export-blocking.ts b/packages/backend/src/queue/queues/db/export-blocking.ts new file mode 100644 index 0000000..fb5f6be --- /dev/null +++ b/packages/backend/src/queue/queues/db/export-blocking.ts @@ -0,0 +1,103 @@ +import * as fs from "node:fs"; + +import { queueLogger } from "../../logger.js"; +import { addFile } from "@/services/drive/add-file.js"; +import { format as dateFormat } from "date-fns"; +import { getFullApAccount } from "@/misc/convert-host.js"; +import { createTemp } from "@/misc/create-temp.js"; +import { Users, Blockings } from "@/models/index.js"; +import { MoreThan } from "typeorm"; +import type { DbUserJobData } from "@/queue/types.js"; +import { Job } from "bullmq"; + +const logger = queueLogger.createSubLogger("export-blocking"); + +export async function exportBlocking( + job: Job, +): Promise { + logger.info(`Exporting blocking of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + return "skip: User not found"; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + logger.info(`Temp file is ${path}`); + + try { + const stream = fs.createWriteStream(path, { flags: "a" }); + + let exportedCount = 0; + let cursor: any = null; + + while (true) { + const blockings = await Blockings.find({ + where: { + blockerId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }); + + if (blockings.length === 0) { + job.updateProgress(100); + break; + } + + cursor = blockings[blockings.length - 1].id; + + for (const block of blockings) { + const u = await Users.findOneBy({ id: block.blockeeId }); + if (u == null) { + exportedCount++; + continue; + } + + const content = getFullApAccount(u.username, u.host); + await new Promise((res, rej) => { + stream.write(content + "\n", (err) => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + exportedCount++; + } + + const total = await Blockings.countBy({ + blockerId: user.id, + }); + + job.updateProgress(exportedCount / total); + } + + stream.end(); + logger.succ(`Exported to: ${path}`); + + const fileName = `blocking-${dateFormat( + new Date(), + "yyyy-MM-dd-HH-mm-ss", + )}.csv`; + const driveFile = await addFile({ + user, + path, + name: fileName, + force: true, + }); + + logger.succ(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + + return "Success"; +} diff --git a/packages/backend/src/queue/queues/db/export-custom-emojis.ts b/packages/backend/src/queue/queues/db/export-custom-emojis.ts new file mode 100644 index 0000000..ba6dad4 --- /dev/null +++ b/packages/backend/src/queue/queues/db/export-custom-emojis.ts @@ -0,0 +1,137 @@ +import * as fs from "node:fs"; + +import mime from "mime-types"; +import archiver from "archiver"; +import { queueLogger } from "../../logger.js"; +import { addFile } from "@/services/drive/add-file.js"; +import { format as dateFormat } from "date-fns"; +import { Users, Emojis } from "@/models/index.js"; +import { DbUserJobData } from "../../types.js"; +import { createTemp, createTempDir } from "@/misc/create-temp.js"; +import { downloadUrl } from "@/misc/download-url.js"; +import config from "@/config/index.js"; +import { IsNull } from "typeorm"; +import { Job } from "bullmq"; + +const logger = queueLogger.createSubLogger("export-custom-emojis"); + +export async function exportCustomEmojis( + job: Job, +): Promise { + logger.info("Exporting custom emojis ..."); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + return "skip: User not found"; + } + + const [path, cleanup] = await createTempDir(); + + logger.info(`Temp dir is ${path}`); + + const metaPath = `${path}/meta.json`; + + fs.writeFileSync(metaPath, "", "utf-8"); + + const metaStream = fs.createWriteStream(metaPath, { flags: "a" }); + + const writeMeta = (text: string): Promise => { + return new Promise((res, rej) => { + metaStream.write(text, (err) => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + }; + + await writeMeta( + `{"metaVersion":2,"host":"${ + config.host + }","exportedAt":"${new Date().toString()}","emojis":[`, + ); + + const customEmojis = await Emojis.find({ + where: { + host: IsNull(), + }, + order: { + id: "ASC", + }, + }); + + for (const emoji of customEmojis) { + const ext = mime.extension(emoji.type); + // there are some restrictions on file names, so to be safe the files are + // named after their database id instead of the actual emoji name + const fileName = emoji.id + (ext ? '.' + ext : ''); + const emojiPath = `${path}/${fileName}`; + fs.writeFileSync(emojiPath, "", "binary"); + let downloaded = false; + + try { + await downloadUrl(emoji.originalUrl, emojiPath); + downloaded = true; + } catch (e) { + // TODO: 何度か再試行 + logger.error(e instanceof Error ? e : new Error(e as string)); + } + + if (!downloaded) { + fs.unlinkSync(emojiPath); + } + + const content = JSON.stringify({ + fileName: fileName, + downloaded: downloaded, + emoji: emoji, + }); + const isFirst = customEmojis.indexOf(emoji) === 0; + + await writeMeta(isFirst ? content : ",\n" + content); + } + + await writeMeta("]}"); + + metaStream.end(); + + // Create archive + await new Promise(async (resolve, reject) => { + try { + const [archivePath, archiveCleanup] = await createTemp(); + const archiveStream = fs.createWriteStream(archivePath); + const archive = archiver("zip", { + zlib: { level: 0 }, + }); + archiveStream.on("close", async () => { + logger.succ(`Exported to: ${archivePath}`); + + const fileName = `custom-emojis-${dateFormat( + new Date(), + "yyyy-MM-dd-HH-mm-ss", + )}.zip`; + const driveFile = await addFile({ + user, + path: archivePath, + name: fileName, + force: true, + }); + + logger.succ(`Exported to: ${driveFile.id}`); + cleanup(); + archiveCleanup(); + resolve(undefined); + }); + archive.pipe(archiveStream); + archive.directory(path, false); + archive.finalize(); + } catch (e) { + reject(e); + } + }); + + return "Success"; +} diff --git a/packages/backend/src/queue/queues/db/export-following.ts b/packages/backend/src/queue/queues/db/export-following.ts new file mode 100644 index 0000000..f921a98 --- /dev/null +++ b/packages/backend/src/queue/queues/db/export-following.ts @@ -0,0 +1,111 @@ +import * as fs from "node:fs"; + +import { queueLogger } from "../../logger.js"; +import { addFile } from "@/services/drive/add-file.js"; +import { format as dateFormat } from "date-fns"; +import { getFullApAccount } from "@/misc/convert-host.js"; +import { createTemp } from "@/misc/create-temp.js"; +import { Users, Followings, Mutings } from "@/models/index.js"; +import { In, MoreThan, Not } from "typeorm"; +import type { DbUserJobData } from "@/queue/types.js"; +import type { Following } from "@/models/entities/following.js"; +import { Job } from "bullmq"; + +const logger = queueLogger.createSubLogger("export-following"); + +export async function exportFollowing( + job: Job, +): Promise { + logger.info(`Exporting following of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + return "skip: User not found"; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + logger.info(`Temp file is ${path}`); + + try { + const stream = fs.createWriteStream(path, { flags: "a" }); + + let cursor: Following["id"] | null = null; + + const mutings = job.data.excludeMuting + ? await Mutings.findBy({ + muterId: user.id, + }) + : []; + + while (true) { + const followings = (await Followings.find({ + where: { + followerId: user.id, + ...(mutings.length > 0 + ? { followeeId: Not(In(mutings.map((x) => x.muteeId))) } + : {}), + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + })) as Following[]; + + if (followings.length === 0) { + break; + } + + cursor = followings[followings.length - 1].id; + + for (const following of followings) { + const u = await Users.findOneBy({ id: following.followeeId }); + if (u == null) { + continue; + } + + if ( + job.data.excludeInactive && + u.updatedAt && + Date.now() - u.updatedAt.getTime() > 1000 * 60 * 60 * 24 * 90 + ) { + continue; + } + + const content = getFullApAccount(u.username, u.host); + await new Promise((res, rej) => { + stream.write(content + "\n", (err) => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + } + } + + stream.end(); + logger.succ(`Exported to: ${path}`); + + const fileName = `following-${dateFormat( + new Date(), + "yyyy-MM-dd-HH-mm-ss", + )}.csv`; + const driveFile = await addFile({ + user, + path, + name: fileName, + force: true, + }); + + logger.succ(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + + return "Success"; +} diff --git a/packages/backend/src/queue/queues/db/export-mute.ts b/packages/backend/src/queue/queues/db/export-mute.ts new file mode 100644 index 0000000..53cc660 --- /dev/null +++ b/packages/backend/src/queue/queues/db/export-mute.ts @@ -0,0 +1,104 @@ +import * as fs from "node:fs"; + +import { queueLogger } from "../../logger.js"; +import { addFile } from "@/services/drive/add-file.js"; +import { format as dateFormat } from "date-fns"; +import { getFullApAccount } from "@/misc/convert-host.js"; +import { createTemp } from "@/misc/create-temp.js"; +import { Users, Mutings } from "@/models/index.js"; +import { IsNull, MoreThan } from "typeorm"; +import type { DbUserJobData } from "@/queue/types.js"; +import { Job } from "bullmq"; + +const logger = queueLogger.createSubLogger("export-mute"); + +export async function exportMute( + job: Job, +): Promise { + logger.info(`Exporting mute of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + return "skip: User not found"; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + logger.info(`Temp file is ${path}`); + + try { + const stream = fs.createWriteStream(path, { flags: "a" }); + + let exportedCount = 0; + let cursor: any = null; + + while (true) { + const mutes = await Mutings.find({ + where: { + muterId: user.id, + expiresAt: IsNull(), + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + }); + + if (mutes.length === 0) { + job.updateProgress(100); + break; + } + + cursor = mutes[mutes.length - 1].id; + + for (const mute of mutes) { + const u = await Users.findOneBy({ id: mute.muteeId }); + if (u == null) { + exportedCount++; + continue; + } + + const content = getFullApAccount(u.username, u.host); + await new Promise((res, rej) => { + stream.write(content + "\n", (err) => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + exportedCount++; + } + + const total = await Mutings.countBy({ + muterId: user.id, + }); + + job.updateProgress(exportedCount / total); + } + + stream.end(); + logger.succ(`Exported to: ${path}`); + + const fileName = `mute-${dateFormat( + new Date(), + "yyyy-MM-dd-HH-mm-ss", + )}.csv`; + const driveFile = await addFile({ + user, + path, + name: fileName, + force: true, + }); + + logger.succ(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + + return "Success"; +} diff --git a/packages/backend/src/queue/queues/db/export-notes.ts b/packages/backend/src/queue/queues/db/export-notes.ts new file mode 100644 index 0000000..9a2cc96 --- /dev/null +++ b/packages/backend/src/queue/queues/db/export-notes.ts @@ -0,0 +1,131 @@ +import * as fs from "node:fs"; + +import { queueLogger } from "../../logger.js"; +import { addFile } from "@/services/drive/add-file.js"; +import { format as dateFormat } from "date-fns"; +import { Users, Notes, Polls, DriveFiles } from "@/models/index.js"; +import { MoreThan } from "typeorm"; +import type { Note } from "@/models/entities/note.js"; +import type { Poll } from "@/models/entities/poll.js"; +import type { DbUserJobData } from "@/queue/types.js"; +import { createTemp } from "@/misc/create-temp.js"; +import { Job } from "bullmq"; + +const logger = queueLogger.createSubLogger("export-notes"); + +export async function exportNotes( + job: Job, +): Promise { + logger.info(`Exporting notes of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + return "skip: User not found"; + } + + // Create temp file + const [path, cleanup] = await createTemp(); + + logger.info(`Temp file is ${path}`); + + try { + const stream = fs.createWriteStream(path, { flags: "a" }); + + const write = (text: string): Promise => { + return new Promise((res, rej) => { + stream.write(text, (err) => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + }; + + await write("["); + + let exportedNotesCount = 0; + let cursor: Note["id"] | null = null; + + while (true) { + const notes = (await Notes.find({ + where: { + userId: user.id, + ...(cursor ? { id: MoreThan(cursor) } : {}), + }, + take: 100, + order: { + id: 1, + }, + })) as Note[]; + + if (notes.length === 0) { + job.updateProgress(100); + break; + } + + cursor = notes[notes.length - 1].id; + + for (const note of notes) { + let poll: Poll | undefined; + if (note.hasPoll) { + poll = await Polls.findOneByOrFail({ noteId: note.id }); + } + const content = JSON.stringify(await serialize(note, poll)); + const isFirst = exportedNotesCount === 0; + await write(isFirst ? content : ",\n" + content); + exportedNotesCount++; + } + + const total = await Notes.countBy({ + userId: user.id, + }); + + job.updateProgress(exportedNotesCount / total); + } + + await write("]"); + + stream.end(); + logger.succ(`Exported to: ${path}`); + + const fileName = `notes-${dateFormat( + new Date(), + "yyyy-MM-dd-HH-mm-ss", + )}.json`; + const driveFile = await addFile({ + user, + path, + name: fileName, + force: true, + }); + + logger.succ(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + + return "Success"; +} + +async function serialize( + note: Note, + poll: Poll | null = null, +): Promise> { + return { + id: note.id, + text: note.text, + createdAt: note.createdAt, + fileIds: note.fileIds, + files: await DriveFiles.packMany(note.fileIds), + replyId: note.replyId, + renoteId: note.renoteId, + poll: poll, + cw: note.cw, + visibility: note.visibility, + visibleUserIds: note.visibleUserIds, + localOnly: note.localOnly, + }; +} diff --git a/packages/backend/src/queue/queues/db/export-user-lists.ts b/packages/backend/src/queue/queues/db/export-user-lists.ts new file mode 100644 index 0000000..648295a --- /dev/null +++ b/packages/backend/src/queue/queues/db/export-user-lists.ts @@ -0,0 +1,79 @@ +import * as fs from "node:fs"; + +import { queueLogger } from "../../logger.js"; +import { addFile } from "@/services/drive/add-file.js"; +import { format as dateFormat } from "date-fns"; +import { getFullApAccount } from "@/misc/convert-host.js"; +import { createTemp } from "@/misc/create-temp.js"; +import { Users, UserLists, UserListJoinings } from "@/models/index.js"; +import { In } from "typeorm"; +import type { DbUserJobData } from "@/queue/types.js"; +import { Job } from "bullmq"; + +const logger = queueLogger.createSubLogger("export-user-lists"); + +export async function exportUserLists( + job: Job, +): Promise { + logger.info(`Exporting user lists of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + return "skip: User not found"; + } + + const lists = await UserLists.findBy({ + userId: user.id, + }); + + // Create temp file + const [path, cleanup] = await createTemp(); + + logger.info(`Temp file is ${path}`); + + try { + const stream = fs.createWriteStream(path, { flags: "a" }); + + for (const list of lists) { + const joinings = await UserListJoinings.findBy({ userListId: list.id }); + const users = await Users.findBy({ + id: In(joinings.map((j) => j.userId)), + }); + + for (const u of users) { + const acct = getFullApAccount(u.username, u.host); + const content = `${list.name},${acct}`; + await new Promise((res, rej) => { + stream.write(content + "\n", (err) => { + if (err) { + logger.error(err); + rej(err); + } else { + res(); + } + }); + }); + } + } + + stream.end(); + logger.succ(`Exported to: ${path}`); + + const fileName = `user-lists-${dateFormat( + new Date(), + "yyyy-MM-dd-HH-mm-ss", + )}.csv`; + const driveFile = await addFile({ + user, + path, + name: fileName, + force: true, + }); + + logger.succ(`Exported to: ${driveFile.id}`); + } finally { + cleanup(); + } + + return "Success"; +} diff --git a/packages/backend/src/queue/queues/db/import-blocking.ts b/packages/backend/src/queue/queues/db/import-blocking.ts new file mode 100644 index 0000000..2c480bd --- /dev/null +++ b/packages/backend/src/queue/queues/db/import-blocking.ts @@ -0,0 +1,75 @@ +import { queueLogger } from "../../logger.js"; +import * as Acct from "@/misc/acct.js"; +import { resolveUser } from "@/remote/resolve-user.js"; +import { downloadTextFile } from "@/misc/download-text-file.js"; +import { isSelfHost, toPuny } from "@/misc/convert-host.js"; +import { Users, DriveFiles, Blockings } from "@/models/index.js"; +import type { DbUserImportJobData } from "@/queue/types.js"; +import block from "@/services/blocking/create.js"; +import { IsNull } from "typeorm"; +import { Job } from "bullmq"; + +const logger = queueLogger.createSubLogger("import-blocking"); + +export async function importBlocking( + job: Job, +): Promise { + logger.info(`Importing blocking of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + return "skip: User not found"; + } + + const file = await DriveFiles.findOneBy({ + id: job.data.fileId, + }); + if (file == null) { + return "skip: File not found"; + } + + const csv = await downloadTextFile(file.url); + + let linenum = 0; + + for (const line of csv.trim().split("\n")) { + linenum++; + + try { + const acct = line.split(",")[0].trim(); + const { username, host } = Acct.parse(acct); + + let target = isSelfHost(host!) + ? await Users.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) + : await Users.findOneBy({ + host: toPuny(host!), + usernameLower: username.toLowerCase(), + }); + + if (host == null && target == null) continue; + + if (target == null) { + target = await resolveUser(username, host); + } + + if (target == null) { + throw new Error(`cannot resolve user: @${username}@${host}`); + } + + // skip myself + if (target.id === job.data.user.id) continue; + + logger.info(`Block[${linenum}] ${target.id} ...`); + + await block(user, target); + } catch (e) { + logger.warn(`Error in line:${linenum} ${e}`); + } + } + + logger.succ("Imported"); + return "Success"; +} diff --git a/packages/backend/src/queue/queues/db/import-custom-emojis.ts b/packages/backend/src/queue/queues/db/import-custom-emojis.ts new file mode 100644 index 0000000..29bc5a0 --- /dev/null +++ b/packages/backend/src/queue/queues/db/import-custom-emojis.ts @@ -0,0 +1,155 @@ +import * as fs from "node:fs"; +import AdmZip from "adm-zip"; + +import { queueLogger } from "../../logger.js"; +import { createTempDir } from "@/misc/create-temp.js"; +import { downloadUrl } from "@/misc/download-url.js"; +import { DriveFiles, Emojis } from "@/models/index.js"; +import type { DbUserImportJobData } from "@/queue/types.js"; +import { addFile } from "@/services/drive/add-file.js"; +import { genId } from "@/misc/gen-id.js"; +import { db } from "@/db/postgre.js"; +import probeImageSize from "probe-image-size"; +import * as path from "path"; +import { Job } from "bullmq"; + +const logger = queueLogger.createSubLogger("import-custom-emojis"); + +// TODO: 名前衝突時の動作を選べるようにする +export async function importCustomEmojis( + job: Job, +): Promise { + logger.info("Importing custom emojis ..."); + + const file = await DriveFiles.findOneBy({ + id: job.data.fileId, + }); + if (file == null) { + return "skip: File not found"; + } + + const [tempPath, cleanup] = await createTempDir(); + + logger.info(`Temp dir is ${tempPath}`); + + const destPath = `${tempPath}/emojis.zip`; + + try { + fs.writeFileSync(destPath, "", "binary"); + await downloadUrl(file.url, destPath); + } catch (e) { + // TODO: 何度か再試行 + if (e instanceof Error || typeof e === "string") { + logger.error(e); + } + throw e; + } + + const outputPath = `${tempPath}/emojis`; + const unzipStream = fs.createReadStream(destPath); + const zip = new AdmZip(destPath); + logger.succ(`Unzipping to ${outputPath}`); + + await new Promise((resolve, reject) => { + zip.extractAllToAsync(outputPath, true, false, async (error) => { + if (error) reject(error); + + if (fs.existsSync(`${outputPath}/meta.json`)) { + logger.info("starting emoji import with metadata"); + const metaRaw = fs.readFileSync(`${outputPath}/meta.json`, "utf-8"); + const meta = JSON.parse(metaRaw); + + for (const record of meta.emojis) { + if (!record.downloaded) continue; + const emojiInfo = record.emoji; + const emojiPath = `${outputPath}/${record.fileName}`; + await Emojis.delete({ + name: emojiInfo.name, + }); + const driveFile = await addFile({ + user: null, + path: emojiPath, + name: record.fileName, + force: true, + }); + const file = fs.createReadStream(emojiPath); + const size = await probeImageSize(file); + file.destroy(); + await Emojis.insert({ + id: genId(), + updatedAt: new Date(), + name: emojiInfo.name, + category: emojiInfo.category, + host: null, + aliases: emojiInfo.aliases, + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + type: driveFile.webpublicType ?? driveFile.type, + license: emojiInfo.license, + glyph: driveFile.type === "image/svg+xml", + width: size.width || null, + height: size.height || null, + }).then((x) => Emojis.findOneByOrFail(x.identifiers[0])); + } + } else { + logger.info("starting emoji import without metadata"); + // Since we lack metadata, we import into a randomized category name instead + let categoryName = genId(); + + let containedEmojis = fs.readdirSync(outputPath); + + // Filter out accidental JSON files + containedEmojis = containedEmojis.filter( + (emoji) => !emoji.match(/\.(json)$/i), + ); + + for (const emojiFilename of containedEmojis) { + // strip extension and get filename to use as name + const name = path.basename(emojiFilename, path.extname(emojiFilename)); + const emojiPath = `${outputPath}/${emojiFilename}`; + + logger.info(`importing ${name}`); + + await Emojis.delete({ + name: name, + }); + const driveFile = await addFile({ + user: null, + path: emojiPath, + name: path.basename(emojiFilename), + force: true, + }); + const file = fs.createReadStream(emojiPath); + const size = await probeImageSize(file); + file.destroy(); + logger.info(`emoji size: ${size.width}x${size.height}`); + + await Emojis.insert({ + id: genId(), + updatedAt: new Date(), + name: name, + category: categoryName, + host: null, + aliases: [], + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + type: driveFile.webpublicType ?? driveFile.type, + license: null, + glyph: driveFile.type === "image/svg+xml", + width: size.width || null, + height: size.height || null, + }).then((x) => Emojis.findOneByOrFail(x.identifiers[0])); + } + } + + await db.queryResultCache!.remove(["meta_emojis"]); + + cleanup(); + + logger.succ("Imported"); + resolve(undefined); + }); + }); + + return "Success"; +} diff --git a/packages/backend/src/queue/queues/db/import-following.ts b/packages/backend/src/queue/queues/db/import-following.ts new file mode 100644 index 0000000..88020e0 --- /dev/null +++ b/packages/backend/src/queue/queues/db/import-following.ts @@ -0,0 +1,115 @@ +import { IsNull } from "typeorm"; +import follow from "@/services/following/create.js"; + +import * as Acct from "@/misc/acct.js"; +import { resolveUser } from "@/remote/resolve-user.js"; +import { downloadTextFile } from "@/misc/download-text-file.js"; +import { isSelfHost, toPuny } from "@/misc/convert-host.js"; +import { Users, DriveFiles } from "@/models/index.js"; +import type { DbUserImportJobData } from "@/queue/types.js"; +import { queueLogger } from "../../logger.js"; +import { cache as heuristic } from "@/server/api/common/generate-following-query.js"; +import { Job } from "bullmq"; + +const logger = queueLogger.createSubLogger("import-following"); + +export async function importFollowing( + job: Job, +): Promise { + logger.info(`Importing following of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + return "skip: User not found"; + } + + const file = await DriveFiles.findOneBy({ + id: job.data.fileId, + }); + if (file == null) { + return "skip: File not found"; + } + + const csv = await downloadTextFile(file.url); + + let linenum = 0; + + if (file.type.endsWith("json")) { + for (const acct of JSON.parse(csv)) { + try { + const { username, host } = Acct.parse(acct); + + let target = isSelfHost(host!) + ? await Users.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) + : await Users.findOneBy({ + host: toPuny(host!), + usernameLower: username.toLowerCase(), + }); + + if (host == null && target == null) continue; + + if (target == null) { + target = await resolveUser(username, host); + } + + if (target == null) { + throw new Error(`cannot resolve user: @${username}@${host}`); + } + + // skip myself + if (target.id === job.data.user.id) continue; + + logger.info(`Follow[${linenum}] ${target.id} ...`); + + follow(user, target); + } catch (e) { + logger.warn(`Error in line:${linenum} ${e}`); + } + } + } else { + for (const line of csv.trim().split("\n")) { + linenum++; + + try { + const acct = line.split(",")[0].trim(); + const { username, host } = Acct.parse(acct); + + let target = isSelfHost(host!) + ? await Users.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) + : await Users.findOneBy({ + host: toPuny(host!), + usernameLower: username.toLowerCase(), + }); + + if (host == null && target == null) continue; + + if (target == null) { + target = await resolveUser(username, host); + } + + if (target == null) { + throw new Error(`cannot resolve user: @${username}@${host}`); + } + + // skip myself + if (target.id === job.data.user.id) continue; + + logger.info(`Follow[${linenum}] ${target.id} ...`); + + follow(user, target); + } catch (e) { + logger.warn(`Error in line:${linenum} ${e}`); + } + } + } + + await heuristic.delete(user.id); + logger.succ("Imported"); + return "Success"; +} diff --git a/packages/backend/src/queue/queues/db/import-muting.ts b/packages/backend/src/queue/queues/db/import-muting.ts new file mode 100644 index 0000000..a2aebb7 --- /dev/null +++ b/packages/backend/src/queue/queues/db/import-muting.ts @@ -0,0 +1,85 @@ +import { queueLogger } from "../../logger.js"; +import * as Acct from "@/misc/acct.js"; +import { resolveUser } from "@/remote/resolve-user.js"; +import { downloadTextFile } from "@/misc/download-text-file.js"; +import { isSelfHost, toPuny } from "@/misc/convert-host.js"; +import { Users, DriveFiles, Mutings } from "@/models/index.js"; +import type { DbUserImportJobData } from "@/queue/types.js"; +import type { User } from "@/models/entities/user.js"; +import { genId } from "@/misc/gen-id.js"; +import { IsNull } from "typeorm"; +import { Job } from "bullmq"; + +const logger = queueLogger.createSubLogger("import-muting"); + +export async function importMuting( + job: Job, +): Promise { + logger.info(`Importing muting of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + return "skip: User not found"; + } + + const file = await DriveFiles.findOneBy({ + id: job.data.fileId, + }); + if (file == null) { + return "skip: File not found"; + } + + const csv = await downloadTextFile(file.url); + + let linenum = 0; + + for (const line of csv.trim().split("\n")) { + linenum++; + + try { + const acct = line.split(",")[0].trim(); + const { username, host } = Acct.parse(acct); + + let target = isSelfHost(host!) + ? await Users.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) + : await Users.findOneBy({ + host: toPuny(host!), + usernameLower: username.toLowerCase(), + }); + + if (host == null && target == null) continue; + + if (target == null) { + target = await resolveUser(username, host); + } + + if (target == null) { + throw new Error(`cannot resolve user: @${username}@${host}`); + } + + // skip myself + if (target.id === job.data.user.id) continue; + + logger.info(`Mute[${linenum}] ${target.id} ...`); + + await mute(user, target); + } catch (e) { + logger.warn(`Error in line:${linenum} ${e}`); + } + } + + logger.succ("Imported"); + return "Success"; +} + +async function mute(user: User, target: User) { + await Mutings.insert({ + id: genId(), + createdAt: new Date(), + muterId: user.id, + muteeId: target.id, + }); +} diff --git a/packages/backend/src/queue/queues/db/import-user-lists.ts b/packages/backend/src/queue/queues/db/import-user-lists.ts new file mode 100644 index 0000000..75652d9 --- /dev/null +++ b/packages/backend/src/queue/queues/db/import-user-lists.ts @@ -0,0 +1,107 @@ +import { queueLogger } from "../../logger.js"; +import * as Acct from "@/misc/acct.js"; +import { resolveUser } from "@/remote/resolve-user.js"; +import { pushUserToUserList } from "@/services/user-list/push.js"; +import { downloadTextFile } from "@/misc/download-text-file.js"; +import { isSelfHost, toPuny } from "@/misc/convert-host.js"; +import { + DriveFiles, + Users, + UserLists, + UserListJoinings, Blockings, Followings, +} from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import type { DbUserImportJobData } from "@/queue/types.js"; +import { IsNull } from "typeorm"; +import { Job } from "bullmq"; + +const logger = queueLogger.createSubLogger("import-user-lists"); + +export async function importUserLists( + job: Job, +): Promise { + logger.info(`Importing user lists of ${job.data.user.id} ...`); + + const user = await Users.findOneBy({ id: job.data.user.id }); + if (user == null) { + return "skip: User not found"; + } + + const file = await DriveFiles.findOneBy({ + id: job.data.fileId, + }); + if (file == null) { + return "skip: File not found"; + } + + const csv = await downloadTextFile(file.url); + + let linenum = 0; + + for (const line of csv.trim().split("\n")) { + linenum++; + + try { + const listName = line.split(",")[0].trim(); + const { username, host } = Acct.parse(line.split(",")[1].trim()); + + let list = await UserLists.findOneBy({ + userId: user.id, + name: listName, + }); + + if (list == null) { + list = await UserLists.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: listName, + }).then((x) => UserLists.findOneByOrFail(x.identifiers[0])); + } + + let target = isSelfHost(host!) + ? await Users.findOneBy({ + host: IsNull(), + usernameLower: username.toLowerCase(), + }) + : await Users.findOneBy({ + host: toPuny(host!), + usernameLower: username.toLowerCase(), + }); + + if (target == null) { + target = await resolveUser(username, host); + } + + const isBlocked = await Blockings.exist({ + where: { + blockerId: target.id, + blockeeId: user.id, + }, + }); + const isFollowed = await Followings.exist({ + where: { + followerId: user.id, + followeeId: target.id, + }, + }); + + if (isBlocked || !isFollowed) continue; + + if ( + (await UserListJoinings.findOneBy({ + userListId: list!.id, + userId: target.id, + })) != null + ) + continue; + + pushUserToUserList(target, list!); + } catch (e) { + logger.warn(`Error in line:${linenum} ${e}`); + } + } + + logger.succ("Imported"); + return "Success"; +} diff --git a/packages/backend/src/queue/queues/db/index.ts b/packages/backend/src/queue/queues/db/index.ts new file mode 100644 index 0000000..b7caff5 --- /dev/null +++ b/packages/backend/src/queue/queues/db/index.ts @@ -0,0 +1,235 @@ +import { Job, Processor } from "bullmq"; +import { deleteAccount } from "./delete-account.js"; +import { deleteDriveFiles } from "./delete-drive-files.js"; +import { exportBlocking } from "./export-blocking.js"; +import { exportCustomEmojis } from "./export-custom-emojis.js"; +import { exportFollowing } from "./export-following.js"; +import { exportMute } from "./export-mute.js"; +import { exportNotes } from "./export-notes.js"; +import { exportUserLists } from "./export-user-lists.js"; +import { importBlocking } from "./import-blocking.js"; +import { importCustomEmojis } from "./import-custom-emojis.js"; +import { importFollowing } from "./import-following.js"; +import { importMuting } from "./import-muting.js"; +import { importUserLists } from "./import-user-lists.js"; +import { createQueue, defaultJobOpts } from "../index.js"; +import { ThinUser } from "@/queue/types.js"; +import { DriveFile } from "@/models/entities/drive-file.js"; +import config from "@/config/index.js"; + +const processors = { + deleteAccount, + deleteDriveFiles, + exportBlocking, + exportCustomEmojis, + exportFollowing, + exportMute, + exportNotes, + exportUserLists, + importBlocking, + importCustomEmojis, + importFollowing, + importMuting, + importUserLists, +} as Record; + +async function process(job: Job): Promise { + const processor = processors[job.name]; + if (processor === undefined) return "skip: unknown job name"; + return await processor(job); +} + +export const [dbQueue, dbInit] = + createQueue("db", process, { limitPerSec: 256, concurrency: 16 }); + +export function createDeleteDriveFilesJob(user: ThinUser) { + return dbQueue.add( + "deleteDriveFiles", + { + user: user, + }, + defaultJobOpts, + ); +} + +export function createExportCustomEmojisJob(user: ThinUser) { + return dbQueue.add( + "exportCustomEmojis", + { + user: user, + }, + defaultJobOpts, + ); +} + +export function createExportNotesJob(user: ThinUser) { + return dbQueue.add( + "exportNotes", + { + user: user, + }, + defaultJobOpts, + ); +} + +export function createExportFollowingJob( + user: ThinUser, + excludeMuting = false, + excludeInactive = false, +) { + return dbQueue.add( + "exportFollowing", + { + user: user, + excludeMuting, + excludeInactive, + }, + defaultJobOpts, + ); +} + +export function createExportMuteJob(user: ThinUser) { + return dbQueue.add( + "exportMute", + { + user: user, + }, + defaultJobOpts, + ); +} + +export function createExportBlockingJob(user: ThinUser) { + return dbQueue.add( + "exportBlocking", + { + user: user, + }, + defaultJobOpts, + ); +} + +export function createExportUserListsJob(user: ThinUser) { + return dbQueue.add( + "exportUserLists", + { + user: user, + }, + defaultJobOpts, + ); +} + +export function createImportFollowingJob( + user: ThinUser, + fileId: DriveFile["id"], +) { + return dbQueue.add( + "importFollowing", + { + user: user, + fileId: fileId, + }, + defaultJobOpts, + ); +} + +export function createImportMastoPostJob( + user: ThinUser, + post: any, + signatureCheck: boolean, +) { + return dbQueue.add( + "importMastoPost", + { + user: user, + post: post, + signatureCheck: signatureCheck, + }, + { + attempts: config.inboxJobMaxAttempts || 8, + ...defaultJobOpts, + }, + ); +} + +export function createImportCkPostJob( + user: ThinUser, + post: any, + signatureCheck: boolean, +) { + return dbQueue.add( + "importCkPost", + { + user: user, + post: post, + signatureCheck: signatureCheck, + }, + defaultJobOpts, + ); +} + +export function createImportMutingJob(user: ThinUser, fileId: DriveFile["id"]) { + return dbQueue.add( + "importMuting", + { + user: user, + fileId: fileId, + }, + defaultJobOpts, + ); +} + +export function createImportBlockingJob( + user: ThinUser, + fileId: DriveFile["id"], +) { + return dbQueue.add( + "importBlocking", + { + user: user, + fileId: fileId, + }, + defaultJobOpts, + ); +} + +export function createImportUserListsJob( + user: ThinUser, + fileId: DriveFile["id"], +) { + return dbQueue.add( + "importUserLists", + { + user: user, + fileId: fileId, + }, + defaultJobOpts, + ); +} + +export function createImportCustomEmojisJob( + user: ThinUser, + fileId: DriveFile["id"], +) { + return dbQueue.add( + "importCustomEmojis", + { + user: user, + fileId: fileId, + }, + defaultJobOpts, + ); +} + +export function createDeleteAccountJob( + user: ThinUser, + opts: { soft?: boolean } = {}, +) { + return dbQueue.add( + "deleteAccount", + { + user: user, + soft: opts.soft, + }, + defaultJobOpts, + ); +} diff --git a/packages/backend/src/queue/queues/deliver.ts b/packages/backend/src/queue/queues/deliver.ts new file mode 100644 index 0000000..9cc7753 --- /dev/null +++ b/packages/backend/src/queue/queues/deliver.ts @@ -0,0 +1,121 @@ +import { URL } from "node:url"; +import request from "@/remote/activitypub/request.js"; +import { registerOrFetchInstanceDoc } from "@/services/register-or-fetch-instance-doc.js"; +import Logger from "@/services/logger.js"; +import { Instances } from "@/models/index.js"; +import { + apRequestChart, + federationChart, + instanceChart, +} from "@/services/chart/index.js"; +import { fetchInstanceMetadata } from "@/services/fetch-instance-metadata.js"; +import { toPuny } from "@/misc/convert-host.js"; +import { StatusError } from "@/misc/fetch.js"; +import { shouldSkipInstance } from "@/misc/skipped-instances.js"; +import type { DeliverJobData } from "../types.js"; +import config from "@/config/index.js"; +import { createQueue, defaultJobOpts, processorTimeout } from "./index.js"; +import { ThinUser } from "../types.js"; +import { Job } from "bullmq"; +import { tickOutbox } from "@/metrics.js"; + +export const deliverLogger = new Logger("deliver"); + +let latest: string | null = null; + +async function process(job: Job) { + if (job.data == null || Object.keys(job.data).length === 0) { + job.opts.removeOnComplete = true; + return "Skip (data was null or empty)"; + } + const { host } = new URL(job.data.to); + const puny = toPuny(host); + + if (await shouldSkipInstance(puny)) return "skip"; + + try { + if (latest !== (latest = JSON.stringify(job.data.content, null, 2))) { + deliverLogger.debug(`delivering ${latest}`); + } + + await request(job.data.user, job.data.to, job.data.content); + + // Update stats + registerOrFetchInstanceDoc(host).then((i) => { + Instances.update(i.id, { + latestRequestSentAt: new Date(), + latestStatus: 200, + lastCommunicatedAt: new Date(), + isNotResponding: false, + }); + + fetchInstanceMetadata(i); + + instanceChart.requestSent(i.host, true); + apRequestChart.deliverSucc(); + federationChart.deliverd(i.host, true); + }); + + tickOutbox(); + + return "Success"; + } catch (res) { + // Update stats + registerOrFetchInstanceDoc(host).then((i) => { + Instances.update(i.id, { + latestRequestSentAt: new Date(), + latestStatus: res instanceof StatusError ? res.statusCode : null, + isNotResponding: true, + }); + + instanceChart.requestSent(i.host, false); + apRequestChart.deliverFail(); + federationChart.deliverd(i.host, false); + }); + + if (res instanceof StatusError) { + // 4xx + if (!res.isRetryable) { + // HTTPステータスコード4xxはクライアントエラーであり、それはつまり + // 何回再送しても成功することはないということなのでエラーにはしないでおく + return `${res.statusCode} ${res.statusMessage}`; + } + + // 5xx etc. + throw new Error(`${res.statusCode} ${res.statusMessage}`); + } else { + // DNS error, socket error, timeout ... + throw res; + } + } +}; + +export const [deliverQueue, deliverInit] = createQueue( + "deliver", + processorTimeout(process, 60), + { + limitPerSec: config.deliverJobPerSec || 128, + concurrency: config.deliverJobConcurrency || 128, + }, +); + +export function deliverJob(user: ThinUser, content: unknown, to: string | null) { + if (content == null) return null; + if (to == null) return null; + + const data = { + user: { + id: user.id, + }, + content, + to, + }; + + return deliverQueue.add("default", data, { + attempts: config.deliverJobMaxAttempts || 12, + backoff: { + type: "custom", + }, + ...defaultJobOpts, + }); +} diff --git a/packages/backend/src/queue/queues/ended-poll-notification.ts b/packages/backend/src/queue/queues/ended-poll-notification.ts new file mode 100644 index 0000000..5f56100 --- /dev/null +++ b/packages/backend/src/queue/queues/ended-poll-notification.ts @@ -0,0 +1,41 @@ +import { Notes, PollVotes } from "@/models/index.js"; +import type { EndedPollNotificationJobData } from "@/queue/types.js"; +import { createNotification } from "@/services/create-notification.js"; +import { deliverQuestionUpdate } from "@/services/note/polls/update.js"; +import { Job } from "bullmq"; +import { createQueue } from "./index.js"; + +async function process(job: Job): Promise { + if (job.data == null || Object.keys(job.data).length === 0) { + job.opts.removeOnComplete = true; + return "skip: corrupt job"; + } + const note = await Notes.findOneBy({ id: job.data.noteId }); + if (note == null || !note.hasPoll) { + return "skip: note not found"; + } + + const votes = await PollVotes.createQueryBuilder("vote") + .select("vote.userId") + .where("vote.noteId = :noteId", { noteId: note.id }) + .innerJoinAndSelect("vote.user", "user") + .andWhere("user.host IS NULL") + .getMany(); + + const userIds = [...new Set([note.userId, ...votes.map((v) => v.userId)])]; + + for (const userId of userIds) { + createNotification(userId, "pollEnded", { + note: note, + noteId: note.id, + }); + } + + // Broadcast the poll result once it ends + if (!note.localOnly) await deliverQuestionUpdate(note.id); + + return "complete" +} + +export const [endedPollNotificationQueue, endedPollNotificationInit] = + createQueue("endedPollNotification", process); diff --git a/packages/backend/src/queue/queues/inbox.ts b/packages/backend/src/queue/queues/inbox.ts new file mode 100644 index 0000000..09278d3 --- /dev/null +++ b/packages/backend/src/queue/queues/inbox.ts @@ -0,0 +1,266 @@ +import { URL } from "node:url"; +import httpSignature from "@peertube/http-signature"; +import perform from "@/remote/activitypub/perform.js"; +import Logger from "@/services/logger.js"; +import { registerOrFetchInstanceDoc } from "@/services/register-or-fetch-instance-doc.js"; +import { Instances } from "@/models/index.js"; +import { + apRequestChart, + federationChart, + instanceChart, +} from "@/services/chart/index.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { toPuny, extractDbHost } from "@/misc/convert-host.js"; +import { IActivity, getApId } from "@/remote/activitypub/type.js"; +import { fetchInstanceMetadata } from "@/services/fetch-instance-metadata.js"; +import type { InboxJobData } from "../types.js"; +import DbResolver from "@/remote/activitypub/db-resolver.js"; +import { resolvePerson } from "@/remote/activitypub/models/person.js"; +import { LdSignature } from "@/remote/activitypub/misc/ld-signature.js"; +import { StatusError } from "@/misc/fetch.js"; +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import type { UserPublickey } from "@/models/entities/user-publickey.js"; +import { shouldBlockInstance } from "@/misc/should-block-instance.js"; +import { verifySignature } from "@/remote/activitypub/check-fetch.js"; +import { Job } from "bullmq"; +import { createQueue, defaultJobOpts, processorTimeout } from "./index.js"; +import config from "@/config/index.js"; +import { tickInbox } from "@/metrics.js"; + +export const inboxLogger = new Logger("inbox"); + +// Processing when an activity arrives in the user's inbox +async function process(job: Job): Promise { + if (job.data == null || Object.keys(job.data).length === 0) { + job.opts.removeOnComplete = true; + return "Skip (data was null or empty)"; + } + const signature = job.data.signature; // HTTP-signature + let activity = job.data.activity; + + //#region Log + const info = Object.assign({}, activity) as any; + info["@context"] = undefined; + inboxLogger.debug(JSON.stringify(info, null, 2)); + + if (!signature?.keyId) { + throw new Error(`Invalid signature: ${signature}`); + } + //#endregion + const host = toPuny(new URL(signature.keyId).hostname); + + // interrupt if blocked + const meta = await fetchMeta(); + if (await shouldBlockInstance(host, meta)) { + return `Blocked request: ${host}`; + } + + // only allowlisted instances in private mode + if (meta.privateMode && !meta.allowedHosts.includes(host)) { + return `Blocked request: ${host}`; + } + + const keyIdLower = signature.keyId.toLowerCase(); + if (keyIdLower.startsWith("acct:")) { + return `Old keyId is no longer supported. ${keyIdLower}`; + } + + const dbResolver = new DbResolver(); + + // HTTP-Signature keyId from DB + let authUser: { + user: CacheableRemoteUser; + key: UserPublickey | null; + } | null = await dbResolver.getAuthUserFromKeyId(signature.keyId); + + // keyIdでわからなければ、activity.actorを元にDBから取得 || activity.actorを元にリモートから取得 + if (authUser == null) { + try { + authUser = await dbResolver.getAuthUserFromApId(getApId(activity.actor)); + } catch (e) { + // Skip if target is 4xx + if (e instanceof StatusError) { + if (!e.isRetryable) { + return `skip: Ignored deleted actors on both ends ${activity.actor} - ${e.statusCode}`; + } + throw new Error( + `Error in actor ${activity.actor} - ${e.statusCode || e}`, + ); + } + } + } + + // それでもわからなければ終了 + if (authUser == null) { + return "skip: failed to resolve user"; + } + + // publicKey がなくても終了 + if (authUser.key == null) { + return "skip: failed to resolve user publicKey"; + } + + // HTTP-Signatureの検証 + let httpSignatureValidated = httpSignature.verifySignature( + signature, + authUser.key.keyPem, + ); + + // If signature validation failed, try refetching the actor + if (!httpSignatureValidated) { + authUser.key = await dbResolver.refetchPublicKeyForApId(authUser.user); + + if (authUser.key == null) { + return "skip: failed to re-resolve user publicKey"; + } + + httpSignatureValidated = httpSignature.verifySignature( + signature, + authUser.key.keyPem, + ); + } + + if (httpSignatureValidated) { + if (!verifySignature(signature, authUser.key)) return `skip: Invalid HTTP signature`; + } + + // また、signatureのsignerは、activity.actorと一致する必要がある + if (!httpSignatureValidated || authUser.user.uri !== activity.actor) { + // 一致しなくても、でもLD-Signatureがありそうならそっちも見る + if (activity.signature) { + if (activity.signature.type !== "RsaSignature2017") { + return `skip: unsupported LD-signature type ${activity.signature.type}`; + } + + // activity.signature.creator: https://example.oom/users/user#main-key + // みたいになっててUserを引っ張れば公開キーも入ることを期待する + if (activity.signature.creator) { + const candicate = activity.signature.creator.replace(/#.*/, ""); + await resolvePerson(candicate).catch(() => null); + } + + // keyIdからLD-Signatureのユーザーを取得 + authUser = await dbResolver.getAuthUserFromKeyId( + activity.signature.creator, + ); + if (authUser == null) { + return "skip: LD-Signatureのユーザーが取得できませんでした"; + } + + if (authUser.key == null) { + return "skip: LD-SignatureのユーザーはpublicKeyを持っていませんでした"; + } + + // LD-Signature検証 + const ldSignature = new LdSignature(); + const signature = activity.signature; + delete activity["signature"]; + activity = await ldSignature.compactToWellKnown(activity); + + if (ldSignature.containsForbiddenDirectives(activity)) { + return "skip: activity contains forbidden directives"; + } + + const verified = await ldSignature.verifyRsaSignature2017( + activity, + signature, + authUser.key.keyPem, + ); + if (!verified) { + return "skip: LD-Signatureの検証に失敗しました"; + } + + // もう一度actorチェック + if (authUser.user.uri !== activity.actor) { + return `skip: LD-Signature user(${authUser.user.uri}) !== activity.actor(${activity.actor})`; + } + + // ブロックしてたら中断 + const ldHost = extractDbHost(authUser.user.uri!); + if (await shouldBlockInstance(ldHost, meta)) { + return `Blocked request: ${ldHost}`; + } + } else { + return `skip: http-signature verification failed and no LD-Signature. keyId=${signature.keyId}`; + } + } + + // activity.idがあればホストが署名者のホストであることを確認する + if (typeof activity.id !== "string") { + return 'skip: activity.id is not a string'; + } + + const signerHost = extractDbHost(authUser.user.uri!); + const activityIdHost = extractDbHost(activity.id); + if (signerHost !== activityIdHost) { + return `skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`; + } + + // Update stats + registerOrFetchInstanceDoc(authUser.user.host).then((i) => { + Instances.update(i.id, { + latestRequestReceivedAt: new Date(), + lastCommunicatedAt: new Date(), + isNotResponding: false, + }); + + fetchInstanceMetadata(i); + + instanceChart.requestReceived(i.host); + apRequestChart.inbox(); + federationChart.inbox(i.host); + }); + + const inbox = authUser.user.sharedInbox ?? authUser.user.inbox; + if (inbox !== null) { + const { host: inboxHost } = new URL(inbox); + + if (inboxHost !== authUser.user.host) { + registerOrFetchInstanceDoc(inboxHost).then((i) => { + Instances.update(i.id, { + latestRequestReceivedAt: new Date(), + lastCommunicatedAt: new Date(), + isNotResponding: false, + }); + + fetchInstanceMetadata(i); + + instanceChart.requestReceived(i.host); + apRequestChart.inbox(); + federationChart.inbox(i.host); + }); + } + } + + tickInbox(); + + // アクティビティを処理 + return await perform(authUser.user, activity); +}; + +export const [inboxQueue, inboxInit] = createQueue( + "inbox", + processorTimeout(process, 5 * 60), + { + limitPerSec: config.inboxJobPerSec || 16, + concurrency: config.inboxJobConcurrency || 16, + }, +); + +export function inboxJob( + activity: IActivity, + signature: httpSignature.IParsedSignature, +) { + const data = { + activity: activity, + signature, + }; + + return inboxQueue.add("default", data, { + attempts: config.inboxJobMaxAttempts || 8, + backoff: { + type: "custom", + }, + ...defaultJobOpts, + }); +} diff --git a/packages/backend/src/queue/queues/index.ts b/packages/backend/src/queue/queues/index.ts new file mode 100644 index 0000000..41cae6c --- /dev/null +++ b/packages/backend/src/queue/queues/index.ts @@ -0,0 +1,113 @@ +import { Worker, Queue, BackoffStrategy, Processor, Job } from "bullmq"; +import config from "@/config/index.js"; +import { queueLogger, renderError } from "../logger.js"; + +const connectionDetails = { + port: config.redis.port, + host: config.redis.host, + family: config.redis.family == null ? 0 : config.redis.family, + username: config.redis.user ?? "default", + password: config.redis.pass, + db: config.redis.db || 0, + tls: config.redis.tls, + lazyConnect: true, + maxRetriesPerRequest: null, +}; + +// ref. https://github.com/misskey-dev/misskey/pull/7635#issue-971097019 +function apBackoff(attemptsMade: number) { + const baseDelay = 60 * 1000; // 1min + const maxBackoff = 8 * 60 * 60 * 1000; // 8hours + let backoff = (Math.pow(2, attemptsMade) - 1) * baseDelay; + backoff = Math.min(backoff, maxBackoff); + backoff += Math.round(backoff * Math.random() * 0.2); + return backoff; +} + +export function createQueue( + name: string, + processor: Processor, + opts: { + backoff?: BackoffStrategy, + limitPerSec?: number, + concurrency?: number, + } = {} +): [Queue, () => Promise] { + const backoffStrategy = opts.backoff || apBackoff; + const sublogger = queueLogger.createSubLogger(name); + + const queue = new Queue( + name, + { + connection: connectionDetails, + prefix: config.redis.prefix, + }, + ); + queue.on("waiting", (job) => sublogger.debug(`waiting id=${job.id}`)); + + const initWorker = () => { + return new Worker( + name, + processor, + { + connection: connectionDetails, + prefix: config.redis.prefix, + settings: { + backoffStrategy, + }, + limiter: (opts.limitPerSec === undefined || opts.limitPerSec === -1) + ? undefined : { + max: opts.limitPerSec, + duration: 1000, + }, + concurrency: opts.concurrency || 1, + }, + ) + .on("ready", () => {}) + .on("active", (job) => sublogger.debug(`active id=${job.id}`)) + .on("completed", (job) => sublogger.debug(`completed id=${job.id}`)) + .on("failed", (job, err) => + sublogger.warn(`failed(${err}) id=${job?.id}`, { + job, + e: renderError(err), + }), + ) + .on("error", (err) => + sublogger.error(`error(${err})`, { e: renderError(err) }), + ) + .on("stalled", (jobId) => + sublogger.warn(`stalled id=${jobId}`), + ) + .waitUntilReady() + .then(() => {}); + } + + return [queue, initWorker]; +} + +export function createQueueProducer(name: string): Queue { + return new Queue( + name, + { + connection: connectionDetails, + prefix: config.redis.prefix, + }, + ); +} + +export function processorTimeout

(processor: P, timeout: number) { + return (job: Job) => { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("timeout reached")), timeout * 1000); + processor(job) + .then(resolve) + .catch(reject) + .finally(() => clearTimeout(timer)); + }); + }; +} + +export const defaultJobOpts = { + removeOnComplete: process.env["NODE_ENV"] === "production" ? { age: 600 } : true, + removeOnFail: process.env["NODE_ENV"] === "production" ? { age: 3600 } : true, +} diff --git a/packages/backend/src/queue/queues/object-storage/clean-remote-files.ts b/packages/backend/src/queue/queues/object-storage/clean-remote-files.ts new file mode 100644 index 0000000..3cbc50a --- /dev/null +++ b/packages/backend/src/queue/queues/object-storage/clean-remote-files.ts @@ -0,0 +1,67 @@ +import { queueLogger } from "../../logger.js"; +import { deleteFileSync } from "@/services/drive/delete-file.js"; +import { DriveFiles } from "@/models/index.js"; +import { User } from "@/models/entities/user.js"; +import config from "@/config/index.js"; +import { Job } from "bullmq"; + +const logger = queueLogger.createSubLogger("clean-remote-files"); + +export async function cleanRemoteFiles( + job: Job>, +): Promise { + let progress = 0; + const untilDate = new Date(Date.now() - ((new Date()).getTimezoneOffset() * 60000)); + untilDate.setDate(untilDate.getDate() - (config.mediaCleanup?.maxAgeDays ?? 0)); + const avatars = !(config.mediaCleanup?.keepHeaders ?? true); + const headers = !(config.mediaCleanup?.keepHeaders ?? true); + + const until = untilDate.toISOString().replace("T", " ").slice(0, -1); + + let target = "files"; + if (avatars) + if (headers) target += ", avatars & headers"; + else target += " & avatars"; + else if (headers) target += " & headers"; + + logger.info(`Deleting cached remote ${target} created before ${until}...`); + + let query = DriveFiles.createQueryBuilder("file") + .where(`file.isLink = FALSE`) + .andWhere(`file.userHost IS NOT NULL`) + .andWhere("file.createdAt < :until", { until }); + + if (!avatars || !headers) { + query = query.andWhere((qb) => { + let sq = qb.subQuery().from(User, "user"); + + if (!avatars) sq = sq.where("file.id = user.avatarId"); + if (!headers) sq = sq.orWhere("file.id = user.bannerId"); + + return `NOT EXISTS ${sq.getQuery()}`; + }); + } + + query = query.take(8); + + const total = await query.getCount(); + logger.info(`Deleting ${total} files, please wait...`); + + while (true) { + const files = await query.getMany(); + + if (files.length === 0) { + job.updateProgress(100); + break; + } + + await Promise.all(files.map((file) => deleteFileSync(file, true))); + + progress += files.length; + + job.updateProgress((progress / total) * 100); + } + + logger.succ(`Remote media cleanup job completed successfully.`); + return "Success"; +} diff --git a/packages/backend/src/queue/queues/object-storage/delete-file.ts b/packages/backend/src/queue/queues/object-storage/delete-file.ts new file mode 100644 index 0000000..bb57bc1 --- /dev/null +++ b/packages/backend/src/queue/queues/object-storage/delete-file.ts @@ -0,0 +1,11 @@ +import type { ObjectStorageFileJobData } from "@/queue/types.js"; +import { deleteObjectStorageFile } from "@/services/drive/delete-file.js"; +import { Job } from "bullmq"; + +export async function deleteFile(job: Job): Promise { + const key: string = job.data.key; + + await deleteObjectStorageFile(key); + + return "Success"; +}; diff --git a/packages/backend/src/queue/queues/object-storage/index.ts b/packages/backend/src/queue/queues/object-storage/index.ts new file mode 100644 index 0000000..a61851a --- /dev/null +++ b/packages/backend/src/queue/queues/object-storage/index.ts @@ -0,0 +1,52 @@ +import { Job, Processor } from "bullmq"; +import { createQueue, defaultJobOpts } from "../index.js"; +import { cleanRemoteFiles } from "./clean-remote-files.js"; +import { deleteFile } from "./delete-file.js"; +import config from "@/config/index.js"; + +const processors = { + cleanRemoteFiles, + deleteFile, +} as Record; + +async function process(job: Job): Promise { + const processor = processors[job.name]; + if (processor === undefined) return "skip: unknown job name"; + return await processor(job); +} + +const [objectStorageQueue_, objectStorageInitQueue] = + createQueue("objectStorage", process, { concurrency: 16 }); +export const objectStorageQueue = objectStorageQueue_; + +export async function objectStorageInit() { + await objectStorageInitQueue(); + if (config.mediaCleanup?.cron) { + await objectStorageQueue.upsertJobScheduler( + "cleanRemoteFiles", + { pattern: "0 0 * * *" }, + { + name: "cleanRemoteFiles", + opts: defaultJobOpts, + } + ) + } +} + +export function createDeleteObjectStorageFileJob(key: string) { + return objectStorageQueue.add( + "deleteFile", + { + key: key, + }, + defaultJobOpts, + ); +} + +export function createCleanRemoteFilesJob() { + return objectStorageQueue.add( + "cleanRemoteFiles", + {}, + defaultJobOpts, + ); +} diff --git a/packages/backend/src/queue/queues/system/check-expired-memoriets.ts b/packages/backend/src/queue/queues/system/check-expired-memoriets.ts new file mode 100644 index 0000000..542a147 --- /dev/null +++ b/packages/backend/src/queue/queues/system/check-expired-memoriets.ts @@ -0,0 +1,43 @@ +import { MemorietArchives, Memoriets, Notes, Users } from "@/models/index.js"; +import deleteNote from "@/services/note/delete.js"; +import { queueLogger } from "../../logger.js"; +import { genId } from "@/misc/gen-id.js"; + +const logger = queueLogger.createSubLogger("check-expired-memoriets"); + +export async function checkExpiredMemoriets() { + logger.info("Checking expired Memoriets..."); + const expired = await Memoriets.createQueryBuilder("memoriet") + .where("memoriet.expiresAt IS NOT NULL") + .andWhere("memoriet.expiresAt < :now", { now: new Date() }) + .getMany(); + + for (const memoriet of expired) { + const note = await Notes.findOneBy({ id: memoriet.noteId }); + if (note != null) { + const user = await Users.findOneBy({ id: note.userId }); + if (user != null) { + await MemorietArchives.save({ + id: genId(), + createdAt: memoriet.createdAt, + deletedAt: new Date(), + userId: memoriet.userId, + text: note.text?.replace(/\n?#Memoriet\b/gi, "").trim() || null, + cw: note.cw, + fileIds: note.fileIds, + textLayers: memoriet.textLayers, + visibility: note.visibility, + }).catch((err) => { + logger.warn(`Failed to archive expired Memoriet ${memoriet.id}: ${err}`); + }); + await deleteNote(user, note, true).catch((err) => { + logger.warn(`Failed to delete expired Memoriet note ${note.id}: ${err}`); + }); + } + } + await Memoriets.delete(memoriet.id); + } + + logger.succ("All expired Memoriets checked."); + return `done: ${expired.length}`; +} diff --git a/packages/backend/src/queue/queues/system/check-expired-mutings.ts b/packages/backend/src/queue/queues/system/check-expired-mutings.ts new file mode 100644 index 0000000..15c229e --- /dev/null +++ b/packages/backend/src/queue/queues/system/check-expired-mutings.ts @@ -0,0 +1,29 @@ +import { In } from "typeorm"; +import { Mutings } from "@/models/index.js"; +import { queueLogger } from "../../logger.js"; +import { publishUserEvent } from "@/services/stream.js"; + +const logger = queueLogger.createSubLogger("check-expired-mutings"); + +export async function checkExpiredMutings(): Promise { + logger.info("Checking expired mutings..."); + + const expired = await Mutings.createQueryBuilder("muting") + .where("muting.expiresAt IS NOT NULL") + .andWhere("muting.expiresAt < :now", { now: new Date() }) + .innerJoinAndSelect("muting.mutee", "mutee") + .getMany(); + + if (expired.length > 0) { + await Mutings.delete({ + id: In(expired.map((m) => m.id)), + }); + + for (const m of expired) { + publishUserEvent(m.muterId, "unmute", m.mutee!); + } + } + + logger.succ("All expired mutings checked."); + return "Success"; +} diff --git a/packages/backend/src/queue/queues/system/clean-charts.ts b/packages/backend/src/queue/queues/system/clean-charts.ts new file mode 100644 index 0000000..15170e3 --- /dev/null +++ b/packages/backend/src/queue/queues/system/clean-charts.ts @@ -0,0 +1,40 @@ +import { Job } from "bullmq"; +import { queueLogger } from "../../logger.js"; +import { + activeUsersChart, + driveChart, + federationChart, + hashtagChart, + instanceChart, + notesChart, + perUserDriveChart, + perUserFollowingChart, + perUserNotesChart, + perUserReactionsChart, + usersChart, + apRequestChart, +} from "@/services/chart/index.js"; + +const logger = queueLogger.createSubLogger("clean-charts"); + +export async function cleanCharts(): Promise { + logger.info("Clean charts..."); + + await Promise.all([ + federationChart.clean(), + notesChart.clean(), + usersChart.clean(), + activeUsersChart.clean(), + instanceChart.clean(), + perUserNotesChart.clean(), + driveChart.clean(), + perUserReactionsChart.clean(), + hashtagChart.clean(), + perUserFollowingChart.clean(), + perUserDriveChart.clean(), + apRequestChart.clean(), + ]); + + logger.succ("All charts successfully cleaned."); + return "Success"; +} diff --git a/packages/backend/src/queue/queues/system/clean.ts b/packages/backend/src/queue/queues/system/clean.ts new file mode 100644 index 0000000..1edfef4 --- /dev/null +++ b/packages/backend/src/queue/queues/system/clean.ts @@ -0,0 +1,17 @@ +import { LessThan } from "typeorm"; +import { UserIps } from "@/models/index.js"; + +import { queueLogger } from "../../logger.js"; + +const logger = queueLogger.createSubLogger("clean"); + +export async function clean(): Promise { + logger.info("Cleaning..."); + + await UserIps.delete({ + createdAt: LessThan(new Date(Date.now() - 1000 * 60 * 60 * 24 * 90)), + }); + + logger.succ("Cleaned."); + return "Success"; +} diff --git a/packages/backend/src/queue/queues/system/index.ts b/packages/backend/src/queue/queues/system/index.ts new file mode 100644 index 0000000..de8e136 --- /dev/null +++ b/packages/backend/src/queue/queues/system/index.ts @@ -0,0 +1,64 @@ +import { Job, Processor } from "bullmq"; +import { ScheduledNotes } from "@/models/index.js"; +import { createQueue, defaultJobOpts } from "../index.js"; +import { clean } from "./clean.js"; +import { cleanCharts } from "./clean-charts.js"; +import { checkExpiredMutings } from "./check-expired-mutings.js"; +import { checkExpiredMemoriets } from "./check-expired-memoriets.js"; +import { resyncCharts } from "./resync-charts.js"; +import { tickCharts } from "./tick-charts.js"; +import { verifyLinks } from "./verify-links.js"; +import { publishScheduledNoteJob } from "./publish-scheduled-note.js"; +import { enqueueScheduledNote } from "./scheduled-note.js"; + +const processors = { + clean, + cleanCharts, + checkExpiredMutings, + checkExpiredMemoriets, + resyncCharts, + tickCharts, + verifyLinks, + publishScheduledNote: publishScheduledNoteJob, +} as Record; + +async function process(job: Job): Promise { + const processor = processors[job.name]; + if (processor === undefined) return "skip: unknown job name"; + return await processor(job); +} + +const [systemQueue_, systemInitQueue] = + createQueue("system", process, { concurrency: Object.keys(processors).length }); +export const systemQueue = systemQueue_; + +async function enqueuePendingScheduledNotes() { + const scheduledNotes = await ScheduledNotes.find({ + where: { status: "scheduled" }, + }); + + for (const scheduledNote of scheduledNotes) { + await enqueueScheduledNote(scheduledNote); + } +} + +export async function systemInit() { + await systemInitQueue(); + for (const { name, seconds } of [ + { name: "clean", seconds: 60 * 60 }, + { name: "cleanCharts", seconds: 60 * 60 }, + { name: "checkExpiredMutings", seconds: 5 * 60 }, + { name: "checkExpiredMemoriets", seconds: 60 }, + { name: "resyncCharts", seconds: 60 * 60 }, + { name: "tickCharts", seconds: 60 }, + { name: "verifyLinks", seconds: 60 * 60 * 24 }, + ]) { + await systemQueue.upsertJobScheduler( + name, + { every: seconds * 1000 }, + { opts: defaultJobOpts } + ); + } + + await enqueuePendingScheduledNotes(); +} diff --git a/packages/backend/src/queue/queues/system/publish-scheduled-note.ts b/packages/backend/src/queue/queues/system/publish-scheduled-note.ts new file mode 100644 index 0000000..e54deac --- /dev/null +++ b/packages/backend/src/queue/queues/system/publish-scheduled-note.ts @@ -0,0 +1,6 @@ +import type { Job } from "bullmq"; +import { publishScheduledNote } from "@/services/note/scheduled.js"; + +export async function publishScheduledNoteJob(job: Job<{ scheduledNoteId: string }>) { + return await publishScheduledNote(job.data.scheduledNoteId); +} diff --git a/packages/backend/src/queue/queues/system/resync-charts.ts b/packages/backend/src/queue/queues/system/resync-charts.ts new file mode 100644 index 0000000..32634e5 --- /dev/null +++ b/packages/backend/src/queue/queues/system/resync-charts.ts @@ -0,0 +1,19 @@ +import { queueLogger } from "../../logger.js"; +import { driveChart, notesChart, usersChart } from "@/services/chart/index.js"; + +const logger = queueLogger.createSubLogger("resync-charts"); + +export async function resyncCharts(): Promise { + logger.info("Resync charts..."); + + // TODO: ユーザーごとのチャートも更新する + // TODO: インスタンスごとのチャートも更新する + await Promise.all([ + driveChart.resync(), + notesChart.resync(), + usersChart.resync(), + ]); + + logger.succ("All charts successfully resynced."); + return "Success"; +} diff --git a/packages/backend/src/queue/queues/system/scheduled-note.ts b/packages/backend/src/queue/queues/system/scheduled-note.ts new file mode 100644 index 0000000..d9aea5e --- /dev/null +++ b/packages/backend/src/queue/queues/system/scheduled-note.ts @@ -0,0 +1,23 @@ +import type { ScheduledNote } from "@/models/entities/scheduled-note.js"; +import { createQueueProducer, defaultJobOpts } from "../index.js"; + +type PublishScheduledNoteJobData = { + scheduledNoteId: string; +}; + +const scheduledNoteSystemQueue = + createQueueProducer("system"); + +export async function enqueueScheduledNote( + scheduledNote: Pick, +) { + await scheduledNoteSystemQueue.add( + "publishScheduledNote", + { scheduledNoteId: scheduledNote.id }, + { + ...defaultJobOpts, + delay: Math.max(0, scheduledNote.scheduledAt.getTime() - Date.now()), + jobId: `scheduledNote:${scheduledNote.id}`, + }, + ); +} diff --git a/packages/backend/src/queue/queues/system/tick-charts.ts b/packages/backend/src/queue/queues/system/tick-charts.ts new file mode 100644 index 0000000..110928f --- /dev/null +++ b/packages/backend/src/queue/queues/system/tick-charts.ts @@ -0,0 +1,39 @@ +import { queueLogger } from "../../logger.js"; +import { + activeUsersChart, + driveChart, + federationChart, + hashtagChart, + instanceChart, + notesChart, + perUserDriveChart, + perUserFollowingChart, + perUserNotesChart, + perUserReactionsChart, + usersChart, + apRequestChart, +} from "@/services/chart/index.js"; + +const logger = queueLogger.createSubLogger("tick-charts"); + +export async function tickCharts(): Promise { + logger.info("Tick charts..."); + + await Promise.all([ + federationChart.tick(false), + notesChart.tick(false), + usersChart.tick(false), + activeUsersChart.tick(false), + instanceChart.tick(false), + perUserNotesChart.tick(false), + driveChart.tick(false), + perUserReactionsChart.tick(false), + hashtagChart.tick(false), + perUserFollowingChart.tick(false), + perUserDriveChart.tick(false), + apRequestChart.tick(false), + ]); + + logger.succ("All charts successfully ticked."); + return "Success"; +} diff --git a/packages/backend/src/queue/queues/system/verify-links.ts b/packages/backend/src/queue/queues/system/verify-links.ts new file mode 100644 index 0000000..d5b4435 --- /dev/null +++ b/packages/backend/src/queue/queues/system/verify-links.ts @@ -0,0 +1,38 @@ +import { UserProfiles } from "@/models/index.js"; +import { Not } from "typeorm"; +import { queueLogger } from "../../logger.js"; +import { verifyLink } from "@/services/fetch-rel-me.js"; + +const logger = queueLogger.createSubLogger("verify-links"); + +export async function verifyLinks(): Promise { + logger.info("Verifying links..."); + + const usersToVerify = await UserProfiles.findBy({ + fields: Not(null), + userHost: "", + }); + for (const user of usersToVerify) { + for (const field of user.fields) { + if (!field || field.name === "" || field.value === "") { + continue; + } + if (field.value.startsWith("http") && user.user?.username) { + field.verified = await verifyLink(field.value, user.user.username); + } + } + if (user.fields.length > 0) { + try { + await UserProfiles.update(user.userId, { + fields: user.fields, + }); + } catch (e) { + logger.error(`Failed to update user ${user.userId} ${e}`); + throw e; + } + } + } + + logger.succ("All links successfully verified."); + return "Success"; +} diff --git a/packages/backend/src/queue/queues/webhook-deliver.ts b/packages/backend/src/queue/queues/webhook-deliver.ts new file mode 100644 index 0000000..c48f199 --- /dev/null +++ b/packages/backend/src/queue/queues/webhook-deliver.ts @@ -0,0 +1,104 @@ +import Logger from "@/services/logger.js"; +import type { WebhookDeliverJobData } from "../types.js"; +import { getResponse, StatusError } from "@/misc/fetch.js"; +import { Webhooks } from "@/models/index.js"; +import config from "@/config/index.js"; +import { Job } from "bullmq"; +import { Webhook, webhookEventTypes } from "@/models/entities/webhook.js"; +import { v4 as uuid } from "uuid"; +import { createQueue, defaultJobOpts, processorTimeout } from "./index.js"; + +const logger = new Logger("webhook"); + +async function process(job: Job) { + if (job.data == null || Object.keys(job.data).length === 0) { + job.opts.removeOnComplete = true; + return "Skip (data was null or empty)"; + } + try { + logger.debug(`delivering ${job.data.webhookId}`); + + const res = await getResponse({ + url: job.data.to, + method: "POST", + headers: { + "User-Agent": "Iceshrimp-Hooks", + "X-Iceshrimp-Host": config.host, + "X-Iceshrimp-Hook-Id": job.data.webhookId, + "X-Iceshrimp-Hook-Secret": job.data.secret, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + hookId: job.data.webhookId, + userId: job.data.userId, + eventId: job.data.eventId, + createdAt: job.data.createdAt, + type: job.data.type, + body: job.data.content, + }), + }); + + Webhooks.update( + { id: job.data.webhookId }, + { + latestSentAt: new Date(), + latestStatus: res.status, + }, + ); + + return "Success"; + } catch (res) { + Webhooks.update( + { id: job.data.webhookId }, + { + latestSentAt: new Date(), + latestStatus: res instanceof StatusError ? res.statusCode : 1, + }, + ); + + if (res instanceof StatusError) { + // 4xx + if (!res.isRetryable) { + return `${res.statusCode} ${res.statusMessage}`; + } + + // 5xx etc. + throw new Error(`${res.statusCode} ${res.statusMessage}`); + } else { + // DNS error, socket error, timeout ... + throw res; + } + } +}; + +export const [webhookDeliverQueue, webhookDeliverInit] = + createQueue( + "webhookDeliver", + processorTimeout(process, 60), + { limitPerSec: 64, concurrency: 64 }, + ); + +export function webhookDeliverJob( + webhook: Webhook, + type: typeof webhookEventTypes[number], + content: unknown, +) { + const data = { + type, + content, + webhookId: webhook.id, + userId: webhook.userId, + to: webhook.url, + secret: webhook.secret, + createdAt: Date.now(), + eventId: uuid(), + }; + + return webhookDeliverQueue.add("default", data, { + attempts: 4, + backoff: { + type: "custom", + }, + ...defaultJobOpts, + }); +} diff --git a/packages/backend/src/queue/types.ts b/packages/backend/src/queue/types.ts new file mode 100644 index 0000000..b72b127 --- /dev/null +++ b/packages/backend/src/queue/types.ts @@ -0,0 +1,82 @@ +import type { DriveFile } from "@/models/entities/drive-file.js"; +import type { Note } from "@/models/entities/note"; +import type { User } from "@/models/entities/user.js"; +import type { Webhook } from "@/models/entities/webhook"; +import type { IActivity } from "@/remote/activitypub/type.js"; +import type httpSignature from "@peertube/http-signature"; + +export type DeliverJobData = { + /** Actor */ + user: ThinUser; + /** Activity */ + content: unknown; + /** inbox URL to deliver */ + to: string; +}; + +export type InboxJobData = { + activity: IActivity; + signature: httpSignature.IParsedSignature; +}; + +export type DbJobData = + | DbUserJobData + | DbUserImportPostsJobData + | DbUserImportJobData + | DbUserDeleteJobData + | DbUserImportMastoPostJobData; + +export type DbUserJobData = { + user: ThinUser; + excludeMuting: boolean; + excludeInactive: boolean; +}; + +export type DbUserDeleteJobData = { + user: ThinUser; + soft?: boolean; +}; + +export type DbUserImportJobData = { + user: ThinUser; + fileId: DriveFile["id"]; +}; + +export type DbUserImportPostsJobData = { + user: ThinUser; + fileId: DriveFile["id"]; + signatureCheck: boolean; +}; + +export type DbUserImportMastoPostJobData = { + user: ThinUser; + post: any; + signatureCheck: boolean; +}; + +export type ObjectStorageJobData = + | ObjectStorageFileJobData + | Record; + +export type ObjectStorageFileJobData = { + key: string; +}; + +export type EndedPollNotificationJobData = { + noteId: Note["id"]; +}; + +export type WebhookDeliverJobData = { + type: string; + content: unknown; + webhookId: Webhook["id"]; + userId: User["id"]; + to: string; + secret: string; + createdAt: number; + eventId: string; +}; + +export type ThinUser = { + id: User["id"]; +}; diff --git a/packages/backend/src/remote/activitypub/ap-request.ts b/packages/backend/src/remote/activitypub/ap-request.ts new file mode 100644 index 0000000..2670929 --- /dev/null +++ b/packages/backend/src/remote/activitypub/ap-request.ts @@ -0,0 +1,152 @@ +import * as crypto from "node:crypto"; +import { URL } from "node:url"; + +type Request = { + url: string; + method: string; + headers: Record; +}; + +type PrivateKey = { + privateKeyPem: string; + keyId: string; +}; + +export function createSignedPost(args: { + key: PrivateKey; + url: string; + body: string; + additionalHeaders: Record; +}) { + const u = new URL(args.url); + const digestHeader = `SHA-256=${crypto + .createHash("sha256") + .update(args.body) + .digest("base64")}`; + + const request: Request = { + url: u.href, + method: "POST", + headers: objectAssignWithLcKey( + { + Date: new Date().toUTCString(), + Host: u.hostname, + "Content-Type": "application/activity+json", + Digest: digestHeader, + }, + args.additionalHeaders, + ), + }; + + const result = signToRequest(request, args.key, [ + "(request-target)", + "date", + "host", + "digest", + ]); + + return { + request, + signingString: result.signingString, + signature: result.signature, + signatureHeader: result.signatureHeader, + }; +} + +export function createSignedGet(args: { + key: PrivateKey; + url: string; + additionalHeaders: Record; +}) { + const u = new URL(args.url); + + const request: Request = { + url: u.href, + method: "GET", + headers: objectAssignWithLcKey( + { + Accept: "application/activity+json, application/ld+json; profile=\"https://www.w3.org/ns/activitystreams\"", + Date: new Date().toUTCString(), + Host: new URL(args.url).hostname, + }, + args.additionalHeaders, + ), + }; + + const result = signToRequest(request, args.key, [ + "(request-target)", + "date", + "host", + "accept", + ]); + + return { + request, + signingString: result.signingString, + signature: result.signature, + signatureHeader: result.signatureHeader, + }; +} + +function signToRequest( + request: Request, + key: PrivateKey, + includeHeaders: string[], +) { + const signingString = genSigningString(request, includeHeaders); + const signature = crypto + .sign("sha256", Buffer.from(signingString), key.privateKeyPem) + .toString("base64"); + const signatureHeader = `keyId="${ + key.keyId + }",algorithm="rsa-sha256",headers="${includeHeaders.join( + " ", + )}",signature="${signature}"`; + + request.headers = objectAssignWithLcKey(request.headers, { + Signature: signatureHeader, + }); + + return { + request, + signingString, + signature, + signatureHeader, + }; +} + +function genSigningString(request: Request, includeHeaders: string[]) { + request.headers = lcObjectKey(request.headers); + + const results: string[] = []; + + for (const key of includeHeaders.map((x) => x.toLowerCase())) { + if (key === "(request-target)") { + results.push( + `(request-target): ${request.method.toLowerCase()} ${ + new URL(request.url).pathname + }`, + ); + } else { + results.push(`${key}: ${request.headers[key]}`); + } + } + + return results.join("\n"); +} + +function lcObjectKey(src: Record) { + const dst: Record = {}; + for (const key of Object.keys(src).filter( + (x) => x !== "__proto__" && typeof src[x] === "string", + )) + dst[key.toLowerCase()] = src[key]; + return dst; +} + +function objectAssignWithLcKey( + a: Record, + b: Record, +) { + return Object.assign(lcObjectKey(a), lcObjectKey(b)); +} diff --git a/packages/backend/src/remote/activitypub/audience.ts b/packages/backend/src/remote/activitypub/audience.ts new file mode 100644 index 0000000..380f1a4 --- /dev/null +++ b/packages/backend/src/remote/activitypub/audience.ts @@ -0,0 +1,107 @@ +import type { ApObject } from "./type.js"; +import { getApIds } from "./type.js"; +import Resolver from "./resolver.js"; +import { resolvePerson } from "./models/person.js"; +import { unique, concat } from "@/prelude/array.js"; +import promiseLimit from "promise-limit"; +import type { + CacheableRemoteUser, + CacheableUser, +} from "@/models/entities/user.js"; +import { User } from "@/models/entities/user.js"; +import { RecursionLimiter } from "@/models/repositories/user-profile.js"; + +type Visibility = "public" | "home" | "followers" | "specified"; + +type AudienceInfo = { + visibility: Visibility; + mentionedUsers: CacheableUser[]; + visibleUsers: CacheableUser[]; +}; + +export async function parseAudience( + actor: CacheableRemoteUser, + to?: ApObject, + cc?: ApObject, + resolver?: Resolver, + limiter: RecursionLimiter = new RecursionLimiter() +): Promise { + const toGroups = groupingAudience(getApIds(to), actor); + const ccGroups = groupingAudience(getApIds(cc), actor); + + const others = unique(concat([toGroups.other, ccGroups.other])); + + resolver ??= new Resolver(); + const limit = promiseLimit(2); + const mentionedUsers = ( + await Promise.all( + others.map((id) => + limit(() => resolvePerson(id, resolver, limiter).catch(() => null)), + ), + ) + ).filter((x): x is CacheableUser => x != null); + + if (toGroups.public.length > 0) { + return { + visibility: "public", + mentionedUsers, + visibleUsers: [], + }; + } + + if (ccGroups.public.length > 0) { + return { + visibility: "home", + mentionedUsers, + visibleUsers: [], + }; + } + + if (toGroups.followers.length > 0) { + return { + visibility: "followers", + mentionedUsers, + visibleUsers: [], + }; + } + + return { + visibility: "specified", + mentionedUsers, + visibleUsers: mentionedUsers, + }; +} + +function groupingAudience(ids: string[], actor: CacheableRemoteUser) { + const groups = { + public: [] as string[], + followers: [] as string[], + other: [] as string[], + }; + + for (const id of ids) { + if (isPublic(id)) { + groups.public.push(id); + } else if (isFollowers(id, actor)) { + groups.followers.push(id); + } else { + groups.other.push(id); + } + } + + groups.other = unique(groups.other); + + return groups; +} + +function isPublic(id: string) { + return [ + "https://www.w3.org/ns/activitystreams#Public", + "as:Public", + "Public", + ].includes(id); +} + +function isFollowers(id: string, actor: CacheableRemoteUser) { + return id === (actor.followersUri || `${actor.uri}/followers`); +} diff --git a/packages/backend/src/remote/activitypub/check-fetch.ts b/packages/backend/src/remote/activitypub/check-fetch.ts new file mode 100644 index 0000000..406e5a7 --- /dev/null +++ b/packages/backend/src/remote/activitypub/check-fetch.ts @@ -0,0 +1,170 @@ +import { URL } from "url"; +import httpSignature, { IParsedSignature } from "@peertube/http-signature"; +import config from "@/config/index.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { toPuny } from "@/misc/convert-host.js"; +import DbResolver from "@/remote/activitypub/db-resolver.js"; +import { getApId } from "@/remote/activitypub/type.js"; +import { shouldBlockInstance } from "@/misc/should-block-instance.js"; +import type { IncomingMessage } from "http"; +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import type { UserPublickey } from "@/models/entities/user-publickey.js"; +import { verify } from "node:crypto"; +import { toSingle } from "@/prelude/array.js"; +import { createHash } from "node:crypto"; +import { tickFetch } from "@/metrics.js"; + +export async function hasSignature(req: IncomingMessage): Promise { + const meta = await fetchMeta(); + const required = meta.secureMode || meta.privateMode; + + try { + httpSignature.parseRequest(req, { headers: [] }); + } catch (e) { + if (e instanceof Error && e.name === "MissingHeaderError") { + return required ? "missing" : "optional"; + } + return "invalid"; + } + return required ? "supplied" : "unneeded"; +} + +export async function checkFetch(req: IncomingMessage): Promise { + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + if (req.headers.host !== config.host) return 400; + + let signature; + + try { + signature = httpSignature.parseRequest(req, { headers: ["(request-target)", "host", "date"], authorizationHeaderName: 'signature' }); + } catch (e) { + return 401; + } + + const keyId = new URL(signature.keyId); + const host = toPuny(keyId.hostname); + + if (await shouldBlockInstance(host, meta)) { + return 403; + } + + if ( + meta.privateMode && + host !== config.host && + host !== config.domain && + !meta.allowedHosts.includes(host) + ) { + return 403; + } + + const keyIdLower = signature.keyId.toLowerCase(); + if (keyIdLower.startsWith("acct:")) { + // Old keyId is no longer supported. + return 401; + } + + const dbResolver = new DbResolver(); + + // HTTP-Signature keyIdを元にDBから取得 + let authUser = await dbResolver.getAuthUserFromKeyId(signature.keyId); + + // keyIdでわからなければ、resolveしてみる + if (authUser == null) { + try { + keyId.hash = ""; + authUser = await dbResolver.getAuthUserFromApId( + getApId(keyId.toString()), + ); + } catch (e) { + // できなければ駄目 + return 403; + } + } + + // publicKey がなくても終了 + if (authUser?.key == null) { + return 403; + } + + // Cannot authenticate against local user + if (authUser.user.uri === null || authUser.user.host === null) { + return 400; + } + + // Check if keyId hostname matches actor hostname + if (toPuny(new URL(authUser.user.uri).hostname) !== host) { + return 403; + } + + // HTTP-Signatureの検証 + let httpSignatureValidated = httpSignature.verifySignature( + signature, + authUser.key.keyPem, + ); + + // If signature validation failed, try refetching the actor + if (!httpSignatureValidated) { + authUser.key = await dbResolver.refetchPublicKeyForApId(authUser.user); + + if (authUser.key == null) { + return 403; + } + + httpSignatureValidated = httpSignature.verifySignature( + signature, + authUser.key.keyPem, + ); + } + + if (!httpSignatureValidated) { + return 403; + } + + if (!verifySignature(signature, authUser.key)) { + return 401; + } + + tickFetch(); + return 200; + } + return 200; +} + +export async function getSignatureUser(req: IncomingMessage): Promise<{ + user: CacheableRemoteUser; + key: UserPublickey | null; +} | null> { + const signature = httpSignature.parseRequest(req, { headers: [] }); + const keyId = new URL(signature.keyId); + const dbResolver = new DbResolver(); + + // Retrieve from DB by HTTP-Signature keyId + const authUser = await dbResolver.getAuthUserFromKeyId(signature.keyId); + if (authUser) { + return authUser; + } + + // Resolve if failed to retrieve by keyId + keyId.hash = ""; + return await dbResolver.getAuthUserFromApId(getApId(keyId.toString())); +} + +export function verifySignature(sig: IParsedSignature, key: UserPublickey): boolean { + if (!['hs2019', 'rsa-sha256'].includes(sig.algorithm.toLowerCase())) return false; + try { + return verify('rsa-sha256', Buffer.from(sig.signingString, 'utf8'), key.keyPem, Buffer.from(sig.params.signature, 'base64')); + } + catch { + // Algo not supported + return false; + } +} + +export function verifyDigest(body: string, digest: string | string[] | undefined): boolean { + digest = toSingle(digest); + if (body == null || digest == null || !digest.toLowerCase().startsWith('sha-256=')) + return false; + + return createHash('sha256').update(body).digest('base64') === digest.substring(8); +} diff --git a/packages/backend/src/remote/activitypub/db-resolver.ts b/packages/backend/src/remote/activitypub/db-resolver.ts new file mode 100644 index 0000000..122c23f --- /dev/null +++ b/packages/backend/src/remote/activitypub/db-resolver.ts @@ -0,0 +1,247 @@ +import escapeRegexp from "escape-regexp"; +import config from "@/config/index.js"; +import type { Note } from "@/models/entities/note.js"; +import type { + CacheableRemoteUser, + CacheableUser, +} from "@/models/entities/user.js"; +import type { UserPublickey } from "@/models/entities/user-publickey.js"; +import type { MessagingMessage } from "@/models/entities/messaging-message.js"; +import { + Notes, + Users, + UserPublickeys, + MessagingMessages, +} from "@/models/index.js"; +import { Cache } from "@/misc/cache.js"; +import { uriPersonCache, userByIdCache } from "@/services/user-cache.js"; +import type { IObject } from "./type.js"; +import { getApId } from "./type.js"; +import { resolvePerson, updatePerson } from "./models/person.js"; +import {redisClient, subscriber} from "@/db/redis.js"; +import { extractDbHost, toPuny } from "@/misc/convert-host.js"; + +const publicKeyCache = new Cache("publicKey", 60 * 30); +const publicKeyByUserIdCache = new Cache( + "publicKeyByUserId", + 60 * 30, +); + +export type UriParseResult = + | { + /** wether the URI was generated by us */ + local: true; + /** id in DB */ + id: string; + /** hint of type, e.g. "notes", "users" */ + type: string; + /** any remaining text after type and id, not including the slash after id. undefined if empty */ + rest?: string; + } + | { + /** wether the URI was generated by us */ + local: false; + /** uri in DB */ + uri: string; + }; + +export function parseUri(value: string | IObject): UriParseResult { + const uri = getApId(value); + const parsed = new URL(uri); + + if (toPuny(parsed.host) === toPuny(config.host)) { + const localRegex = new RegExp(`^.*?/(\\w+)/(\\w+)(?:/(.+))?`); + const matchLocal = uri.match(localRegex); + if (matchLocal == null) { + throw new Error(`Failed to parse local URI: ${uri}`); + } + + return { + local: true, + type: matchLocal[1], + id: matchLocal[2], + rest: matchLocal[3], + }; + } else { + return { + local: false, + uri, + }; + } +} + +export default class DbResolver { + /** + * AP Note => Misskey Note in DB + */ + public async getNoteFromApId(value: string | IObject): Promise { + const parsed = parseUri(value); + + if (parsed.local) { + if (parsed.type !== "notes") return null; + + return await Notes.findOneBy({ + id: parsed.id, + }); + } else { + return await Notes.findOne({ + where: [ + { + uri: parsed.uri, + }, + { + url: parsed.uri, + }, + ], + }); + } + } + + public async getMessageFromApId( + value: string | IObject, + ): Promise { + const parsed = parseUri(value); + + if (parsed.local) { + if (parsed.type !== "notes") return null; + + return await MessagingMessages.findOneBy({ + id: parsed.id, + }); + } else { + return await MessagingMessages.findOneBy({ + uri: parsed.uri, + }); + } + } + + /** + * AP Person => Misskey User in DB + */ + public async getUserFromApId( + value: string | IObject, + ): Promise { + const parsed = parseUri(value); + + if (parsed.local) { + if (parsed.type !== "users") return null; + + return ( + (await userByIdCache.fetchMaybe( + parsed.id, + () => + Users.findOneBy({ + id: parsed.id, + }).then((x) => x ?? undefined), + true, + )) ?? null + ); + } else { + return await uriPersonCache.fetch( + parsed.uri, + () => + Users.findOneBy({ + uri: parsed.uri, + }), + true, + ); + } + } + + /** + * AP KeyId => Misskey User and Key + */ + public async getAuthUserFromKeyId(keyId: string): Promise<{ + user: CacheableRemoteUser; + key: UserPublickey | null; + } | null> { + const key = await publicKeyCache.fetch( + keyId, + async () => { + const key = await UserPublickeys.findOneBy({ + keyId, + }); + + if (key == null) return null; + + return key; + }, + true, + (key) => key != null, + ); + + if (key == null) return null; + + return { + user: (await userByIdCache.fetch( + key.userId, + () => Users.findOneByOrFail({ id: key.userId }), + true, + )) as CacheableRemoteUser, + key, + }; + } + + /** + * AP Actor id => Misskey User and Key + */ + public async getAuthUserFromApId(uri: string): Promise<{ + user: CacheableRemoteUser; + key: UserPublickey | null; + } | null> { + const user = (await resolvePerson(uri)) as CacheableRemoteUser; + + if (user == null) return null; + + const key = await publicKeyByUserIdCache.fetch( + user.id, + () => UserPublickeys.findOneBy({ userId: user.id }), + true, + (v) => v != null, + ); + + return { + user, + key, + }; + } + + public async refetchPublicKeyForApId(user: CacheableRemoteUser): Promise { + try { + await updatePerson(user.uri!, undefined, undefined, user); + let key = await UserPublickeys.findOneBy({ userId: user.id }); + if (key != null) { + await publicKeyByUserIdCache.set(user.id, key); + } + return key; + } + catch { + return null; + } + } +} + +subscriber.on("message", async (_, data) => { + const obj = JSON.parse(data); + + if (obj.channel === "internal") { + const { type, body } = obj.message; + switch (type) { + case "remoteUserDeleted": + case "localUserDeleted": { + const toDelete = Array.from(await publicKeyByUserIdCache.getAll()) + .filter((v) => v[1]?.userId === body.id) + .map((v) => v[0]); + const toDeleteKey = Array.from(await publicKeyCache.getAll()) + .filter((v) => v[1]?.userId === body.id) + .map((v) => v[0]); + await publicKeyByUserIdCache.delete(...toDelete); + await publicKeyCache.delete(...toDeleteKey); + break; + } + default: + break; + } + } +}); + diff --git a/packages/backend/src/remote/activitypub/deliver-manager.ts b/packages/backend/src/remote/activitypub/deliver-manager.ts new file mode 100644 index 0000000..7f0215e --- /dev/null +++ b/packages/backend/src/remote/activitypub/deliver-manager.ts @@ -0,0 +1,190 @@ +import {IsNull, Not} from "typeorm"; +import {Followings, Users} from "@/models/index.js"; +import type {ILocalUser, IRemoteUser, User} from "@/models/entities/user.js"; +import {deliver} from "@/queue/index.js"; +import {skippedInstances} from "@/misc/skipped-instances.js"; +import {apLogger} from "@/remote/activitypub/logger.js"; + +//#region types +interface IRecipe { + type: string; +} + +interface IFollowersRecipe extends IRecipe { + type: "Followers"; +} + +interface IDirectRecipe extends IRecipe { + type: "Direct"; + to: IRemoteUser; +} + +const isFollowers = (recipe: any): recipe is IFollowersRecipe => + recipe.type === "Followers"; + +const isDirect = (recipe: any): recipe is IDirectRecipe => + recipe.type === "Direct"; +//#endregion + +export default class DeliverManager { + private actor: { id: User["id"]; host: null }; + private activity: any; + private recipes: IRecipe[] = []; + + /** + * Constructor + * @param actor Actor + * @param activity Activity to deliver + */ + constructor(actor: { id: User["id"]; host: null }, activity: any) { + this.actor = actor; + this.activity = activity; + } + + /** + * Add recipe for followers deliver + */ + public addFollowersRecipe() { + const deliver = { + type: "Followers", + } as IFollowersRecipe; + + this.addRecipe(deliver); + } + + /** + * Add recipe for direct deliver + * @param to To + */ + public addDirectRecipe(to: IRemoteUser) { + const recipe = { + type: "Direct", + to, + } as IDirectRecipe; + + this.addRecipe(recipe); + } + + /** + * Add recipe + * @param recipe Recipe + */ + public addRecipe(recipe: IRecipe) { + this.recipes.push(recipe); + } + + /** + * Execute delivers + */ + public async execute() { + if (!Users.isLocalUser(this.actor)) return; + + const inboxes = new Set(); + + /* + build inbox list + + Process follower recipes first to avoid duplication when processing + direct recipes later. + */ + if (this.recipes.some((r) => isFollowers(r))) { + // followers deliver + // TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう + // ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう? + const followers = (await Followings.find({ + where: { + followeeId: this.actor.id, + followerHost: Not(IsNull()), + }, + select: { + followerSharedInbox: true, + followerInbox: true, + }, + })) as { + followerSharedInbox: string | null; + followerInbox: string; + }[]; + + for (const following of followers) { + const inbox = following.followerSharedInbox || following.followerInbox; + inboxes.add(inbox); + } + } + + this.recipes + .filter( + (recipe): recipe is IDirectRecipe => + // followers recipes have already been processed + isDirect(recipe) && + // check that shared inbox has not been added yet + !(recipe.to.sharedInbox && inboxes.has(recipe.to.sharedInbox)) && + // check that they actually have an inbox + recipe.to.inbox != null, + ) + .forEach((recipe) => inboxes.add(recipe.to.inbox!)); + + const instancesToSkip = await skippedInstances( + // get (unique) list of hosts + Array.from( + new Set(Array.from(inboxes).map((inbox) => { + try { + return new URL(inbox).host; + } catch (e) { + apLogger.error(`Invalid inbox URL: ${inbox}`); + return null; + } + } ).filter((host) => host != null) as string[]), + ), + ); + + // deliver + for (const inbox of inboxes) { + // skip instances as indicated + + try { + const host = new URL(inbox).host; + if (instancesToSkip.includes(host)) + continue; + + } catch (e) { + // skip invalid URLs + apLogger.error(`Invalid inbox URL: ${inbox}`); + continue; + } + + + deliver(this.actor, this.activity, inbox); + } + } +} + +//#region Utilities +/** + * Deliver activity to followers + * @param activity Activity + * @param from Followee + */ +export async function deliverToFollowers( + actor: { id: ILocalUser["id"]; host: null }, + activity: any, +) { + const manager = new DeliverManager(actor, activity); + manager.addFollowersRecipe(); + await manager.execute(); +} + +/** + * Deliver activity to user + * @param activity Activity + * @param to Target user + */ +export async function deliverToUser( + actor: { id: ILocalUser["id"]; host: null }, + activity: any, + to: IRemoteUser, +) { + const manager = new DeliverManager(actor, activity); + manager.addDirectRecipe(to); + await manager.execute(); +} +//#endregion diff --git a/packages/backend/src/remote/activitypub/kernel/accept/follow.ts b/packages/backend/src/remote/activitypub/kernel/accept/follow.ts new file mode 100644 index 0000000..e430bbf --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/accept/follow.ts @@ -0,0 +1,32 @@ +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import accept from "@/services/following/requests/accept.js"; +import type { IFollow } from "../../type.js"; +import DbResolver from "../../db-resolver.js"; +import { relayAccepted } from "@/services/relay.js"; + +export default async ( + actor: CacheableRemoteUser, + activity: IFollow, +): Promise => { + // ※ activityはこっちから投げたフォローリクエストなので、activity.actorは存在するローカルユーザーである必要がある + + const dbResolver = new DbResolver(); + const follower = await dbResolver.getUserFromApId(activity.actor); + + if (follower == null) { + return "skip: follower not found"; + } + + if (follower.host != null) { + return "skip: follower is not a local user"; + } + + // relay + const match = activity.id?.match(/follow-relay\/(\w+)/); + if (match) { + return await relayAccepted(match[1]); + } + + await accept(actor, follower); + return "ok"; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/accept/index.ts b/packages/backend/src/remote/activitypub/kernel/accept/index.ts new file mode 100644 index 0000000..d33025b --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/accept/index.ts @@ -0,0 +1,30 @@ +import Resolver from "../../resolver.js"; +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import acceptFollow from "./follow.js"; +import type { IAccept } from "../../type.js"; +import { isFollow, getApType, isQuoteRequest } from "../../type.js"; +import { apLogger } from "../../logger.js"; +import { acceptQuoteRequest } from "./quote-request.js"; + +const logger = apLogger; + +export default async ( + actor: CacheableRemoteUser, + activity: IAccept, +): Promise => { + const uri = activity.id || activity; + + logger.info(`Accept: ${uri}`); + + const resolver = new Resolver(); + + const object = await resolver.resolve(activity.object).catch((e) => { + logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isFollow(object)) return await acceptFollow(actor, object); + else if (isQuoteRequest(object)) return await acceptQuoteRequest(actor, object, activity.result); + + return `skip: Unknown Accept type: ${getApType(object)}`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/accept/quote-request.ts b/packages/backend/src/remote/activitypub/kernel/accept/quote-request.ts new file mode 100644 index 0000000..7fb1651 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/accept/quote-request.ts @@ -0,0 +1,51 @@ +import { CacheableRemoteUser, ILocalUser } from "@/models/entities/user.js"; +import { IQuoteRequest } from "../../type.js"; +import { resolveNote } from "../../models/note.js"; +import { Notes } from "@/models/index.js"; +import { parseUri } from "../../db-resolver.js"; +import edit from "@/services/note/edit.js"; +import { toPuny } from "@/misc/convert-host.js"; + +export async function acceptQuoteRequest( + actor: CacheableRemoteUser, + activity: IQuoteRequest, + result: string | undefined, +): Promise { + if (!result) return "skip: missing result"; + const localParsed = parseUri(activity.instrument); + if (!localParsed.local) return "skip: note not local"; + const resultUrl = new URL(result); + if (toPuny(resultUrl.host) !== actor.host) { + return "skip: result not on same host as actor"; + } + + const [note, targetNote] = await Promise.all([ + Notes.findOne({ where: { id: localParsed.id }, relations: ["user"] }), + resolveNote(activity.object), + ]); + + if (note === null) return "skip: note not found"; + if (targetNote === null) return "skip: target note not found"; + if (targetNote.userId !== actor.id) + return "skip: tried to authorize note without ownership"; + if (note.renoteId === null) return "skip: note not renote"; + if (note.renoteId !== targetNote.id) return "skip: note not renoting target"; + if (note.quoteAuthorization !== null) + return "skip: quote already authorizated"; + if (note.text == null && note.cw == null && !note.hasPoll && note.fileIds.length === 0) + return "skip: note is plain renote"; + + note.quoteAuthorization = result; + await Notes.update( + { id: note.id }, + { quoteAuthorization: note.quoteAuthorization }, + ); + + await edit(note.user as unknown as ILocalUser, note, { + text: note.text, + cw: note.cw, + quoteAuthorization: result, + }, true); + + return "ok"; +} diff --git a/packages/backend/src/remote/activitypub/kernel/add/index.ts b/packages/backend/src/remote/activitypub/kernel/add/index.ts new file mode 100644 index 0000000..4a8a5db --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/add/index.ts @@ -0,0 +1,32 @@ +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import type { IAdd } from "../../type.js"; +import { resolveNote } from "../../models/note.js"; +import { addPinned } from "@/services/i/pin.js"; +import Resolver from "../../resolver.js"; +import { Users } from "@/models/index.js"; + +export default async ( + actor: CacheableRemoteUser, + activity: IAdd, +): Promise => { + if ("actor" in activity && actor.uri !== activity.actor) { + throw new Error("invalid actor"); + } + + if (activity.target == null) { + throw new Error("target is null"); + } + + if (activity.target === actor.featured) { + const resolver = new Resolver(); + const follower = await Users.getRandomFollower(actor.id); + if (follower) resolver.setUser(follower); + + const note = await resolveNote(activity.object, resolver); + if (note == null) throw new Error("note not found"); + await addPinned(actor, note.id); + return "ok"; + } + + throw new Error(`unknown target: ${activity.target}`); +}; diff --git a/packages/backend/src/remote/activitypub/kernel/announce/index.ts b/packages/backend/src/remote/activitypub/kernel/announce/index.ts new file mode 100644 index 0000000..b1610ee --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/announce/index.ts @@ -0,0 +1,23 @@ +import Resolver from "../../resolver.js"; +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import announceNote from "./note.js"; +import type { IAnnounce } from "../../type.js"; +import { getApId } from "../../type.js"; +import { apLogger } from "../../logger.js"; + +const logger = apLogger; + +export default async ( + actor: CacheableRemoteUser, + activity: IAnnounce, +): Promise => { + const uri = getApId(activity); + + logger.info(`Announce: ${uri}`); + + const resolver = new Resolver(); + + const targetUri = getApId(activity.object); + + return announceNote(resolver, actor, activity, targetUri); +}; diff --git a/packages/backend/src/remote/activitypub/kernel/announce/note.ts b/packages/backend/src/remote/activitypub/kernel/announce/note.ts new file mode 100644 index 0000000..2ee8e4b --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/announce/note.ts @@ -0,0 +1,85 @@ +import type Resolver from "../../resolver.js"; +import post from "@/services/note/create.js"; +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import type { IAnnounce } from "../../type.js"; +import { getApId } from "../../type.js"; +import { fetchNote, resolveNote } from "../../models/note.js"; +import { apLogger } from "../../logger.js"; +import { extractDbHost } from "@/misc/convert-host.js"; +import { getApLock } from "@/misc/app-lock.js"; +import { parseAudience } from "../../audience.js"; +import { StatusError } from "@/misc/fetch.js"; +import { Notes } from "@/models/index.js"; +import { shouldBlockInstance } from "@/misc/should-block-instance.js"; + +const logger = apLogger; + +/** + * Handle announcement activities + */ +export default async function ( + resolver: Resolver, + actor: CacheableRemoteUser, + activity: IAnnounce, + targetUri: string, +): Promise { + const uri = getApId(activity); + + if (actor.isSuspended) { + return "skip: actor is suspended"; + } + + // Interrupt if you block the announcement destination + if (await shouldBlockInstance(extractDbHost(uri))) return "skip: instance is blocked"; + + const unlock = await getApLock(uri); + + try { + // Check if something with the same URI is already registered + const exist = await fetchNote(uri); + if (exist) { + return "skip: note exists"; + } + + // Resolve Announce target + let renote; + try { + renote = await resolveNote(targetUri); + } catch (e) { + // Skip if target is 4xx + if (e instanceof StatusError) { + if (!e.isRetryable) { + logger.warn(`Ignored announce target ${targetUri} - ${e.statusCode}`); + return "skip: failed fetching note"; + } + + logger.warn( + `Error in announce target ${targetUri} - ${e.statusCode || e}`, + ); + } + throw e; + } + + if (!(await Notes.isVisibleForMe(renote, actor.id))) + return "skip: invalid actor for this activity"; + + logger.info(`Creating the (Re)Note: ${uri}`); + + const activityAudience = await parseAudience( + actor, + activity.to, + activity.cc, + ); + + await post(actor, { + createdAt: activity.published ? new Date(activity.published) : null, + renote, + visibility: activityAudience.visibility, + visibleUsers: activityAudience.visibleUsers, + uri, + }); + return "ok"; + } finally { + unlock(); + } +} diff --git a/packages/backend/src/remote/activitypub/kernel/bite.ts b/packages/backend/src/remote/activitypub/kernel/bite.ts new file mode 100644 index 0000000..85d0fc3 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/bite.ts @@ -0,0 +1,82 @@ +import { CacheableRemoteUser } from "@/models/entities/user.js"; +import { IBite } from "../type.js"; +import Resolver from "../resolver.js"; +import { fetchPerson } from "../models/person.js"; +import config from "@/config/index.js"; +import { createBite } from "@/services/create-bite.js"; +import { tickBiteIncoming } from "@/metrics.js"; +import { getNote } from "@/server/api/common/getters.js"; +import { parseUri } from "../db-resolver.js"; + +export default async ( + actor: CacheableRemoteUser, + bite: IBite, +): Promise => { + if (actor.uri !== bite.actor) { + return "skip: actor uri mismatch"; + } + + if (bite.id === null) { + return "skip: bite id not specified"; + } + + const resolver = new Resolver(); + const biteActor = await fetchPerson(bite.actor, resolver); + if (biteActor === null) { + return "skip: biteActor is null"; + } + const targetParsed = parseUri(bite.target); + if (!targetParsed.local) { + return "skip: target is not local"; + } + + const targetDbId = targetParsed.id; + const targetPathType = targetParsed.type; + + let targetType: "user" | "bite" | "note"; + let targetId; + let fallback = false; + + if (targetPathType === "users") { + targetType = "user"; + targetId = targetDbId; + } else if (targetPathType === "bites") { + targetType = "bite"; + targetId = targetDbId; + } else if (targetPathType === "notes") { + targetType = "note"; + targetId = targetDbId; + try { + await getNote(targetDbId!, actor); + } catch (err: any) { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") { + // note either doesn't exist or the remote user shouldn't be able to access it + fallback = true; + } + } + } else { + fallback = true; + } + if (fallback) { + // fallback for unknown object types + targetType = "user"; + if (bite.to !== undefined) { + const to = Array.isArray(bite.to) ? bite.to[0] : bite.to; + targetId = (to as string).split("/").pop(); + } else { + return "skip: unknown type missing to field"; + } + } + + await createBite( + biteActor, + targetType!, + targetId!, + bite.id!, + bite.published ? new Date(bite.published) : null, + ); + + tickBiteIncoming(); + + return "ok"; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/block/index.ts b/packages/backend/src/remote/activitypub/kernel/block/index.ts new file mode 100644 index 0000000..4dc868b --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/block/index.ts @@ -0,0 +1,29 @@ +import type { IBlock } from "../../type.js"; +import block from "@/services/blocking/create.js"; +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import DbResolver from "../../db-resolver.js"; +import { Users } from "@/models/index.js"; + +export default async ( + actor: CacheableRemoteUser, + activity: IBlock, +): Promise => { + // ※ There is a block target in activity.object, which should be a local user that exists. + + const dbResolver = new DbResolver(); + const blockee = await dbResolver.getUserFromApId(activity.object); + + if (blockee == null) { + return "skip: blockee not found"; + } + + if (blockee.host != null) { + return "skip: The user you are trying to block is not a local user"; + } + + await block( + await Users.findOneByOrFail({ id: actor.id }), + await Users.findOneByOrFail({ id: blockee.id }), + ); + return "ok"; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/create/index.ts b/packages/backend/src/remote/activitypub/kernel/create/index.ts new file mode 100644 index 0000000..ec7e55b --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/create/index.ts @@ -0,0 +1,52 @@ +import Resolver from "../../resolver.js"; +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import createNote from "./note.js"; +import type { ICreate } from "../../type.js"; +import { getApId, isPost, getApType } from "../../type.js"; +import { apLogger } from "../../logger.js"; +import { toArray, concat, unique } from "@/prelude/array.js"; + +const logger = apLogger; + +export default async ( + actor: CacheableRemoteUser, + activity: ICreate, +): Promise => { + const uri = getApId(activity); + + logger.info(`Create: ${uri}`); + + // copy audiences between activity <=> object. + if (typeof activity.object === "object") { + const to = unique( + concat([toArray(activity.to), toArray(activity.object.to)]), + ); + const cc = unique( + concat([toArray(activity.cc), toArray(activity.object.cc)]), + ); + + activity.to = to; + activity.cc = cc; + activity.object.to = to; + activity.object.cc = cc; + } + + // If there is no attributedTo, use Activity actor. + if (typeof activity.object === "object" && !activity.object.attributedTo) { + activity.object.attributedTo = activity.actor; + } + + const resolver = new Resolver(); + + const object = await resolver.resolve(activity.object).catch((e) => { + logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isPost(object)) { + return createNote(resolver, actor, object, false, activity); + } else { + logger.warn(`Unknown type: ${getApType(object)}`); + return "skip: unknown create type"; + } +}; diff --git a/packages/backend/src/remote/activitypub/kernel/create/note.ts b/packages/backend/src/remote/activitypub/kernel/create/note.ts new file mode 100644 index 0000000..a39a316 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/create/note.ts @@ -0,0 +1,54 @@ +import type Resolver from "../../resolver.js"; +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import { createNote, fetchNote } from "../../models/note.js"; +import type { IObject, ICreate } from "../../type.js"; +import { getApId } from "../../type.js"; +import { getApLock } from "@/misc/app-lock.js"; +import { extractDbHost } from "@/misc/convert-host.js"; +import { StatusError } from "@/misc/fetch.js"; + +/** + * Handle post creation activity + */ +export default async function ( + resolver: Resolver, + actor: CacheableRemoteUser, + note: IObject, + silent = false, + activity?: ICreate, +): Promise { + const uri = getApId(note); + + if (typeof note === "object") { + if (actor.uri !== note.attributedTo) { + return "skip: actor.uri !== note.attributedTo"; + } + + if (typeof note.id === "string") { + if (extractDbHost(actor.uri) !== extractDbHost(note.id)) { + return "skip: host in actor.uri !== note.id"; + } + } + else { + return "skip: note.id is not a string"; + } + } + + const unlock = await getApLock(uri); + + try { + const exist = await fetchNote(note); + if (exist) return "skip: note exists"; + + await createNote(note, resolver, silent); + return "ok"; + } catch (e) { + if (e instanceof StatusError && !e.isRetryable) { + return `skip ${e.statusCode}`; + } else { + throw e; + } + } finally { + unlock(); + } +} diff --git a/packages/backend/src/remote/activitypub/kernel/delete/actor.ts b/packages/backend/src/remote/activitypub/kernel/delete/actor.ts new file mode 100644 index 0000000..83c6442 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/delete/actor.ts @@ -0,0 +1,32 @@ +import { apLogger } from "../../logger.js"; +import { createDeleteAccountJob } from "@/queue/index.js"; +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import { Users } from "@/models/index.js"; + +const logger = apLogger; + +export async function deleteActor( + actor: CacheableRemoteUser, + uri: string, +): Promise { + logger.info(`Deleting the Actor: ${uri}`); + + if (actor.uri !== uri) { + return `skip: delete actor ${actor.uri} !== ${uri}`; + } + + const user = await Users.findOneBy({ id: actor.id }); + if (!user) { + return `skip: actor ${actor.id} not found in the local database`; + } else if (user.isDeleted) { + return `skip: user ${user.id} already deleted`; + } + + const job = await createDeleteAccountJob(actor); + + await Users.update(actor.id, { + isDeleted: true, + }); + + return `ok: queued ${job.name} ${job.id}`; +} diff --git a/packages/backend/src/remote/activitypub/kernel/delete/index.ts b/packages/backend/src/remote/activitypub/kernel/delete/index.ts new file mode 100644 index 0000000..f9ad52d --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/delete/index.ts @@ -0,0 +1,55 @@ +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import { toSingle } from "@/prelude/array.js"; +import { getApId, isTombstone, validPost, validActor } from "../../type.js"; +import deleteNote from "./note.js"; +import { deleteActor } from "./actor.js"; +import type { IDelete, IObject } from "../../type.js"; + +/** + * Handle delete activity + */ +export default async ( + actor: CacheableRemoteUser, + activity: IDelete, +): Promise => { + if ("actor" in activity && actor.uri !== activity.actor) { + throw new Error("invalid actor"); + } + + // Type of object to be deleted + let formerType: string | undefined; + + if (typeof activity.object === "string") { + // The type is unknown, but it has disappeared + // anyway, so it does not remote resolve + formerType = undefined; + } else { + const object = activity.object as IObject; + if (isTombstone(object)) { + formerType = toSingle(object.formerType); + } else { + formerType = toSingle(object.type); + } + } + + const uri = getApId(activity.object); + + // Even if type is unknown, if actor and object are the same, + // it must be `Person`. + if (!formerType && actor.uri === uri) { + formerType = "Person"; + } + + // If not, fallback to `Note`. + if (!formerType) { + formerType = "Note"; + } + + if (validPost.includes(formerType)) { + return await deleteNote(actor, uri); + } else if (validActor.includes(formerType)) { + return await deleteActor(actor, uri); + } else { + return `Unknown type ${formerType}`; + } +}; diff --git a/packages/backend/src/remote/activitypub/kernel/delete/note.ts b/packages/backend/src/remote/activitypub/kernel/delete/note.ts new file mode 100644 index 0000000..69298e9 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/delete/note.ts @@ -0,0 +1,44 @@ +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import deleteNode from "@/services/note/delete.js"; +import { apLogger } from "../../logger.js"; +import DbResolver from "../../db-resolver.js"; +import { getApLock } from "@/misc/app-lock.js"; +import { deleteMessage } from "@/services/messages/delete.js"; + +const logger = apLogger; + +export default async function ( + actor: CacheableRemoteUser, + uri: string, +): Promise { + logger.info(`Deleting the Note: ${uri}`); + + const unlock = await getApLock(uri); + + try { + const dbResolver = new DbResolver(); + const note = await dbResolver.getNoteFromApId(uri); + + if (note == null) { + const message = await dbResolver.getMessageFromApId(uri); + if (message == null) return "message not found"; + + if (message.userId !== actor.id) { + return "The user trying to delete the post is not the post author"; + } + + await deleteMessage(message); + + return "ok: message deleted"; + } + + if (note.userId !== actor.id) { + return "The user trying to delete the post is not the post author"; + } + + await deleteNode(actor, note); + return "ok: note deleted"; + } finally { + unlock(); + } +} diff --git a/packages/backend/src/remote/activitypub/kernel/flag/index.ts b/packages/backend/src/remote/activitypub/kernel/flag/index.ts new file mode 100644 index 0000000..39ba8b3 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/flag/index.ts @@ -0,0 +1,37 @@ +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import config from "@/config/index.js"; +import type { IFlag } from "../../type.js"; +import { getApIds } from "../../type.js"; +import { AbuseUserReports, Users } from "@/models/index.js"; +import { In } from "typeorm"; +import { genId } from "@/misc/gen-id.js"; + +export default async ( + actor: CacheableRemoteUser, + activity: IFlag, +): Promise => { + // The object is `(User | Note) | (User | Note) []`, but it cannot be + // matched with all patterns of the DB schema, so the target user is the first + // user and it is stored as a comment. + const uris = getApIds(activity.object); + + const userIds = uris + .filter((uri) => uri.startsWith(`${config.url}/users/`)) + .map((uri) => uri.split("/").pop()!); + const users = await Users.findBy({ + id: In(userIds), + }); + if (users.length < 1) return "skip"; + + await AbuseUserReports.insert({ + id: genId(), + createdAt: new Date(), + targetUserId: users[0].id, + targetUserHost: users[0].host, + reporterId: actor.id, + reporterHost: actor.host, + comment: `${activity.content}\n${JSON.stringify(uris, null, 2)}`, + }); + + return "ok"; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/follow.ts b/packages/backend/src/remote/activitypub/kernel/follow.ts new file mode 100644 index 0000000..1c1ef36 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/follow.ts @@ -0,0 +1,23 @@ +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import follow from "@/services/following/create.js"; +import type { IFollow } from "../type.js"; +import DbResolver from "../db-resolver.js"; + +export default async ( + actor: CacheableRemoteUser, + activity: IFollow, +): Promise => { + const dbResolver = new DbResolver(); + const followee = await dbResolver.getUserFromApId(activity.object); + + if (followee == null) { + return "skip: followee not found"; + } + + if (followee.host != null) { + return "skip: user you are trying to follow is not a local user"; + } + + await follow(actor, followee, activity.id); + return "ok"; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/index.ts b/packages/backend/src/remote/activitypub/kernel/index.ts new file mode 100644 index 0000000..3657586 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/index.ts @@ -0,0 +1,106 @@ +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import { + isCreate, + isDelete, + isUpdate, + isRead, + isFollow, + isAccept, + isReject, + isAdd, + isRemove, + isAnnounce, + isLike, + isUndo, + isBlock, + isCollectionOrOrderedCollection, + isFlag, + isMove, + getApId, + isBite, + isQuoteRequest, +} from "../type.js"; +import { apLogger } from "../logger.js"; +import create from "./create/index.js"; +import performDeleteActivity from "./delete/index.js"; +import performUpdateActivity from "./update/index.js"; +import { performReadActivity } from "./read.js"; +import follow from "./follow.js"; +import undo from "./undo/index.js"; +import like from "./like.js"; +import announce from "./announce/index.js"; +import accept from "./accept/index.js"; +import reject from "./reject/index.js"; +import add from "./add/index.js"; +import remove from "./remove/index.js"; +import block from "./block/index.js"; +import flag from "./flag/index.js"; +import move from "./move/index.js"; +import bite from "./bite.js"; +import quoteRequest from "./quote-request.js"; +import type { IObject } from "../type.js"; +import { extractDbHost } from "@/misc/convert-host.js"; +import { shouldBlockInstance } from "@/misc/should-block-instance.js"; + +export async function performActivity( + actor: CacheableRemoteUser, + activity: IObject, +): Promise { + if (isCollectionOrOrderedCollection(activity)) { + apLogger.debug('Refusing to ingest collection as activity'); + return "skip: activity is collection"; + } else { + return await performOneActivity(actor, activity); + } +} + +async function performOneActivity( + actor: CacheableRemoteUser, + activity: IObject, +): Promise { + if (actor.isSuspended) return "skip: actor suspended"; + + if (typeof activity.id !== "undefined") { + const host = extractDbHost(getApId(activity)); + if (await shouldBlockInstance(host)) return "skip: instance blocked"; + } + + if (isCreate(activity)) { + return await create(actor, activity); + } else if (isDelete(activity)) { + return await performDeleteActivity(actor, activity); + } else if (isUpdate(activity)) { + return await performUpdateActivity(actor, activity); + } else if (isRead(activity)) { + return await performReadActivity(actor, activity); + } else if (isFollow(activity)) { + return await follow(actor, activity); + } else if (isAccept(activity)) { + return await accept(actor, activity); + } else if (isReject(activity)) { + return await reject(actor, activity); + } else if (isAdd(activity)) { + return await add(actor, activity).catch((err) => { apLogger.error(err); return `skip: ${err}` }); + } else if (isRemove(activity)) { + return await remove(actor, activity).catch((err) => { apLogger.error(err);return `skip: ${err}` }); + } else if (isAnnounce(activity)) { + return await announce(actor, activity); + } else if (isLike(activity)) { + return await like(actor, activity); + } else if (isUndo(activity)) { + return await undo(actor, activity); + } else if (isBlock(activity)) { + return await block(actor, activity); + } else if (isFlag(activity)) { + return await flag(actor, activity); + } else if (isMove(activity)) { + return await move(actor, activity); + } else if (isBite(activity)) { + return await bite(actor, activity); + } else if (isQuoteRequest(activity)) { + return await quoteRequest(actor, activity); + } else { + apLogger.warn(`unrecognized activity type: ${(activity as any).type}`); + return `skip: unrecognized activity type: ${(activity as any).type}`; + } +} diff --git a/packages/backend/src/remote/activitypub/kernel/like.ts b/packages/backend/src/remote/activitypub/kernel/like.ts new file mode 100644 index 0000000..abc66c3 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/like.ts @@ -0,0 +1,28 @@ +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import type { ILike } from "../type.js"; +import { getApId } from "../type.js"; +import create from "@/services/note/reaction/create.js"; +import { fetchNote, extractEmojis } from "../models/note.js"; + +export default async (actor: CacheableRemoteUser, activity: ILike): Promise => { + const targetUri = getApId(activity.object); + + const note = await fetchNote(targetUri); + if (!note) return `skip: target note not found ${targetUri}`; + + await extractEmojis(activity.tag || [], actor.host).catch(() => null); + + return await create( + actor, + note, + activity._misskey_reaction || activity.content || activity.name, + ) + .catch((e) => { + if (e.id === "51c42bb4-931a-456b-bff7-e5a8a70dd298") { + return "skip: already reacted"; + } else { + throw e; + } + }) + .then(() => "ok"); +}; diff --git a/packages/backend/src/remote/activitypub/kernel/move/index.ts b/packages/backend/src/remote/activitypub/kernel/move/index.ts new file mode 100644 index 0000000..800fd7b --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/move/index.ts @@ -0,0 +1,69 @@ +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import { Followings, Users } from "@/models/index.js"; +import { + resolvePerson, + updatePerson, +} from "@/remote/activitypub/models/person.js"; +import create from "@/services/following/create.js"; +import deleteFollowing from "@/services/following/delete.js"; + +import type { IMove } from "../../type.js"; +import { getApHrefNullable } from "../../type.js"; + +export default async ( + actor: CacheableRemoteUser, + activity: IMove, +): Promise => { + // ※ There is a block target in activity.object, which should be a local user that exists. + + // fetch the new and old accounts + const targetUri = getApHrefNullable(activity.target); + if (!targetUri) return "move: target uri is null"; + let new_acc = await resolvePerson(targetUri); + if (!actor.uri) return "move: actor uri is null"; + let old_acc = await resolvePerson(actor.uri); + + // update them if they're remote + if (new_acc.uri) await updatePerson(new_acc.uri); + if (old_acc.uri) await updatePerson(old_acc.uri); + + // retrieve updated users + new_acc = await resolvePerson(targetUri); + old_acc = await resolvePerson(actor.uri); + + // check if alsoKnownAs of the new account is valid + let isValidMove = true; + if (old_acc.uri) { + if (!new_acc.alsoKnownAs?.includes(old_acc.uri)) { + isValidMove = false; + } + } else if (!new_acc.alsoKnownAs?.includes(old_acc.id)) { + isValidMove = false; + } + if (!isValidMove) { + return "skip: accounts invalid"; + } + + // add target uri to movedToUri in order to indicate that the user has moved + await Users.update(old_acc.id, { movedToUri: targetUri }); + + // follow the new account and unfollow the old one + const followings = await Followings.findBy({ + followeeId: old_acc.id, + }); + followings.forEach(async (following) => { + // If follower is local + if (!following.followerHost) { + try { + const follower = await Users.findOneBy({ id: following.followerId }); + if (!follower) return; + await create(follower, new_acc); + await deleteFollowing(follower, old_acc); + } catch { + /* empty */ + } + } + }); + + return "ok"; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/quote-request.ts b/packages/backend/src/remote/activitypub/kernel/quote-request.ts new file mode 100644 index 0000000..e344878 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/quote-request.ts @@ -0,0 +1,55 @@ +import { CacheableRemoteUser } from "@/models/entities/user"; +import type { IQuoteRequest } from "../type.js"; +import { resolveNote } from "../models/note.js"; +import renderAcceptQuoteRequest from "@/remote/activitypub/renderer/accept-quote-request.js"; +import { deliverToUser } from "../deliver-manager.js"; +import { renderActivity } from "../renderer/index.js"; +import { parseUri } from "../db-resolver.js"; +import { InteractionStamps, Notes } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; + +export default async ( + actor: CacheableRemoteUser, + activity: IQuoteRequest, +): Promise => { + const localParsed = parseUri(activity.object); + if (!localParsed.local) return "skip: local note not local"; + + const [note, targetNote] = await Promise.all([ + resolveNote(activity.instrument), + Notes.findOneBy({ id: localParsed.id }), + ]); + + if (note === null) return "skip: note not found"; + if (note.userId !== actor.id) return "skip: actor is requesting authorization for a quote they didn't make"; + if (targetNote === null) return "skip: target note not found"; + if (!await Notes.isVisibleForMe(targetNote, actor.id)) return "skip: target note is not visible for remote user" + + let stamp = await InteractionStamps.findOneBy({ + noteId: note.id, + targetNoteId: targetNote.id, + }); + + if (stamp === null) { + stamp = { + id: genId(), + type: "quote", + noteId: note.id, + targetNoteId: targetNote.id, + }; + await InteractionStamps.insert(stamp); + } + + stamp.note = note; + stamp.targetNote = targetNote; + + await deliverToUser( + { + id: targetNote.userId, + host: null, + }, + renderActivity(await renderAcceptQuoteRequest(activity, stamp)), + actor, + ); + return "ok"; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/read.ts b/packages/backend/src/remote/activitypub/kernel/read.ts new file mode 100644 index 0000000..7cc7097 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/read.ts @@ -0,0 +1,33 @@ +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import type { IRead } from "../type.js"; +import { getApId } from "../type.js"; +import { isSelfHost, extractDbHost } from "@/misc/convert-host.js"; +import { MessagingMessages } from "@/models/index.js"; +import { readUserMessagingMessage } from "../../../server/api/common/read-messaging-message.js"; + +export const performReadActivity = async ( + actor: CacheableRemoteUser, + activity: IRead, +): Promise => { + const id = await getApId(activity.object); + + if (!isSelfHost(extractDbHost(id))) { + return `skip: Read to foreign host (${id})`; + } + + const messageId = id.split("/").pop(); + + const message = await MessagingMessages.findOneBy({ id: messageId }); + if (message == null) { + return "skip: message not found"; + } + + if (actor.id !== message.recipientId) { + return "skip: actor is not a message recipient"; + } + + await readUserMessagingMessage(message.recipientId!, message.userId, [ + message.id, + ]); + return `ok: mark as read (${message.userId} => ${message.recipientId} ${message.id})`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/reject/follow.ts b/packages/backend/src/remote/activitypub/kernel/reject/follow.ts new file mode 100644 index 0000000..670c155 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/reject/follow.ts @@ -0,0 +1,33 @@ +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import { remoteReject } from "@/services/following/reject.js"; +import type { IFollow } from "../../type.js"; +import DbResolver from "../../db-resolver.js"; +import { relayRejected } from "@/services/relay.js"; +import { Users } from "@/models/index.js"; + +export default async ( + actor: CacheableRemoteUser, + activity: IFollow, +): Promise => { + // ※ `activity.actor` must be an existing local user, since `activity` is a follow request thrown from us. + + const dbResolver = new DbResolver(); + const follower = await dbResolver.getUserFromApId(activity.actor); + + if (follower == null) { + return "skip: follower not found"; + } + + if (!Users.isLocalUser(follower)) { + return "skip: follower is not a local user"; + } + + // relay + const match = activity.id?.match(/follow-relay\/(\w+)/); + if (match) { + return await relayRejected(match[1]); + } + + await remoteReject(actor, follower); + return "ok"; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/reject/index.ts b/packages/backend/src/remote/activitypub/kernel/reject/index.ts new file mode 100644 index 0000000..10edb0f --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/reject/index.ts @@ -0,0 +1,28 @@ +import Resolver from "../../resolver.js"; +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import rejectFollow from "./follow.js"; +import type { IReject } from "../../type.js"; +import { isFollow, getApType } from "../../type.js"; +import { apLogger } from "../../logger.js"; + +const logger = apLogger; + +export default async ( + actor: CacheableRemoteUser, + activity: IReject, +): Promise => { + const uri = activity.id || activity; + + logger.info(`Reject: ${uri}`); + + const resolver = new Resolver(); + + const object = await resolver.resolve(activity.object).catch((e) => { + logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isFollow(object)) return await rejectFollow(actor, object); + + return `skip: Unknown Reject type: ${getApType(object)}`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/remove/index.ts b/packages/backend/src/remote/activitypub/kernel/remove/index.ts new file mode 100644 index 0000000..9a82b92 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/remove/index.ts @@ -0,0 +1,26 @@ +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import type { IRemove } from "../../type.js"; +import { fetchNote } from "../../models/note.js"; +import { removePinned } from "@/services/i/pin.js"; + +export default async ( + actor: CacheableRemoteUser, + activity: IRemove, +): Promise => { + if ("actor" in activity && actor.uri !== activity.actor) { + throw new Error("invalid actor"); + } + + if (activity.target == null) { + throw new Error("target is null"); + } + + if (activity.target === actor.featured) { + const note = await fetchNote(activity.object); + if (note == null) return "skip: note not found"; // not pinned either way + await removePinned(actor, note.id); + return "ok"; + } + + throw new Error(`unknown target: ${activity.target}`); +}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/accept.ts b/packages/backend/src/remote/activitypub/kernel/undo/accept.ts new file mode 100644 index 0000000..2cd05a7 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/undo/accept.ts @@ -0,0 +1,30 @@ +import unfollow from "@/services/following/delete.js"; +import cancelRequest from "@/services/following/requests/cancel.js"; +import type { IAccept } from "../../type.js"; +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import { Followings } from "@/models/index.js"; +import DbResolver from "../../db-resolver.js"; + +export default async ( + actor: CacheableRemoteUser, + activity: IAccept, +): Promise => { + const dbResolver = new DbResolver(); + + const follower = await dbResolver.getUserFromApId(activity.object); + if (follower == null) { + return "skip: follower not found"; + } + + const following = await Followings.findOneBy({ + followerId: follower.id, + followeeId: actor.id, + }); + + if (following) { + await unfollow(follower, actor); + return "ok: unfollowed"; + } + + return "skip: skip: not followed"; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/announce.ts b/packages/backend/src/remote/activitypub/kernel/undo/announce.ts new file mode 100644 index 0000000..a6e9c88 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/undo/announce.ts @@ -0,0 +1,22 @@ +import { Notes } from "@/models/index.js"; +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import type { IAnnounce } from "../../type.js"; +import { getApId } from "../../type.js"; +import deleteNote from "@/services/note/delete.js"; + +export const undoAnnounce = async ( + actor: CacheableRemoteUser, + activity: IAnnounce, +): Promise => { + const uri = getApId(activity); + + const note = await Notes.findOneBy({ + uri, + userId: actor.id, + }); + + if (!note) return "skip: no such Announce"; + + await deleteNote(actor, note); + return "ok: deleted"; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/block.ts b/packages/backend/src/remote/activitypub/kernel/undo/block.ts new file mode 100644 index 0000000..b4e1d8e --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/undo/block.ts @@ -0,0 +1,24 @@ +import type { IBlock } from "../../type.js"; +import unblock from "@/services/blocking/delete.js"; +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import DbResolver from "../../db-resolver.js"; +import { Users } from "@/models/index.js"; + +export default async ( + actor: CacheableRemoteUser, + activity: IBlock, +): Promise => { + const dbResolver = new DbResolver(); + const blockee = await dbResolver.getUserFromApId(activity.object); + + if (blockee == null) { + return "skip: blockee not found"; + } + + if (blockee.host != null) { + return "skip: The user you are trying to unblock is not a local user"; + } + + await unblock(await Users.findOneByOrFail({ id: actor.id }), blockee); + return "ok"; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/follow.ts b/packages/backend/src/remote/activitypub/kernel/undo/follow.ts new file mode 100644 index 0000000..1c4648c --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/undo/follow.ts @@ -0,0 +1,44 @@ +import unfollow from "@/services/following/delete.js"; +import cancelRequest from "@/services/following/requests/cancel.js"; +import type { IFollow } from "../../type.js"; +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import { FollowRequests, Followings } from "@/models/index.js"; +import DbResolver from "../../db-resolver.js"; + +export default async ( + actor: CacheableRemoteUser, + activity: IFollow, +): Promise => { + const dbResolver = new DbResolver(); + + const followee = await dbResolver.getUserFromApId(activity.object); + if (followee == null) { + return "skip: followee not found"; + } + + if (followee.host != null) { + return "skip: The user you are trying to unfollow is not a local user"; + } + + const req = await FollowRequests.findOneBy({ + followerId: actor.id, + followeeId: followee.id, + }); + + const following = await Followings.findOneBy({ + followerId: actor.id, + followeeId: followee.id, + }); + + if (req) { + await cancelRequest(followee, actor); + return "ok: follow request canceled"; + } + + if (following) { + await unfollow(actor, followee); + return "ok: unfollowed"; + } + + return "skip: Not requested or followed"; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/index.ts b/packages/backend/src/remote/activitypub/kernel/undo/index.ts new file mode 100644 index 0000000..f0e2316 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/undo/index.ts @@ -0,0 +1,47 @@ +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import type { IUndo } from "../../type.js"; +import { + isFollow, + isBlock, + isLike, + isAnnounce, + getApType, + isAccept, +} from "../../type.js"; +import unfollow from "./follow.js"; +import unblock from "./block.js"; +import undoLike from "./like.js"; +import undoAccept from "./accept.js"; +import { undoAnnounce } from "./announce.js"; +import Resolver from "../../resolver.js"; +import { apLogger } from "../../logger.js"; + +const logger = apLogger; + +export default async ( + actor: CacheableRemoteUser, + activity: IUndo, +): Promise => { + if ("actor" in activity && actor.uri !== activity.actor) { + throw new Error("invalid actor"); + } + + const uri = activity.id || activity; + + logger.info(`Undo: ${uri}`); + + const resolver = new Resolver(); + + const object = await resolver.resolve(activity.object).catch((e) => { + logger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isFollow(object)) return await unfollow(actor, object); + if (isBlock(object)) return await unblock(actor, object); + if (isLike(object)) return await undoLike(actor, object); + if (isAnnounce(object)) return await undoAnnounce(actor, object); + if (isAccept(object)) return await undoAccept(actor, object); + + return `skip: unknown object type ${getApType(object)}`; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/undo/like.ts b/packages/backend/src/remote/activitypub/kernel/undo/like.ts new file mode 100644 index 0000000..90220e2 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/undo/like.ts @@ -0,0 +1,22 @@ +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import type { ILike } from "../../type.js"; +import { getApId } from "../../type.js"; +import deleteReaction from "@/services/note/reaction/delete.js"; +import { fetchNote } from "../../models/note.js"; + +/** + * Process Undo.Like activity + */ +export default async (actor: CacheableRemoteUser, activity: ILike) => { + const targetUri = getApId(activity.object); + + const note = await fetchNote(targetUri); + if (!note) return `skip: target note not found ${targetUri}`; + + await deleteReaction(actor, note).catch((e) => { + if (e.id === "60527ec9-b4cb-4a88-a6bd-32d3ad26817d") return; + throw e; + }); + + return "ok"; +}; diff --git a/packages/backend/src/remote/activitypub/kernel/update/index.ts b/packages/backend/src/remote/activitypub/kernel/update/index.ts new file mode 100644 index 0000000..299a955 --- /dev/null +++ b/packages/backend/src/remote/activitypub/kernel/update/index.ts @@ -0,0 +1,54 @@ +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import { getApId, IUpdate } from "../../type.js"; +import { getApType, isActor } from "../../type.js"; +import { apLogger } from "../../logger.js"; +import { updateNote } from "../../models/note.js"; +import Resolver from "../../resolver.js"; +import { updatePerson } from "../../models/person.js"; + +/** + * Handler for the Update activity + */ +export default async ( + actor: CacheableRemoteUser, + activity: IUpdate, +): Promise => { + if (actor.uri == null || actor.uri !== getApId(activity.actor)) { + return "skip: invalid actor"; + } + + apLogger.debug("Update"); + + const resolver = new Resolver(); + + const object = await resolver.resolve(activity.object).catch((e) => { + apLogger.error(`Resolution failed: ${e}`); + throw e; + }); + + if (isActor(object)) { + if (actor.uri !== object.id) { + return "skip: actor id mismatch"; + } + + await updatePerson(actor.uri!, resolver, object); + return "ok: Person updated"; + } + + const objectType = getApType(object); + switch (objectType) { + case "Question": + case "Note": + case "Article": + case "Document": + case "Page": + let failed = false; + await updateNote(object, actor, resolver).catch((e: Error) => { + failed = true; + }); + return failed ? "skip: Note update failed" : "ok: Note updated"; + + default: + return `skip: Unknown type: ${objectType}`; + } +}; diff --git a/packages/backend/src/remote/activitypub/logger.ts b/packages/backend/src/remote/activitypub/logger.ts new file mode 100644 index 0000000..47383cf --- /dev/null +++ b/packages/backend/src/remote/activitypub/logger.ts @@ -0,0 +1,3 @@ +import { remoteLogger } from "../logger.js"; + +export const apLogger = remoteLogger.createSubLogger("ap", "magenta"); diff --git a/packages/backend/src/remote/activitypub/misc/contexts.ts b/packages/backend/src/remote/activitypub/misc/contexts.ts new file mode 100644 index 0000000..3ad9042 --- /dev/null +++ b/packages/backend/src/remote/activitypub/misc/contexts.ts @@ -0,0 +1,609 @@ +const id_v1 = { + "@context": { + id: "@id", + type: "@type", + + cred: "https://w3id.org/credentials#", + dc: "http://purl.org/dc/terms/", + identity: "https://w3id.org/identity#", + perm: "https://w3id.org/permissions#", + ps: "https://w3id.org/payswarm#", + rdf: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + rdfs: "http://www.w3.org/2000/01/rdf-schema#", + sec: "https://w3id.org/security#", + schema: "http://schema.org/", + xsd: "http://www.w3.org/2001/XMLSchema#", + + Group: "https://www.w3.org/ns/activitystreams#Group", + + claim: { "@id": "cred:claim", "@type": "@id" }, + credential: { "@id": "cred:credential", "@type": "@id" }, + issued: { "@id": "cred:issued", "@type": "xsd:dateTime" }, + issuer: { "@id": "cred:issuer", "@type": "@id" }, + recipient: { "@id": "cred:recipient", "@type": "@id" }, + Credential: "cred:Credential", + CryptographicKeyCredential: "cred:CryptographicKeyCredential", + + about: { "@id": "schema:about", "@type": "@id" }, + address: { "@id": "schema:address", "@type": "@id" }, + addressCountry: "schema:addressCountry", + addressLocality: "schema:addressLocality", + addressRegion: "schema:addressRegion", + comment: "rdfs:comment", + created: { "@id": "dc:created", "@type": "xsd:dateTime" }, + creator: { "@id": "dc:creator", "@type": "@id" }, + description: "schema:description", + email: "schema:email", + familyName: "schema:familyName", + givenName: "schema:givenName", + image: { "@id": "schema:image", "@type": "@id" }, + label: "rdfs:label", + name: "schema:name", + postalCode: "schema:postalCode", + streetAddress: "schema:streetAddress", + title: "dc:title", + url: { "@id": "schema:url", "@type": "@id" }, + Person: "schema:Person", + PostalAddress: "schema:PostalAddress", + Organization: "schema:Organization", + + identityService: { "@id": "identity:identityService", "@type": "@id" }, + idp: { "@id": "identity:idp", "@type": "@id" }, + Identity: "identity:Identity", + + paymentProcessor: "ps:processor", + preferences: { "@id": "ps:preferences", "@type": "@vocab" }, + + cipherAlgorithm: "sec:cipherAlgorithm", + cipherData: "sec:cipherData", + cipherKey: "sec:cipherKey", + digestAlgorithm: "sec:digestAlgorithm", + digestValue: "sec:digestValue", + domain: "sec:domain", + expires: { "@id": "sec:expiration", "@type": "xsd:dateTime" }, + initializationVector: "sec:initializationVector", + member: { "@id": "schema:member", "@type": "@id" }, + memberOf: { "@id": "schema:memberOf", "@type": "@id" }, + nonce: "sec:nonce", + normalizationAlgorithm: "sec:normalizationAlgorithm", + owner: { "@id": "sec:owner", "@type": "@id" }, + password: "sec:password", + privateKey: { "@id": "sec:privateKey", "@type": "@id" }, + privateKeyPem: "sec:privateKeyPem", + publicKey: { "@id": "sec:publicKey", "@type": "@id" }, + publicKeyPem: "sec:publicKeyPem", + publicKeyService: { "@id": "sec:publicKeyService", "@type": "@id" }, + revoked: { "@id": "sec:revoked", "@type": "xsd:dateTime" }, + signature: "sec:signature", + signatureAlgorithm: "sec:signatureAlgorithm", + signatureValue: "sec:signatureValue", + CryptographicKey: "sec:Key", + EncryptedMessage: "sec:EncryptedMessage", + GraphSignature2012: "sec:GraphSignature2012", + LinkedDataSignature2015: "sec:LinkedDataSignature2015", + + accessControl: { "@id": "perm:accessControl", "@type": "@id" }, + writePermission: { "@id": "perm:writePermission", "@type": "@id" }, + }, +}; + +const security_v1 = { + "@context": { + id: "@id", + type: "@type", + + dc: "http://purl.org/dc/terms/", + sec: "https://w3id.org/security#", + xsd: "http://www.w3.org/2001/XMLSchema#", + + EcdsaKoblitzSignature2016: "sec:EcdsaKoblitzSignature2016", + Ed25519Signature2018: "sec:Ed25519Signature2018", + EncryptedMessage: "sec:EncryptedMessage", + GraphSignature2012: "sec:GraphSignature2012", + LinkedDataSignature2015: "sec:LinkedDataSignature2015", + LinkedDataSignature2016: "sec:LinkedDataSignature2016", + CryptographicKey: "sec:Key", + + authenticationTag: "sec:authenticationTag", + canonicalizationAlgorithm: "sec:canonicalizationAlgorithm", + cipherAlgorithm: "sec:cipherAlgorithm", + cipherData: "sec:cipherData", + cipherKey: "sec:cipherKey", + created: { "@id": "dc:created", "@type": "xsd:dateTime" }, + creator: { "@id": "dc:creator", "@type": "@id" }, + digestAlgorithm: "sec:digestAlgorithm", + digestValue: "sec:digestValue", + domain: "sec:domain", + encryptionKey: "sec:encryptionKey", + expiration: { "@id": "sec:expiration", "@type": "xsd:dateTime" }, + expires: { "@id": "sec:expiration", "@type": "xsd:dateTime" }, + initializationVector: "sec:initializationVector", + iterationCount: "sec:iterationCount", + nonce: "sec:nonce", + normalizationAlgorithm: "sec:normalizationAlgorithm", + owner: { "@id": "sec:owner", "@type": "@id" }, + password: "sec:password", + privateKey: { "@id": "sec:privateKey", "@type": "@id" }, + privateKeyPem: "sec:privateKeyPem", + publicKey: { "@id": "sec:publicKey", "@type": "@id" }, + publicKeyBase58: "sec:publicKeyBase58", + publicKeyPem: "sec:publicKeyPem", + publicKeyWif: "sec:publicKeyWif", + publicKeyService: { "@id": "sec:publicKeyService", "@type": "@id" }, + revoked: { "@id": "sec:revoked", "@type": "xsd:dateTime" }, + salt: "sec:salt", + signature: "sec:signature", + signatureAlgorithm: "sec:signingAlgorithm", + signatureValue: "sec:signatureValue", + }, +}; + +const activitystreams = { + "@context": { + "@vocab": "_:", + xsd: "http://www.w3.org/2001/XMLSchema#", + as: "https://www.w3.org/ns/activitystreams#", + ldp: "http://www.w3.org/ns/ldp#", + vcard: "http://www.w3.org/2006/vcard/ns#", + id: "@id", + type: "@type", + Accept: "as:Accept", + Activity: "as:Activity", + IntransitiveActivity: "as:IntransitiveActivity", + Add: "as:Add", + Announce: "as:Announce", + Application: "as:Application", + Arrive: "as:Arrive", + Article: "as:Article", + Audio: "as:Audio", + Block: "as:Block", + Collection: "as:Collection", + CollectionPage: "as:CollectionPage", + Relationship: "as:Relationship", + Create: "as:Create", + Delete: "as:Delete", + Dislike: "as:Dislike", + Document: "as:Document", + Event: "as:Event", + Follow: "as:Follow", + Flag: "as:Flag", + Group: "as:Group", + Ignore: "as:Ignore", + Image: "as:Image", + Invite: "as:Invite", + Join: "as:Join", + Leave: "as:Leave", + Like: "as:Like", + Link: "as:Link", + Mention: "as:Mention", + Note: "as:Note", + Object: "as:Object", + Offer: "as:Offer", + OrderedCollection: "as:OrderedCollection", + OrderedCollectionPage: "as:OrderedCollectionPage", + Organization: "as:Organization", + Page: "as:Page", + Person: "as:Person", + Place: "as:Place", + Profile: "as:Profile", + Question: "as:Question", + Reject: "as:Reject", + Remove: "as:Remove", + Service: "as:Service", + TentativeAccept: "as:TentativeAccept", + TentativeReject: "as:TentativeReject", + Tombstone: "as:Tombstone", + Undo: "as:Undo", + Update: "as:Update", + Video: "as:Video", + View: "as:View", + Listen: "as:Listen", + Read: "as:Read", + Move: "as:Move", + Travel: "as:Travel", + IsFollowing: "as:IsFollowing", + IsFollowedBy: "as:IsFollowedBy", + IsContact: "as:IsContact", + IsMember: "as:IsMember", + subject: { + "@id": "as:subject", + "@type": "@id", + }, + relationship: { + "@id": "as:relationship", + "@type": "@id", + }, + actor: { + "@id": "as:actor", + "@type": "@id", + }, + attributedTo: { + "@id": "as:attributedTo", + "@type": "@id", + }, + attachment: { + "@id": "as:attachment", + "@type": "@id", + }, + bcc: { + "@id": "as:bcc", + "@type": "@id", + }, + bto: { + "@id": "as:bto", + "@type": "@id", + }, + cc: { + "@id": "as:cc", + "@type": "@id", + }, + context: { + "@id": "as:context", + "@type": "@id", + }, + current: { + "@id": "as:current", + "@type": "@id", + }, + first: { + "@id": "as:first", + "@type": "@id", + }, + generator: { + "@id": "as:generator", + "@type": "@id", + }, + icon: { + "@id": "as:icon", + "@type": "@id", + }, + image: { + "@id": "as:image", + "@type": "@id", + }, + inReplyTo: { + "@id": "as:inReplyTo", + "@type": "@id", + }, + items: { + "@id": "as:items", + "@type": "@id", + }, + instrument: { + "@id": "as:instrument", + "@type": "@id", + }, + orderedItems: { + "@id": "as:items", + "@type": "@id", + "@container": "@list", + }, + last: { + "@id": "as:last", + "@type": "@id", + }, + location: { + "@id": "as:location", + "@type": "@id", + }, + next: { + "@id": "as:next", + "@type": "@id", + }, + object: { + "@id": "as:object", + "@type": "@id", + }, + oneOf: { + "@id": "as:oneOf", + "@type": "@id", + }, + anyOf: { + "@id": "as:anyOf", + "@type": "@id", + }, + closed: { + "@id": "as:closed", + "@type": "xsd:dateTime", + }, + origin: { + "@id": "as:origin", + "@type": "@id", + }, + accuracy: { + "@id": "as:accuracy", + "@type": "xsd:float", + }, + prev: { + "@id": "as:prev", + "@type": "@id", + }, + preview: { + "@id": "as:preview", + "@type": "@id", + }, + replies: { + "@id": "as:replies", + "@type": "@id", + }, + result: { + "@id": "as:result", + "@type": "@id", + }, + audience: { + "@id": "as:audience", + "@type": "@id", + }, + partOf: { + "@id": "as:partOf", + "@type": "@id", + }, + tag: { + "@id": "as:tag", + "@type": "@id", + }, + target: { + "@id": "as:target", + "@type": "@id", + }, + to: { + "@id": "as:to", + "@type": "@id", + }, + url: { + "@id": "as:url", + "@type": "@id", + }, + altitude: { + "@id": "as:altitude", + "@type": "xsd:float", + }, + content: "as:content", + contentMap: { + "@id": "as:content", + "@container": "@language", + }, + name: "as:name", + nameMap: { + "@id": "as:name", + "@container": "@language", + }, + duration: { + "@id": "as:duration", + "@type": "xsd:duration", + }, + endTime: { + "@id": "as:endTime", + "@type": "xsd:dateTime", + }, + height: { + "@id": "as:height", + "@type": "xsd:nonNegativeInteger", + }, + href: { + "@id": "as:href", + "@type": "@id", + }, + hreflang: "as:hreflang", + latitude: { + "@id": "as:latitude", + "@type": "xsd:float", + }, + longitude: { + "@id": "as:longitude", + "@type": "xsd:float", + }, + mediaType: "as:mediaType", + published: { + "@id": "as:published", + "@type": "xsd:dateTime", + }, + radius: { + "@id": "as:radius", + "@type": "xsd:float", + }, + rel: "as:rel", + startIndex: { + "@id": "as:startIndex", + "@type": "xsd:nonNegativeInteger", + }, + startTime: { + "@id": "as:startTime", + "@type": "xsd:dateTime", + }, + summary: "as:summary", + summaryMap: { + "@id": "as:summary", + "@container": "@language", + }, + totalItems: { + "@id": "as:totalItems", + "@type": "xsd:nonNegativeInteger", + }, + units: "as:units", + updated: { + "@id": "as:updated", + "@type": "xsd:dateTime", + }, + width: { + "@id": "as:width", + "@type": "xsd:nonNegativeInteger", + }, + describes: { + "@id": "as:describes", + "@type": "@id", + }, + formerType: { + "@id": "as:formerType", + "@type": "@id", + }, + deleted: { + "@id": "as:deleted", + "@type": "xsd:dateTime", + }, + inbox: { + "@id": "ldp:inbox", + "@type": "@id", + }, + outbox: { + "@id": "as:outbox", + "@type": "@id", + }, + following: { + "@id": "as:following", + "@type": "@id", + }, + followers: { + "@id": "as:followers", + "@type": "@id", + }, + streams: { + "@id": "as:streams", + "@type": "@id", + }, + preferredUsername: "as:preferredUsername", + endpoints: { + "@id": "as:endpoints", + "@type": "@id", + }, + uploadMedia: { + "@id": "as:uploadMedia", + "@type": "@id", + }, + proxyUrl: { + "@id": "as:proxyUrl", + "@type": "@id", + }, + liked: { + "@id": "as:liked", + "@type": "@id", + }, + oauthAuthorizationEndpoint: { + "@id": "as:oauthAuthorizationEndpoint", + "@type": "@id", + }, + oauthTokenEndpoint: { + "@id": "as:oauthTokenEndpoint", + "@type": "@id", + }, + provideClientKey: { + "@id": "as:provideClientKey", + "@type": "@id", + }, + signClientKey: { + "@id": "as:signClientKey", + "@type": "@id", + }, + sharedInbox: { + "@id": "as:sharedInbox", + "@type": "@id", + }, + Public: { + "@id": "as:Public", + "@type": "@id", + }, + source: "as:source", + likes: { + "@id": "as:likes", + "@type": "@id", + }, + shares: { + "@id": "as:shares", + "@type": "@id", + }, + alsoKnownAs: { + "@id": "as:alsoKnownAs", + "@type": "@id", + }, + }, +}; + +export const WellKnownContext = { + "@context": [ + "https://www.w3.org/ns/activitystreams", + "https://w3id.org/security/v1", + { + // as non-standards + manuallyApprovesFollowers: "as:manuallyApprovesFollowers", + movedTo: { + "@id": "https://www.w3.org/ns/activitystreams#movedTo", + "@type": "@id", + }, + movedToUri: "as:movedTo", + sensitive: "as:sensitive", + Hashtag: "as:Hashtag", + quoteUri: "fedibird:quoteUri", + quoteUrl: "as:quoteUrl", + // Mastodon + toot: "http://joinmastodon.org/ns#", + Emoji: "toot:Emoji", + featured: "toot:featured", + discoverable: "toot:discoverable", + // schema + schema: "http://schema.org#", + PropertyValue: "schema:PropertyValue", + value: "schema:value", + // Misskey + misskey: "https://misskey-hub.net/ns#", + _misskey_content: "misskey:_misskey_content", + _misskey_quote: "misskey:_misskey_quote", + _misskey_reaction: "misskey:_misskey_reaction", + _misskey_votes: "misskey:_misskey_votes", + _misskey_talk: "misskey:_misskey_talk", + _misskey_summary: "misskey:_misskey_summary", + isCat: "misskey:isCat", + // Fedibird + fedibird: "http://fedibird.com/ns#", + // vcard + vcard: "http://www.w3.org/2006/vcard/ns#", + // litepub + litepub: "http://litepub.social/ns#", + EmojiReact: "litepub:EmojiReact", + EmojiReaction: "litepub:EmojiReaction", + // mia + Bite: "https://ns.mia.jetzt/as#Bite", + canBite: { + "@id": "https://ns.mia.jetzt/as#canBite", + "@type": "@id", + }, + // pancakes + pronouns: { + "@id": "https://ns.pancakes.gay/as#pronouns", + "@container": "@language", + }, + // mastodon-style quotes + QuoteAuthorization: "https://w3id.org/fep/044f#QuoteAuthorization", + quote: { + "@id": "https://w3id.org/fep/044f#quote", + "@type": "@id", + }, + gts: "https://gotosocial.org/ns#", + interactionPolicy: { + "@id": "gts:interactionPolicy", + "@type": "@id", + }, + canQuote: { + "@id": "gts:canQuote", + "@type": "@id", + }, + automaticApproval: { + "@id": "gts:automaticApproval", + "@type": "@id", + }, + interactingObject: { + "@id": "gts:interactingObject", + "@type": "@id", + }, + interactionTarget: { + "@id": "gts:interactionTarget", + "@type": "@id", + }, + }, + ], +}; + +export const CONTEXTS: Record = { + "https://w3id.org/identity/v1": id_v1, + "https://w3id.org/security/v1": security_v1, + "https://www.w3.org/ns/activitystreams": activitystreams, +}; diff --git a/packages/backend/src/remote/activitypub/misc/get-note-html.ts b/packages/backend/src/remote/activitypub/misc/get-note-html.ts new file mode 100644 index 0000000..162a2db --- /dev/null +++ b/packages/backend/src/remote/activitypub/misc/get-note-html.ts @@ -0,0 +1,8 @@ +import * as mfm from "mfm-js"; +import type { Note } from "@/models/entities/note.js"; +import { toHtml } from "../../../mfm/to-html.js"; + +export default async function (note: Note) { + if (!note.text) return ""; + return toHtml(mfm.parse(note.text), JSON.parse(note.mentionedRemoteUsers), note.userHost); +} diff --git a/packages/backend/src/remote/activitypub/misc/html-to-mfm.ts b/packages/backend/src/remote/activitypub/misc/html-to-mfm.ts new file mode 100644 index 0000000..a6ea63b --- /dev/null +++ b/packages/backend/src/remote/activitypub/misc/html-to-mfm.ts @@ -0,0 +1,11 @@ +import type { IObject } from "../type.js"; +import { extractApHashtagObjects } from "../models/tag.js"; +import { fromHtml } from "../../../mfm/from-html.js"; + +export async function htmlToMfm(html: string, tag?: IObject | IObject[]) { + const hashtagNames = extractApHashtagObjects(tag) + .map((x) => x.name) + .filter((x): x is string => x != null); + + return await fromHtml(html, hashtagNames); +} diff --git a/packages/backend/src/remote/activitypub/misc/ld-signature.ts b/packages/backend/src/remote/activitypub/misc/ld-signature.ts new file mode 100644 index 0000000..4034291 --- /dev/null +++ b/packages/backend/src/remote/activitypub/misc/ld-signature.ts @@ -0,0 +1,173 @@ +import * as crypto from "node:crypto"; +import jsonld from "jsonld"; +import { CONTEXTS, WellKnownContext } from "./contexts.js"; +import fetch from "node-fetch"; +import { httpAgent, httpsAgent } from "@/misc/fetch.js"; + +// RsaSignature2017 based from https://github.com/transmute-industries/RsaSignature2017 + +export class LdSignature { + public debug = false; + public preLoad = true; + public loaderTimeout = 10 * 1000; + + public async signRsaSignature2017( + data: any, + privateKey: string, + creator: string, + domain?: string, + created?: Date, + ): Promise { + const options = { + type: "RsaSignature2017", + creator, + domain, + nonce: crypto.randomBytes(16).toString("hex"), + created: (created || new Date()).toISOString(), + } as { + type: string; + creator: string; + domain?: string; + nonce: string; + created: string; + }; + + if (!domain) { + options.domain = undefined; + } + + const toBeSigned = await this.createVerifyData(data, options); + + const signer = crypto.createSign("sha256"); + signer.update(toBeSigned); + signer.end(); + + const signature = signer.sign(privateKey); + + return { + ...data, + signature: { + ...options, + signatureValue: signature.toString("base64"), + }, + }; + } + + public async verifyRsaSignature2017( + data: any, + signature: any, + publicKey: string, + ): Promise { + const toBeSigned = await this.createVerifyData(data, signature); + const verifier = crypto.createVerify("sha256"); + verifier.update(toBeSigned); + return verifier.verify(publicKey, signature.signatureValue, "base64"); + } + + public async createVerifyData(data: any, options: any) { + const transformedOptions = { + ...options, + "@context": "https://w3id.org/identity/v1", + }; + delete transformedOptions["type"]; + delete transformedOptions["id"]; + delete transformedOptions["signatureValue"]; + const canonizedOptions = await this.normalize(transformedOptions); + const optionsHash = this.sha256(canonizedOptions); + const transformedData = { ...data }; + const cannonidedData = await this.normalize(transformedData); + if (this.debug) console.debug(`cannonidedData: ${cannonidedData}`); + const documentHash = this.sha256(cannonidedData); + const verifyData = `${optionsHash}${documentHash}`; + return verifyData; + } + + public async normalize(data: any) { + const customLoader = this.getLoader(); + return await jsonld.normalize(data, { + documentLoader: customLoader, + }); + } + + public async compactToWellKnown(data: any): Promise { + const options = { documentLoader: this.getLoader() }; + const context = WellKnownContext as any; + return await jsonld.compact(data, context, options); + } + + private getLoader() { + return async (url: string): Promise => { + if (!url.match("^https?://")) throw new Error(`Invalid URL ${url}`); + + if (this.preLoad) { + if (url in CONTEXTS) { + if (this.debug) console.debug(`HIT: ${url}`); + return { + contextUrl: null, + document: CONTEXTS[url], + documentUrl: url, + }; + } + } + + if (this.debug) console.debug(`MISS: ${url}`); + const document = await this.fetchDocument(url); + return { + contextUrl: null, + document: document, + documentUrl: url, + }; + }; + } + + private async fetchDocument(url: string) { + const json = await fetch(url, { + headers: { + Accept: "application/ld+json, application/json", + }, + size: 1024 * 1024, // 1MiB + // TODO + //timeout: this.loaderTimeout, + agent: (u) => (u.protocol === "http:" ? httpAgent : httpsAgent), + }).then((res) => { + if (!res.ok) { + throw new Error(`${res.status} ${res.statusText}`); + } else { + return res.json(); + } + }); + + return json; + } + + public sha256(data: string): string { + const hash = crypto.createHash("sha256"); + hash.update(data); + return hash.digest("hex"); + } + + public containsForbiddenDirectives(doc: any): boolean { + if (typeof doc === "object" && doc !== null) { + if (Array.isArray(doc)) { + for (const item of doc) { + if (this.containsForbiddenDirectives(item)) { + return true; + } + } + } else { + for (const [key, value] of Object.entries(doc)) { + if (["@included", "@graph", "@reverse"].includes(key)) { + return true; + } + + if (typeof value === "object" && value !== null) { + if (this.containsForbiddenDirectives(value)) { + return true; + } + } + } + } + } + return false; + } +} diff --git a/packages/backend/src/remote/activitypub/models/icon.ts b/packages/backend/src/remote/activitypub/models/icon.ts new file mode 100644 index 0000000..50794a9 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/icon.ts @@ -0,0 +1,5 @@ +export type IIcon = { + type: string; + mediaType?: string; + url?: string; +}; diff --git a/packages/backend/src/remote/activitypub/models/identifier.ts b/packages/backend/src/remote/activitypub/models/identifier.ts new file mode 100644 index 0000000..f6c3bb8 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/identifier.ts @@ -0,0 +1,5 @@ +export type IIdentifier = { + type: string; + name: string; + value: string; +}; diff --git a/packages/backend/src/remote/activitypub/models/image.ts b/packages/backend/src/remote/activitypub/models/image.ts new file mode 100644 index 0000000..7152a25 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/image.ts @@ -0,0 +1,82 @@ +import { uploadFromUrl } from "@/services/drive/upload-from-url.js"; +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import { IRemoteUser } from "@/models/entities/user.js"; +import Resolver from "../resolver.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { apLogger } from "../logger.js"; +import type { DriveFile } from "@/models/entities/drive-file.js"; +import { DriveFiles, Users } from "@/models/index.js"; +import { truncate } from "@/misc/truncate.js"; +import { DB_MAX_IMAGE_COMMENT_LENGTH } from "@/misc/hard-limits.js"; + +const logger = apLogger; + +/** + * create an Image. + */ +export async function createImage( + actor: CacheableRemoteUser, + value: any, +): Promise { + // Skip if author is frozen. + if (actor.isSuspended) { + throw new Error("actor has been suspended"); + } + + const image = (await new Resolver().resolve(value)) as any; + + if (image.url == null) { + throw new Error("Invalid image, URL not provided"); + } + + if (!image.url.startsWith("https://") && !image.url.startsWith("http://")) { + throw new Error(`Invalid image, unexpected schema: ${image.url}`); + } + + logger.info(`Creating the Image: ${image.url}`); + + const instance = await fetchMeta(); + + let file = await uploadFromUrl({ + url: image.url, + user: actor, + uri: image.url, + sensitive: image.sensitive, + isLink: !instance.cacheRemoteFiles, + comment: truncate(image.name, DB_MAX_IMAGE_COMMENT_LENGTH), + }); + + if (file.isLink) { + // If the URL is different, it means that the same image was previously + // registered with a different URL, so update the URL + if (file.url !== image.url) { + await DriveFiles.update( + { id: file.id }, + { + url: image.url, + uri: image.url, + }, + ); + + file = await DriveFiles.findOneByOrFail({ id: file.id }); + } + } + + return file; +} + +/** + * Resolve Image. + * + * If the target Image is registered in Iceshrimp, return it, otherwise + * Fetch from remote server, register with Iceshrimp and return it. + */ +export async function resolveImage( + actor: CacheableRemoteUser, + value: any, +): Promise { + // TODO + + // Fetch from remote server and register + return await createImage(actor, value); +} diff --git a/packages/backend/src/remote/activitypub/models/mention.ts b/packages/backend/src/remote/activitypub/models/mention.ts new file mode 100644 index 0000000..232b6ff --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/mention.ts @@ -0,0 +1,38 @@ +import promiseLimit from "promise-limit"; +import { toArray, unique } from "@/prelude/array.js"; +import type { CacheableUser } from "@/models/entities/user.js"; +import { User } from "@/models/entities/user.js"; +import type { IObject, IApMention } from "../type.js"; +import { isMention } from "../type.js"; +import Resolver from "../resolver.js"; +import { resolvePerson } from "./person.js"; +import { RecursionLimiter } from "@/models/repositories/user-profile.js"; + +export async function extractApMentions( + tags: IObject | IObject[] | null | undefined, + limiter: RecursionLimiter = new RecursionLimiter() +) { + const hrefs = unique( + extractApMentionObjects(tags).map((x) => x.href as string), + ); + + const resolver = new Resolver(); + + const limit = promiseLimit(2); + const mentionedUsers = ( + await Promise.all( + hrefs.map((x) => + limit(() => resolvePerson(x, resolver, limiter).catch(() => null)), + ), + ) + ).filter((x): x is CacheableUser => x != null); + + return mentionedUsers; +} + +export function extractApMentionObjects( + tags: IObject | IObject[] | null | undefined, +): IApMention[] { + if (tags == null) return []; + return toArray(tags).filter(isMention); +} diff --git a/packages/backend/src/remote/activitypub/models/note.ts b/packages/backend/src/remote/activitypub/models/note.ts new file mode 100644 index 0000000..262a772 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/note.ts @@ -0,0 +1,822 @@ +import promiseLimit from "promise-limit"; +import * as mfm from "mfm-js"; +import config from "@/config/index.js"; +import Resolver from "../resolver.js"; +import post from "@/services/note/create.js"; +import { extractMentionedUsers } from "@/services/note/create.js"; +import { resolvePerson } from "./person.js"; +import { resolveImage } from "./image.js"; +import type { + ILocalUser, + CacheableRemoteUser, +} from "@/models/entities/user.js"; +import { htmlToMfm } from "../misc/html-to-mfm.js"; +import { extractApHashtags } from "./tag.js"; +import { unique, toArray, toSingle } from "@/prelude/array.js"; +import { extractPollFromQuestion } from "./question.js"; +import vote from "@/services/note/polls/vote.js"; +import { apLogger } from "../logger.js"; +import { DriveFile } from "@/models/entities/drive-file.js"; +import { extractDbHost, toPuny } from "@/misc/convert-host.js"; +import { + Emojis, + Polls, + MessagingMessages, + Notes, + NoteEdits, + DriveFiles, + PollVotes, +} from "@/models/index.js"; +import type { IMentionedRemoteUsers, Note } from "@/models/entities/note.js"; +import type { IObject, IPost } from "../type.js"; +import { + getOneApId, + getApId, + getOneApHrefNullable, + validPost, + isEmoji, + getApType, +} from "../type.js"; +import type { Emoji } from "@/models/entities/emoji.js"; +import { genId } from "@/misc/gen-id.js"; +import { getApLock } from "@/misc/app-lock.js"; +import { createMessage } from "@/services/messages/create.js"; +import { parseAudience } from "../audience.js"; +import { extractApMentions } from "./mention.js"; +import DbResolver from "../db-resolver.js"; +import { StatusError } from "@/misc/fetch.js"; +import { shouldBlockInstance } from "@/misc/should-block-instance.js"; +import { publishNoteStream, publishNoteUpdatesStream } from "@/services/stream.js"; +import { extractHashtags } from "@/misc/extract-hashtags.js"; +import { UserProfiles } from "@/models/index.js"; +import { In } from "typeorm"; +import { DB_MAX_IMAGE_COMMENT_LENGTH } from "@/misc/hard-limits.js"; +import { truncate } from "@/misc/truncate.js"; +import { type Size, getEmojiSize } from "@/misc/emoji-meta.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { RecursionLimiter } from "@/models/repositories/user-profile.js"; + +const logger = apLogger; + +export function validateNote(object: any, uri: string) { + const expectHost = extractDbHost(uri); + + if (object == null) { + return new Error("invalid Note: object is null"); + } + + if (!validPost.includes(getApType(object))) { + return new Error(`invalid Note: invalid object type ${getApType(object)}`); + } + + if (object.id && extractDbHost(object.id) !== expectHost) { + return new Error( + `invalid Note: id has different host. expected: ${expectHost}, actual: ${extractDbHost( + object.id, + )}`, + ); + } + + if ( + object.attributedTo && + extractDbHost(getOneApId(object.attributedTo)) !== expectHost + ) { + return new Error( + `invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${extractDbHost( + object.attributedTo, + )}`, + ); + } + + return null; +} + +/** + * Fetch Notes. + * + * If the target Note is registered in Iceshrimp, it will be returned. + */ +export async function fetchNote( + object: string | IObject, +): Promise { + const dbResolver = new DbResolver(); + return await dbResolver.getNoteFromApId(object); +} + +/** + * Create a Note. + */ +export async function createNote( + value: string | IObject, + resolver?: Resolver, + silent = false, + limiter: RecursionLimiter = new RecursionLimiter() +): Promise { + if (resolver == null) resolver = new Resolver(); + + const object: any = await resolver.resolve(value); + + const entryUri = getApId(value); + const err = validateNote(object, entryUri); + if (err) { + logger.error(`${err.message}`, { + resolver: { + history: resolver.getHistory(), + }, + value: value, + object: object, + }); + throw new Error("invalid note"); + } + + const note: IPost = object; + + if (note.id == null) { + throw new Error('Note must have an id'); + } + + const idUrl = new URL(note.id); + + if (idUrl.protocol != 'https:') { + throw new Error(`unexpected schema of note.id: ${note.id}`); + } + + let url = getOneApHrefNullable(note.url); + const urlUrl = url != null ? new URL(url) : null; + + if (urlUrl != null && urlUrl.protocol != 'https:') { + throw new Error(`unexpected schema of note url: ${url}`); + } + + logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); + logger.info(`Creating the Note: ${note.id}`); + + // Skip if note is made before 2007 (1yr before Fedi was created) + // OR skip if note is made 3 days in advance + if (note.published) { + const DateChecker = new Date(note.published); + const FutureCheck = new Date(); + FutureCheck.setDate(FutureCheck.getDate() + 3); // Allow some wiggle room for misconfigured hosts + if (DateChecker.getFullYear() < 2007) { + logger.warn( + "Note somehow made before Activitypub was created; discarding", + ); + return null; + } + if (DateChecker > FutureCheck) { + logger.warn("Note somehow made after today; discarding"); + return null; + } + } + + // Fetch author + const actor = (await resolvePerson( + getOneApId(note.attributedTo), + resolver, + limiter + )) as CacheableRemoteUser; + + if (actor.uri == null) { + logger.warn('Note actor uri is null, discarding'); + return null; + } + + const actorUri = new URL(actor.uri); + if (idUrl.host != actorUri.host) { + logger.warn("Note id host doesn't match actor host, discarding"); + return null; + } + + if (urlUrl != null && urlUrl.host != actorUri.host) { + logger.debug("Note url host doesn't match actor host, clearing variable"); + url = undefined; + } + + // Skip if author is suspended. + if (actor.isSuspended) { + logger.debug( + `User ${actor.usernameLower}@${actor.host} suspended; discarding.`, + ); + return null; + } + + const noteAudience = await parseAudience(actor, note.to, note.cc, undefined, limiter); + let visibility = noteAudience.visibility; + const visibleUsers = noteAudience.visibleUsers; + + // If Audience (to, cc) was not specified + if (visibility === "specified" && visibleUsers.length === 0) { + if (typeof value === "string") { + // If the input is a string, GET occurs in resolver + // Public if you can GET anonymously from here + visibility = "public"; + } + } + + let isTalk = note._misskey_talk && visibility === "specified"; + + const apMentions = await extractApMentions(note.tag, limiter); + const apHashtags = extractApHashtags(note.tag); + + // Attachments + // TODO: attachmentは必ずしもImageではない + // TODO: attachmentは必ずしも配列ではない + // Noteがsensitiveなら添付もsensitiveにする + const limit = promiseLimit(2); + + note.attachment = Array.isArray(note.attachment) + ? note.attachment + : note.attachment + ? [note.attachment] + : []; + note.attachment = note.attachment.filter( + (attach) => ["Document", "Image", "Audio", "Video"].includes(attach.type), + ); + const files = note.attachment.map( + (attach) => (attach.sensitive = note.sensitive), + ) + ? ( + await Promise.all( + note.attachment.map( + (x) => limit(() => resolveImage(actor, x)) as Promise, + ), + ) + ).filter((image) => image != null) + : []; + + // Reply + const reply: Note | null = note.inReplyTo + ? await resolveNote(note.inReplyTo, resolver, limiter) + .then((x) => { + if (x == null) { + logger.warn("Specified inReplyTo, but nout found"); + throw new Error("inReplyTo not found"); + } else { + return x; + } + }) + .catch(async (e) => { + // トークだったらinReplyToのエラーは無視 + const uri = getApId(note.inReplyTo); + if (uri.startsWith(`${config.url}/`)) { + const id = uri.split("/").pop(); + const talk = await MessagingMessages.findOneBy({ id }); + if (talk) { + isTalk = true; + return null; + } + } + + logger.warn( + `Error in inReplyTo ${note.inReplyTo} - ${e.statusCode || e}`, + ); + throw e; + }) + : null; + + // Quote + let quote: Note | undefined | null; + + if (note._misskey_quote || note.quoteUrl || note.quoteUri || note.quote) { + const tryResolveNote = async ( + uri: string, + ): Promise< + | { + status: "ok"; + res: Note | null; + } + | { + status: "permerror" | "temperror"; + } + > => { + if (typeof uri !== "string" || !uri.match(/^https?:/)) + return { status: "permerror" }; + try { + const res = await resolveNote(uri, undefined, limiter); + if (res) { + return { + status: "ok", + res, + }; + } else { + return { + status: "permerror", + }; + } + } catch (e) { + return { + status: + e instanceof StatusError && !e.isRetryable + ? "permerror" + : "temperror", + }; + } + }; + + const uris = unique( + [note._misskey_quote, note.quoteUrl, note.quoteUri, note.quote].filter( + (x): x is string => typeof x === "string", + ), + ); + const results = await Promise.all(uris.map((uri) => tryResolveNote(uri))); + + quote = results + .filter((x): x is { status: "ok"; res: Note | null } => x.status === "ok") + .map((x) => x.res) + .find((x) => x); + if (!quote) { + if (results.some((x) => x.status === "temperror")) { + throw new Error("quote resolve failed"); + } + } + } + + const cw = note.summary === "" ? null : note.summary; + + // Text parsing + let text: string | null = null; + if ( + note.source?.mediaType === "text/x.misskeymarkdown" && + typeof note.source?.content === "string" + ) { + text = note.source.content; + } else if (typeof note._misskey_content !== "undefined") { + text = note._misskey_content; + } else if (typeof note.content === "string") { + text = await htmlToMfm(note.content, note.tag); + } + + // vote + if (reply?.hasPoll) { + const poll = await Polls.findOneByOrFail({ noteId: reply.id }); + + const tryCreateVote = async ( + name: string, + index: number, + ): Promise => { + if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) { + logger.warn( + `vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`, + ); + } else if (index >= 0) { + logger.info( + `vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`, + ); + await vote(actor, reply, index); + } + return null; + }; + + if (note.name) { + return await tryCreateVote( + note.name, + poll.choices.findIndex((x) => x === note.name), + ); + } + } + + const emojis = await extractEmojis(note.tag || [], actor.host).catch((e) => { + logger.info(`extractEmojis: ${e}`); + return [] as Emoji[]; + }); + + const apEmojis = emojis.map((emoji) => emoji.name); + + const poll = await extractPollFromQuestion(note, resolver).catch( + () => undefined, + ); + + if (isTalk) { + for (const recipient of visibleUsers) { + await createMessage( + actor, + recipient, + undefined, + text || undefined, + files && files.length > 0 ? files[0] : null, + object.id, + ); + return null; + } + } + + return await post( + actor, + { + createdAt: note.published ? new Date(note.published) : null, + files, + reply, + renote: quote, + name: note.name, + cw, + text, + localOnly: false, + visibility, + visibleUsers, + apMentions, + apHashtags, + apEmojis, + poll, + uri: note.id, + url: url, + canQuote: !!note.interactionPolicy?.canQuote, + }, + silent, + limiter + ); +} + +/** + * Resolve Note. + * + * If the target Note is registered in Iceshrimp, return it, otherwise + * Fetch from remote server, register with Iceshrimp and return it. + */ +export async function resolveNote( + value: string | IObject, + resolver?: Resolver, + limiter: RecursionLimiter = new RecursionLimiter() +): Promise { + const uri = typeof value === "string" ? value : value.id; + if (uri == null) throw new Error("missing uri"); + + // Abort if origin host is blocked + if (await shouldBlockInstance(extractDbHost(uri))) + throw new StatusError( + "host blocked", + 451, + `host ${extractDbHost(uri)} is blocked`, + ); + + const unlock = await getApLock(uri); + + try { + //#region Returns if already registered with this server + const exist = await fetchNote(uri); + + if (exist) { + return exist; + } + //#endregion + + if (extractDbHost(uri) === toPuny(config.host)) { + throw new StatusError( + "cannot resolve local note", + 400, + "cannot resolve local note", + ); + } + + // Fetch from remote server and register + // If the attached `Note` Object is specified here instead of the uri, the note will be generated without going through the server fetch. + // Since the attached Note Object may be disguised, always specify the uri and fetch it from the server. + return await createNote(uri, resolver, true, limiter); + } finally { + unlock(); + } +} + +export async function extractEmojis( + tags: IObject | IObject[], + host: string, +): Promise { + host = toPuny(host); + + if (!tags) return []; + + const eomjiTags = toArray(tags).filter(isEmoji); + + return await Promise.all( + eomjiTags.map(async (tag) => { + const name = tag.name!.replace(/^:/, "").replace(/:$/, ""); + tag.icon = toSingle(tag.icon); + + const exists = await Emojis.findOneBy({ + host, + name, + }); + + if (exists) { + if ( + (tag.updated != null && exists.updatedAt == null) || + (tag.id != null && exists.uri == null) || + (tag.updated != null && + exists.updatedAt != null && + new Date(tag.updated) > exists.updatedAt) || + tag.icon!.url !== exists.originalUrl || + !(exists.width && exists.height) + ) { + let size: Size = { width: 0, height: 0 }; + try { + size = await getEmojiSize(tag.icon!.url); + } catch { + /* skip if any error happens */ + } + await Emojis.update( + { + host, + name, + }, + { + uri: tag.id, + originalUrl: tag.icon!.url, + publicUrl: tag.icon!.url, + updatedAt: new Date(), + width: size.width || null, + height: size.height || null, + }, + ); + + return (await Emojis.findOneBy({ + host, + name, + })) as Emoji; + } + + return exists; + } + + logger.info(`register emoji host=${host}, name=${name}`); + + let size: Size = { width: 0, height: 0 }; + try { + size = await getEmojiSize(tag.icon!.url); + } catch { + /* skip if any error happens */ + } + return await Emojis.insert({ + id: genId(), + host, + name, + uri: tag.id, + originalUrl: tag.icon!.url, + publicUrl: tag.icon!.url, + updatedAt: new Date(), + aliases: [], + glyph: (tag.icon as any)?.type === "image/svg+xml", + width: size.width || null, + height: size.height || null, + } as Partial).then((x) => + Emojis.findOneByOrFail(x.identifiers[0]), + ); + }), + ); +} + +type TagDetail = { + type: string; + name: string; +}; + +function notEmpty(partial: Partial) { + return Object.keys(partial).length > 0; +} + +export async function updateNote(value: string | IObject, actor: CacheableRemoteUser, resolver?: Resolver) { + const uri = typeof value === "string" ? value : value.id; + if (!uri) throw new Error("Missing note uri"); + + // Skip if URI points to this server + if (extractDbHost(uri) === toPuny(config.host)) throw new Error("uri points local"); + + // A new resolver is created if not specified + if (resolver == null) resolver = new Resolver(); + + // Resolve the updated Note object + const post = (await resolver.resolve(value)) as IPost; + + if (getOneApId(post.attributedTo) !== actor.uri || actor.uri == null) { + throw new Error('Refusing to ingest update for note with mismatching actor'); + } + + // Already registered with this server? + const note = await Notes.findOneBy({ uri }); + if (note == null) { + return await createNote(post, resolver); + } + if (note.userId !== actor.id) { + throw new Error('Refusing to ingest update for note of different user'); + } + + // Whether to tell clients the note has been updated and requires refresh. + let updating = false; + + // Text parsing + let text: string | null = null; + if ( + post.source?.mediaType === "text/x.misskeymarkdown" && + typeof post.source?.content === "string" + ) { + text = post.source.content; + } else if (typeof post._misskey_content !== "undefined") { + text = post._misskey_content; + } else if (typeof post.content === "string") { + text = await htmlToMfm(post.content, post.tag); + } + + const cw = post.summary === "" ? null : post.summary; + + // File parsing + const fileList = post.attachment + ? Array.isArray(post.attachment) + ? post.attachment + : [post.attachment] + : []; + + // Fetch files + const limit = promiseLimit(2); + + const driveFiles = ( + await Promise.all( + fileList.map( + (x) => + limit(async () => { + const file = await resolveImage(actor, x); + const update: Partial = {}; + + const altText = truncate(x.name, DB_MAX_IMAGE_COMMENT_LENGTH) ?? null; + if (file.comment !== altText) { + update.comment = altText; + } + + // Don't unmark previously marked sensitive files, + // but if edited post contains sensitive marker, update it. + if (post.sensitive && !file.isSensitive) { + update.isSensitive = post.sensitive; + } + + if (notEmpty(update)) { + await DriveFiles.update(file.id, update); + updating = true; + } + + return file; + }) as Promise, + ), + ) + ).filter((file) => file != null); + const fileIds = driveFiles.map((file) => file.id); + const fileTypes = driveFiles.map((file) => file.type); + + const apEmojis = ( + await extractEmojis(post.tag || [], actor.host).catch((e) => []) + ).map((emoji) => emoji.name); + const apMentions = await extractApMentions(post.tag); + const apHashtags = await extractApHashtags(post.tag); + + const poll = await extractPollFromQuestion(post, resolver).catch( + () => undefined, + ); + + const choices = poll?.choices.flatMap((choice) => mfm.parse(choice)) ?? []; + + const tokens = mfm + .parse(text || "") + .concat(mfm.parse(cw || "")) + .concat(choices); + + const hashTags: string[] = apHashtags || extractHashtags(tokens); + + const mentionUsers = + apMentions || (await extractMentionedUsers(actor, tokens)); + + const mentionUserIds = mentionUsers.map((user) => user.id); + const remoteUsers = mentionUsers.filter((user) => user.host != null); + const remoteUserIds = remoteUsers.map((user) => user.id); + const remoteProfiles = await UserProfiles.findBy({ + userId: In(remoteUserIds), + }); + const mentionedRemoteUsers = remoteUsers.map((user) => { + const profile = remoteProfiles.find( + (profile) => profile.userId === user.id, + ); + return { + username: user.username, + host: user.host ?? null, + uri: user.uri, + url: profile ? profile.url : undefined, + } as IMentionedRemoteUsers[0]; + }); + + const update = {} as Partial; + if (text && text !== note.text) { + update.text = text; + } + if (cw !== note.cw) { + update.cw = cw ? cw : null; + } + if (fileIds.sort().join(",") !== note.fileIds.sort().join(",")) { + update.fileIds = fileIds; + update.attachedFileTypes = fileTypes; + } + + if (hashTags.sort().join(",") !== note.tags.sort().join(",")) { + update.tags = hashTags; + } + + if (mentionUserIds.sort().join(",") !== note.mentions.sort().join(",")) { + update.mentions = mentionUserIds; + update.mentionedRemoteUsers = JSON.stringify(mentionedRemoteUsers); + } + + if (apEmojis.sort().join(",") !== note.emojis.sort().join(",")) { + update.emojis = apEmojis; + } + + if (note.hasPoll !== !!poll) { + update.hasPoll = !!poll; + } + + if (poll) { + const dbPoll = await Polls.findOneBy({ noteId: note.id }); + if (poll?.votes != null && poll.votes.find(p => !Number.isInteger(p) || p < 0) !== undefined) { + throw new Error('Refusing to ingest poll with non-integer or negative vote count'); + } + + if (dbPoll == null) { + await Polls.insert({ + noteId: note.id, + choices: poll?.choices, + multiple: poll?.multiple, + votes: poll?.votes, + expiresAt: poll?.expiresAt, + noteVisibility: note.visibility === "hidden" ? "home" : note.visibility, + userId: actor.id, + userHost: actor.host, + }); + updating = true; + } else { + const choicesChanged = JSON.stringify(dbPoll.choices) !== JSON.stringify(poll.choices); + + if ( + dbPoll.multiple !== poll.multiple || + dbPoll.expiresAt !== poll.expiresAt || + dbPoll.noteVisibility !== note.visibility || + choicesChanged + ) { + await Polls.update( + { noteId: note.id }, + { + choices: poll?.choices, + multiple: poll?.multiple, + votes: poll?.votes, + expiresAt: poll?.expiresAt, + noteVisibility: + note.visibility === "hidden" ? "home" : note.visibility, + }, + ); + + // Reset votes + if (choicesChanged) { + await PollVotes.delete({ noteId: dbPoll.noteId }); + } + + updating = true; + } else { + for (let i = 0; i < poll.choices.length; i++) { + if (dbPoll.votes[i] !== poll.votes?.[i]) { + await Polls.update({ noteId: note.id }, { votes: poll?.votes }); + updating = true; + break; + } + } + } + } + } + + // Update Note + if (notEmpty(update)) { + update.updatedAt = new Date(); + + // Save updated note to the database + await Notes.update({ uri }, update); + + // Save an edit history for the previous note + await NoteEdits.insert({ + id: genId(), + noteId: note.id, + text: note.text, + cw: note.cw, + fileIds: note.fileIds, + updatedAt: update.updatedAt, + }); + + updating = true; + } + + if (updating) { + // Publish update event for the updated note details + publishNoteStream(note.id, "updated", { + updatedAt: update.updatedAt, + }); + + const updatedNote = { + ...note, + ...update + }; + + publishNoteUpdatesStream("updated", updatedNote); + } + + return null; +} diff --git a/packages/backend/src/remote/activitypub/models/person.ts b/packages/backend/src/remote/activitypub/models/person.ts new file mode 100644 index 0000000..c90278f --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/person.ts @@ -0,0 +1,872 @@ +import { URL } from "node:url"; +import promiseLimit from "promise-limit"; + +import config from "@/config/index.js"; +import { registerOrFetchInstanceDoc } from "@/services/register-or-fetch-instance-doc.js"; +import type { Note } from "@/models/entities/note.js"; +import { updateUsertags } from "@/services/update-hashtag.js"; +import { + Users, + Instances, + DriveFiles, + Followings, + UserProfiles, + UserPublickeys, +} from "@/models/index.js"; +import type { IRemoteUser, CacheableUser } from "@/models/entities/user.js"; +import { User } from "@/models/entities/user.js"; +import type { Emoji } from "@/models/entities/emoji.js"; +import { UserNotePining } from "@/models/entities/user-note-pining.js"; +import { genId } from "@/misc/gen-id.js"; +import { instanceChart, usersChart } from "@/services/chart/index.js"; +import { UserPublickey } from "@/models/entities/user-publickey.js"; +import { isDuplicateKeyValueError } from "@/misc/is-duplicate-key-value-error.js"; +import { extractDbHost, toPuny } from "@/misc/convert-host.js"; +import { UserProfile } from "@/models/entities/user-profile.js"; +import { toArray } from "@/prelude/array.js"; +import { fetchInstanceMetadata } from "@/services/fetch-instance-metadata.js"; +import { normalizeForSearch } from "@/misc/normalize-for-search.js"; +import { truncate } from "@/misc/truncate.js"; +import { StatusError } from "@/misc/fetch.js"; +import { uriPersonCache } from "@/services/user-cache.js"; +import { publishInternalEvent } from "@/services/stream.js"; +import { db } from "@/db/postgre.js"; +import { apLogger } from "../logger.js"; +import { htmlToMfm } from "../misc/html-to-mfm.js"; +import { fromHtml } from "../../../mfm/from-html.js"; +import type { IActor, IObject, IApPropertyValue } from "../type.js"; +import { + isCollectionOrOrderedCollection, + isCollection, + getApId, + getOneApHrefNullable, + isPropertyValue, + getApType, + isActor, +} from "../type.js"; +import Resolver from "../resolver.js"; +import { extractApHashtags } from "./tag.js"; +import { resolveNote, extractEmojis } from "./note.js"; +import { resolveImage } from "./image.js"; +import { + getSubjectHostFromUri, + getSubjectHostFromRemoteUser, + getSubjectHostFromAcctParts +} from "@/remote/resolve-user.js" +import { RecursionLimiter } from "@/models/repositories/user-profile.js"; +import { UserConverter } from "@/server/api/mastodon/converters/user.js"; + +import fetch from "node-fetch"; + +const logger = apLogger; + +const nameLength = 128; +const summaryLength = 2048; + +/** + * Validate and convert to actor object + * @param x Fetched object + * @param uri Fetch target URI + */ +function validateActor(x: IObject, uri: string): IActor { + const expectHost = extractDbHost(uri); + + if (x == null) { + throw new Error("invalid Actor: object is null"); + } + + if (!isActor(x)) { + throw new Error(`invalid Actor type '${x.type}'`); + } + + if (!(typeof x.id === "string" && x.id.length > 0)) { + throw new Error("invalid Actor: wrong id"); + } + + if (!(typeof x.inbox === "string" && x.inbox.length > 0 && extractDbHost(x.inbox) === expectHost)) { + throw new Error("invalid Actor: wrong inbox"); + } + + if (!(typeof x.outbox === "string" && x.outbox.length > 0 && extractDbHost(getApId(x.outbox)) === expectHost)) { + throw new Error("invalid Actor: wrong outbox"); + } + + const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined); + if (sharedInboxObject != null) { + const sharedInbox = getApId(sharedInboxObject); + if (!(typeof sharedInbox === "string" && sharedInbox.length > 0 && extractDbHost(sharedInbox) === expectHost)) { + throw new Error("invalid Actor: wrong shared inbox"); + } + } + + if (x.followers != null) { + x.followers = getApId(x.followers); + if (!(typeof x.followers === "string" && x.followers.length > 0 && extractDbHost(x.followers) === expectHost)) { + throw new Error("invalid Actor: wrong followers"); + } + } + + if (x.following != null) { + x.following = getApId(x.following); + if (!(typeof x.following === "string" && x.following.length > 0 && extractDbHost(x.following) === expectHost)) { + throw new Error("invalid Actor: wrong following"); + } + } + + if ( + !( + typeof x.preferredUsername === "string" && + x.preferredUsername.length > 0 && + x.preferredUsername.length <= 128 && + /^\w([\w-.]*\w)?$/.test(x.preferredUsername) + ) + ) { + throw new Error("invalid Actor: wrong username"); + } + + // These fields are only informational, and some AP software allows these + // fields to be very long. If they are too long, we cut them off. This way + // we can at least see these users and their activities. + if (x.name) { + if (!(typeof x.name === "string" && x.name.length > 0)) { + throw new Error("invalid Actor: wrong name"); + } + x.name = truncate(x.name, nameLength); + } + if (x.summary) { + if (!(typeof x.summary === "string" && x.summary.length > 0)) { + throw new Error("invalid Actor: wrong summary"); + } + x.summary = truncate(x.summary, summaryLength); + } + + const idHost = toPuny(new URL(x.id!).host); + if (idHost !== expectHost) { + throw new Error("invalid Actor: id has different host"); + } + + if (x.publicKey) { + if (typeof x.publicKey.id !== "string") { + throw new Error("invalid Actor: publicKey.id is not a string"); + } + + const publicKeyIdHost = toPuny(new URL(x.publicKey.id).host); + if (publicKeyIdHost !== expectHost) { + throw new Error("invalid Actor: publicKey.id has different host"); + } + } + + if (x.pronouns) { + if (typeof x.pronouns !== "object") { + throw new Error("invalid Actor: pronouns is not an object"); + } + for (const key of Object.keys(x.pronouns)) { + if (typeof x.pronouns[key] !== "string") { + throw new Error(`invalid Actor: pronouns.${key} is not a string`); + } + } + } + + if (x.canBite && typeof x.canBite !== "string") { + throw new Error("invalid Actor: canBite is not a string"); + } + + return x; +} + +/** + * Fetch a Person. + * + * If the target Person is registered in Iceshrimp, it will be returned. + */ +export async function fetchPerson( + uri: string, + resolver?: Resolver, +): Promise { + if (typeof uri !== "string") throw new Error("uri is not string"); + + const cached = await uriPersonCache.get(uri, true); + if (cached) return cached; + + // Fetch from the database if the URI points to this server + if (extractDbHost(uri) === toPuny(config.host)) { + const id = uri.split("/").pop(); + const u = await Users.findOneBy({ id }); + if (u) await uriPersonCache.set(uri, u); + return u; + } + + //#region Returns if already registered with this server + const user = await Users.findOneBy({ uri }); + + if (user != null) { + await uriPersonCache.set(uri, user); + return user; + } + //#endregion + + return null; +} + +/** + * Create Person. + */ +export async function createPerson( + uri: string, + resolver?: Resolver, + subjectHost?: string, + limiter: RecursionLimiter = new RecursionLimiter() +): Promise { + if (typeof uri !== "string") throw new Error("uri is not string"); + + if (extractDbHost(uri) === toPuny(config.host)) { + throw new StatusError( + "cannot resolve local user", + 400, + "cannot resolve local user", + ); + } + + if (resolver == null) resolver = new Resolver(); + + let object = (await resolver.resolve(uri)) as any; + + let person: IActor; + try { + person = validateActor(object, uri); + } + catch (e: any) { + // Work around GoToSocial issue #1186 (ref: https://github.com/superseriousbusiness/gotosocial/issues/1186) + if (typeof object.publicKey?.owner !== 'string' || object.inbox != null) + throw e; + + logger.info(`Received stub actor, re-resolving with key owner uri: ${object.publicKey.owner}`); + object = (await resolver.resolve(object.publicKey.owner)) as any; + person = validateActor(object, uri); + } + + logger.info(`Creating the Person: ${person.id}`); + + const usernameLower = person.preferredUsername?.toLowerCase(); + + const urlHostname = toPuny(new URL(object.id).hostname); + + const host = subjectHost ?? await getSubjectHostFromUri(object.id) ?? await getSubjectHostFromAcctParts(usernameLower, urlHostname) ?? urlHostname; + + if (usernameLower !== null) { + let checkUser = (await Users.findOneBy({ + usernameLower: usernameLower, + host: toPuny(new URL(object.id).hostname), + })) as IRemoteUser | null; + + if (checkUser != null) { + logger.info('Person already exists'); + if (host != checkUser.host) { + logger.info(`Updating existing person with canonical account domain (${usernameLower}@${checkUser.host} -> ${usernameLower}@${host})`); + await Users.update( + { + usernameLower: usernameLower, + host: checkUser.host, + }, + { + host: host, + }, + ); + checkUser.host = host; + } + logger.info('Returning existing person'); + return checkUser; + } + + if (host != toPuny(new URL(object.id).hostname)) { + checkUser = (await Users.findOneBy({ + usernameLower: usernameLower, + host: host, + })) as IRemoteUser | null; + + if (checkUser != null) { + logger.info('Person already exists'); + logger.info('Returning existing person'); + return checkUser; + } + } + } + + const { fields } = await analyzeAttachments(person.attachment || []); + + const tags = extractApHashtags(person.tag) + .map((tag) => normalizeForSearch(tag)) + .splice(0, 32); + + const isBot = getApType(object) === "Service"; + + const bday = person["vcard:bday"]?.match(/^\d{4}-\d{2}-\d{2}/); + + let url = getOneApHrefNullable(person.url); + const urlUrl = url != null ? new URL(url) : null; + const uriUrl = new URL(uri); + + if (urlUrl != null && urlUrl.protocol != 'https:') { + throw new Error(`unexpected schema of person url: ${url}`); + } + + if (urlUrl != null && urlUrl.host != uriUrl.host) { + logger.debug("Person url host doesn't match person uri host, clearing variable"); + url = undefined; + } + + let followersCount: number | undefined; + + if (typeof person.followers === "string") { + try { + let data = await fetch(person.followers, { + headers: { Accept: "application/json" }, + size: 1024 * 1024 + }); + let json_data = JSON.parse(await data.text()); + + followersCount = json_data.totalItems; + } catch { + followersCount = undefined; + } + } + + let followingCount: number | undefined; + + if (typeof person.following === "string") { + try { + let data = await fetch(person.following, { + headers: { Accept: "application/json" }, + size: 1024 * 1024 + }); + let json_data = JSON.parse(await data.text()); + + followingCount = json_data.totalItems; + } catch (e) { + followingCount = undefined; + } + } + + let canBite: "anyone" | "followers" | "nobody" = "nobody"; + if (person.canBite) { + if (person.canBite === "https://www.w3.org/ns/activitystreams#Public") { + canBite = "anyone"; + } else if (person.followers && person.canBite === getApId(person.followers)) { + canBite = "followers"; + } + } + + // Prepare objects + let user = new User({ + id: genId(), + avatarId: null, + bannerId: null, + createdAt: new Date(), + lastFetchedAt: new Date(), + name: truncate(person.name, nameLength), + isLocked: !!person.manuallyApprovesFollowers, + movedToUri: person.movedTo, + alsoKnownAs: person.alsoKnownAs, + isExplorable: !!person.discoverable, + username: person.preferredUsername, + usernameLower: person.preferredUsername!.toLowerCase(), + host, + inbox: person.inbox, + sharedInbox: + person.sharedInbox || + (person.endpoints ? person.endpoints.sharedInbox : undefined), + followersUri: person.followers + ? getApId(person.followers) + : undefined, + followersCount: + followersCount !== undefined + ? followersCount + : person.followers && + typeof person.followers !== "string" && + isCollectionOrOrderedCollection(person.followers) + ? person.followers.totalItems + : undefined, + followingCount: + followingCount !== undefined + ? followingCount + : person.following && + typeof person.following !== "string" && + isCollectionOrOrderedCollection(person.following) + ? person.following.totalItems + : undefined, + featured: person.featured ? getApId(person.featured) : undefined, + uri: person.id, + tags, + isBot, + isCat: (person as any).isCat === true, + canBite, + }) as IRemoteUser; + + const profile = new UserProfile({ + userId: user.id, + description: person.summary + ? await htmlToMfm(truncate(person.summary, summaryLength), person.tag) + : null, + url: url, + fields, + birthday: bday ? bday[0] : null, + location: person["vcard:Address"] || null, + userHost: host, + pronouns: person.pronouns || {}, + }); + + const publicKey = person.publicKey + ? new UserPublickey({ + userId: user.id, + keyId: person.publicKey.id, + keyPem: person.publicKey.publicKeyPem, + }) + : null; + + try { + // Save the objects atomically using a db transaction, note that we should never run any code in a transaction block directly + await db.transaction(async (transactionalEntityManager) => { + await transactionalEntityManager.save(user); + await transactionalEntityManager.save(profile); + if (publicKey) await transactionalEntityManager.save(publicKey); + }); + } catch (e) { + // duplicate key error + if (isDuplicateKeyValueError(e)) { + // /users/@a => /users/:id Corresponds to an error that may occur when the input is an alias like + const u = await Users.findOneBy({ + uri: person.id, + }); + + if (u) { + user = u as IRemoteUser; + } else { + throw new Error("already registered"); + } + } else { + logger.error(e instanceof Error ? e : new Error(e as string)); + throw e; + } + } + + // Register host + registerOrFetchInstanceDoc(host).then((i) => { + Instances.increment({ id: i.id }, "usersCount", 1); + instanceChart.newUser(i.host); + fetchInstanceMetadata(i); + }); + + usersChart.update(user!, true); + + // Hashtag update + updateUsertags(user!, tags); + + // Mentions update, then prewarm html cache + if (await limiter.shouldContinue()) UserProfiles.updateMentions(user!.id, limiter) + .then(_ => UserConverter.prewarmCacheById(user!.id)); + + //#region Fetch avatar and header image + const [avatar, banner] = await Promise.all( + [person.icon, person.image].map((img) => + img == null + ? Promise.resolve(null) + : resolveImage(user!, img).catch(() => null), + ), + ); + + const avatarId = avatar?.id ?? null; + const avatarBlurhash = avatar?.blurhash ?? null; + const avatarUrl = avatar ? DriveFiles.getDatabasePrefetchUrl(avatar, true) : null; + const bannerId = banner?.id ?? null; + const bannerBlurhash = banner?.blurhash ?? null; + const bannerUrl = banner ? DriveFiles.getDatabasePrefetchUrl(banner, false) : null; + + await Users.update(user!.id, { + avatarId, + avatarBlurhash, + avatarUrl, + bannerId, + bannerBlurhash, + bannerUrl, + }); + + user!.avatarId = avatarId; + user!.avatarBlurhash = avatarBlurhash; + user!.avatarUrl = avatarUrl; + user!.bannerId = bannerId; + user!.bannerBlurhash = bannerBlurhash; + user!.bannerUrl = bannerUrl; + //#endregion + + //#region Get custom emoji + const emojis = await extractEmojis(person.tag || [], host).catch((e) => { + logger.info(`extractEmojis: ${e}`); + return [] as Emoji[]; + }); + + const emojiNames = emojis.map((emoji) => emoji.name); + + await Users.update(user!.id, { + emojis: emojiNames, + }); + //#endregion + + await updateFeatured(user!.id, resolver, limiter).catch((err) => logger.error(err)); + + return user!; +} + +/** + * Update Person data from remote. + * If the target Person is not registered in Iceshrimp, it is ignored. + * @param uri URI of Person + * @param resolver Resolver + * @param hint Hint of Person object (If this value is a valid Person, it is used for updating without Remote resolve) + * @param userHint Hint of IRemoteUser object, used for updating user information for remotes that only support webfinger with acct: query + */ +export async function updatePerson( + uri: string, + resolver?: Resolver | null, + hint?: IObject, + userHint?: IRemoteUser, +): Promise { + if (typeof uri !== "string") throw new Error("uri is not string"); + + // Skip if the URI points to this server + if (extractDbHost(uri) === toPuny(config.host)) { + return; + } + + //#region Already registered on this server? + const user = (await Users.findOneBy({ uri })) as IRemoteUser; + + if (user == null) { + return; + } + //#endregion + + if (resolver == null) resolver = new Resolver(); + + const object = hint || (await resolver.resolve(uri)); + + const person = validateActor(object, uri); + + logger.info(`Updating the Person: ${person.id}`); + + const host = await getSubjectHostFromUri(uri) ?? await getSubjectHostFromRemoteUser(userHint); + + // Fetch avatar and header image + const [avatar, banner] = await Promise.all( + [person.icon, person.image].map((img) => + img == null + ? Promise.resolve(null) + : resolveImage(user, img).catch(() => null), + ), + ); + + // Custom pictogram acquisition + const emojis = await extractEmojis(person.tag || [], user.host).catch((e) => { + logger.info(`extractEmojis: ${e}`); + return [] as Emoji[]; + }); + + const emojiNames = emojis.map((emoji) => emoji.name); + + const { fields } = await analyzeAttachments(person.attachment || []); + + const tags = extractApHashtags(person.tag) + .map((tag) => normalizeForSearch(tag)) + .splice(0, 32); + + const bday = person["vcard:bday"]?.match(/^\d{4}-\d{2}-\d{2}/); + + const url = getOneApHrefNullable(person.url); + + if (url && !url.startsWith("https://")) { + throw new Error(`unexpected schema of person url: ${url}`); + } + + let followersCount: number | undefined; + + if (typeof person.followers === "string") { + try { + let data = await fetch(person.followers, { + headers: { Accept: "application/json" }, + size: 1024 * 1024 + }); + let json_data = JSON.parse(await data.text()); + + followersCount = json_data.totalItems; + } catch { + followersCount = undefined; + } + } + + let followingCount: number | undefined; + + if (typeof person.following === "string") { + try { + let data = await fetch(person.following, { + headers: { Accept: "application/json" }, + size: 1024 * 1024 + }); + let json_data = JSON.parse(await data.text()); + + followingCount = json_data.totalItems; + } catch { + followingCount = undefined; + } + } + + let canBite: "anyone" | "followers" | "nobody" = "nobody"; + if (person.canBite) { + if (person.canBite === "https://www.w3.org/ns/activitystreams#Public") { + canBite = "anyone"; + } else if (person.followers && person.canBite === getApId(person.followers)) { + canBite = "followers"; + } + } + + const updates = { + lastFetchedAt: new Date(), + inbox: person.inbox, + sharedInbox: + person.sharedInbox || + (person.endpoints ? person.endpoints.sharedInbox : undefined), + followersUri: person.followers ? getApId(person.followers) : undefined, + followersCount: + followersCount !== undefined + ? followersCount + : person.followers && + typeof person.followers !== "string" && + isCollectionOrOrderedCollection(person.followers) + ? person.followers.totalItems + : undefined, + followingCount: + followingCount !== undefined + ? followingCount + : person.following && + typeof person.following !== "string" && + isCollectionOrOrderedCollection(person.following) + ? person.following.totalItems + : undefined, + featured: person.featured, + emojis: emojiNames, + name: truncate(person.name, nameLength), + tags, + isBot: getApType(object) === "Service", + isCat: (person as any).isCat === true, + isLocked: !!person.manuallyApprovesFollowers, + movedToUri: person.movedTo || null, + alsoKnownAs: person.alsoKnownAs || null, + isExplorable: !!person.discoverable, + canBite, + } as Partial; + + if (avatar) { + updates.avatarId = avatar.id; + updates.avatarUrl = DriveFiles.getDatabasePrefetchUrl(avatar, true); + updates.avatarBlurhash = avatar.blurhash; + } + + if (banner) { + updates.bannerId = banner.id; + updates.bannerUrl = DriveFiles.getDatabasePrefetchUrl(banner, false); + updates.bannerBlurhash = banner.blurhash; + } + + if (host) { + updates.host = host; + } + + // Update user + await Users.update(user.id, updates); + + if (person.publicKey) { + await UserPublickeys.update( + { userId: user.id }, + { + keyId: person.publicKey.id, + keyPem: person.publicKey.publicKeyPem, + }, + ); + } + + // Get old profile to see if we need to update any matching html cache entries + const oldProfile = await UserProfiles.findOneBy({ userId: user.id }); + + const newProfile = { + url: url, + fields, + description: person._misskey_summary + ? truncate(person._misskey_summary, summaryLength) + : person.summary + ? await htmlToMfm(truncate(person.summary, summaryLength), person.tag) + : null, + birthday: bday ? bday[0] : null, + location: person["vcard:Address"] || null, + pronouns: person.pronouns || {}, + } as Partial; + + await UserProfiles.update({ userId: user.id }, newProfile); + + publishInternalEvent("remoteUserUpdated", { id: user.id }); + + // Hashtag Update + updateUsertags(user, tags); + + // Mentions update, then prewarm html cache + UserProfiles.updateMentions(user!.id) + .then(_ => UserConverter.prewarmCacheById(user!.id, oldProfile)); + + // If the user in question is a follower, followers will also be updated. + await Followings.update( + { + followerId: user.id, + }, + { + followerSharedInbox: + person.sharedInbox || + (person.endpoints ? person.endpoints.sharedInbox : null), + }, + ); + + await updateFeatured(user.id, resolver).catch((err) => logger.error(err)); +} + +/** + * Resolve Person. + * + * If the target person is registered in Iceshrimp, it returns it; + * otherwise, it fetches it from the remote server, registers it in Iceshrimp, and returns it. + */ +export async function resolvePerson( + uri: string, + resolver?: Resolver, + limiter: RecursionLimiter = new RecursionLimiter() +): Promise { + if (typeof uri !== "string") throw new Error("uri is not string"); + + //#region If already registered on this server, return it. + const user = await fetchPerson(uri); + + if (user != null) { + return user; + } + //#endregion + + // Fetched from remote server and registered + if (resolver == null) resolver = new Resolver(); + return await createPerson(uri, resolver, undefined, limiter); +} + +const services: { + [x: string]: (id: string, username: string) => any; +} = { + "misskey:authentication:github": (id, login) => ({ id, login }), + "misskey:authentication:discord": (id, name) => $discord(id, name), +}; + +const $discord = (id: string, name: string) => { + if (typeof name !== "string") { + name = "unknown#0000"; + } + const [username, discriminator] = name.split("#"); + return { id, username, discriminator }; +}; + +function addService(target: { [x: string]: any }, source: IApPropertyValue) { + const service = services[source.name]; + + if (typeof source.value !== "string") { + source.value = "unknown"; + } + + const [id, username] = source.value.split("@"); + + if (service) { + target[source.name.split(":")[2]] = service(id, username); + } +} + +export async function analyzeAttachments( + attachments: IObject | IObject[] | undefined, +) { + const fields: { + name: string; + value: string; + }[] = []; + const services: { [x: string]: any } = {}; + + if (Array.isArray(attachments)) { + for (const attachment of attachments.filter(isPropertyValue)) { + if (isPropertyValue(attachment.identifier)) { + addService(services, attachment.identifier); + } else { + fields.push({ + name: attachment.name, + value: await fromHtml(attachment.value), + }); + } + } + } + + return { fields, services }; +} + +export async function updateFeatured(userId: User["id"], resolver?: Resolver, limiter: RecursionLimiter = new RecursionLimiter()) { + const user = await Users.findOneByOrFail({ id: userId }); + if (!Users.isRemoteUser(user)) return; + if (!user.featured) return; + + logger.info(`Updating the featured: ${user.uri}`); + + if (resolver == null) resolver = new Resolver(); + + // Attempt to get a local user that follows the remote user + const follower = await Users.getRandomFollower(userId); + if (follower) resolver.setUser(follower); + + // Resolve to (Ordered)Collection Object + const collection = await resolver.resolveCollection(user.featured); + if (!isCollectionOrOrderedCollection(collection)) + throw new Error("Object is not Collection or OrderedCollection"); + + // Resolve to Object(may be Note) arrays + const unresolvedItems = isCollection(collection) + ? collection.items + : collection.orderedItems; + const items = await Promise.all( + toArray(unresolvedItems).map((x) => resolver.resolve(x)), + ); + + // Resolve and register Notes + resolver.reset(); + const limit = promiseLimit(2); + const featuredNotes = await Promise.all( + items + .filter((item) => getApType(item) === "Note") // TODO: Maybe it doesn't have to be a Note. + .slice(0, 5) + .map((item) => limit(() => resolveNote(item, resolver, limiter))), + ); + + // Prepare the objects + // For now, generate the id at a different time and maintain the order. + const data: Partial[] = []; + let td = 0; + for (const note of featuredNotes.filter((note) => note != null)) { + td -= 1000; + data.push({ + id: genId(new Date(Date.now() + td)), + createdAt: new Date(), + userId: user.id, + noteId: note!.id, + }); + } + + // Save the objects atomically using a db transaction, note that we should never run any code in a transaction block directly + await db.transaction(async (transactionalEntityManager) => { + await transactionalEntityManager.delete(UserNotePining, { userId: user.id }); + await transactionalEntityManager.insert(UserNotePining, data); + }); +} diff --git a/packages/backend/src/remote/activitypub/models/question.ts b/packages/backend/src/remote/activitypub/models/question.ts new file mode 100644 index 0000000..8a23f22 --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/question.ts @@ -0,0 +1,99 @@ +import config from "@/config/index.js"; +import Resolver from "../resolver.js"; +import type { IObject, IQuestion } from "../type.js"; +import { getApId, isQuestion } from "../type.js"; +import { apLogger } from "../logger.js"; +import { Notes, Polls } from "@/models/index.js"; +import type { IPoll } from "@/models/entities/poll.js"; +import { extractDbHost, toPuny } from "@/misc/convert-host.js"; + +export async function extractPollFromQuestion( + source: string | IObject, + resolver: Resolver, +): Promise { + const question = await resolver.resolve(source); + + if (!isQuestion(question)) { + throw new Error("invalid type"); + } + + const multiple = !question.oneOf; + const expiresAt = question.endTime + ? new Date(question.endTime) + : question.closed + ? new Date(question.closed) + : null; + + if (multiple && !question.anyOf) { + throw new Error("invalid question"); + } + + const choices = question[multiple ? "anyOf" : "oneOf"]!.map( + (x, i) => x.name!, + ); + + const votes = question[multiple ? "anyOf" : "oneOf"]!.map( + (x, i) => x.replies?.totalItems || x._misskey_votes || 0, + ); + + return { + choices, + votes, + multiple, + expiresAt, + }; +} + +/** + * Update votes of Question + * @param value URI of AP Question object or object itself + * @returns true if updated + */ +export async function updateQuestion( + value: string | IQuestion, + resolver: Resolver, +): Promise { + const uri = typeof value === "string" ? value : getApId(value); + + // Skip if URI points to this server + if (extractDbHost(uri) === toPuny(config.host)) throw new Error("uri points local"); + + //#region Already registered with this server? + const note = await Notes.findOneBy({ uri }); + if (note == null) throw new Error("Question is not registed"); + + const poll = await Polls.findOneBy({ noteId: note.id }); + if (poll == null) throw new Error("Question is not registed"); + //#endregion + + // resolve new Question object + const question = (await resolver.resolve(value)) as IQuestion; + apLogger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`); + + if (question.type !== "Question") throw new Error("object is not a Question"); + + const apChoices = question.oneOf || question.anyOf; + if (!apChoices) return false; + + let changed = false; + + for (const choice of poll.choices) { + const oldCount = poll.votes[poll.choices.indexOf(choice)]; + const newCount = apChoices.filter((ap) => ap.name === choice)[0]?.replies + ?.totalItems; + + if (newCount !== undefined && oldCount !== newCount) { + changed = true; + poll.votes[poll.choices.indexOf(choice)] = newCount; + } + } + + await Polls.update( + { noteId: note.id }, + { + votes: poll.votes, + }, + ); + + return changed; +} diff --git a/packages/backend/src/remote/activitypub/models/tag.ts b/packages/backend/src/remote/activitypub/models/tag.ts new file mode 100644 index 0000000..537cdec --- /dev/null +++ b/packages/backend/src/remote/activitypub/models/tag.ts @@ -0,0 +1,25 @@ +import { toArray } from "@/prelude/array.js"; +import type { IObject, IApHashtag } from "../type.js"; +import { isHashtag } from "../type.js"; + +export function extractApHashtags( + tags: IObject | IObject[] | null | undefined, +) { + if (tags == null) return []; + + const hashtags = extractApHashtagObjects(tags); + + return hashtags + .map((tag) => { + const m = tag.name.match(/^#(.+)/); + return m ? m[1] : null; + }) + .filter((x): x is string => x != null); +} + +export function extractApHashtagObjects( + tags: IObject | IObject[] | null | undefined, +): IApHashtag[] { + if (tags == null) return []; + return toArray(tags).filter(isHashtag); +} diff --git a/packages/backend/src/remote/activitypub/perform.ts b/packages/backend/src/remote/activitypub/perform.ts new file mode 100644 index 0000000..5a8fb6d --- /dev/null +++ b/packages/backend/src/remote/activitypub/perform.ts @@ -0,0 +1,25 @@ +import type { IObject } from "./type.js"; +import type { CacheableRemoteUser } from "@/models/entities/user.js"; +import { performActivity } from "./kernel/index.js"; +import { updatePerson } from "./models/person.js"; + +export default async ( + actor: CacheableRemoteUser, + activity: IObject, +): Promise => { + const ret = await performActivity(actor, activity); + + // Update the remote user information if it is out of date + if (actor.uri) { + if ( + actor.lastFetchedAt == null || + Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24 + ) { + setImmediate(() => { + updatePerson(actor.uri!); + }); + } + } + + return ret; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/accept-follow.ts b/packages/backend/src/remote/activitypub/renderer/accept-follow.ts new file mode 100644 index 0000000..c14f8f3 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/accept-follow.ts @@ -0,0 +1,15 @@ +import config from "@/config/index.js"; +import type { User } from "@/models/entities/user.js"; +import renderFollow from "./follow.js"; + +export default ( + follower: { id: User["id"]; host: User["host"]; uri: User["host"] }, + followee: { id: User["id"]; host: User["host"]; uri: User["host"] }, + requestId?: string, +) => { + return { + type: "Accept", + actor: `${config.url}/users/${followee.id}`, + object: renderFollow(follower, followee, requestId), + }; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/accept-quote-request.ts b/packages/backend/src/remote/activitypub/renderer/accept-quote-request.ts new file mode 100644 index 0000000..08c7d90 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/accept-quote-request.ts @@ -0,0 +1,18 @@ +import config from "@/config/index.js"; +import { IQuoteRequest } from "../type"; +import { InteractionStamp } from "@/models/entities/interaction-stamp"; + +// assumes stamp.targetNote is populated +export default async (request: IQuoteRequest, stamp: InteractionStamp) => ({ + type: "Accept", + to: request.actor, + actor: `${config.url}/users/${stamp.targetNote!.userId}`, + object: { + type: "QuoteRequest", + id: request.id, + actor: request.actor, + object: request.object, + instrument: request.instrument, + }, + result: `${config.url}/stamp/${stamp.id}`, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/add.ts b/packages/backend/src/remote/activitypub/renderer/add.ts new file mode 100644 index 0000000..d8203ac --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/add.ts @@ -0,0 +1,9 @@ +import config from "@/config/index.js"; +import type { ILocalUser } from "@/models/entities/user.js"; + +export default (user: ILocalUser, target: any, object: any) => ({ + type: "Add", + actor: `${config.url}/users/${user.id}`, + target, + object, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/announce.ts b/packages/backend/src/remote/activitypub/renderer/announce.ts new file mode 100644 index 0000000..1fd1842 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/announce.ts @@ -0,0 +1,37 @@ +import config from "@/config/index.js"; +import type { Note } from "@/models/entities/note.js"; + +export default (object: any, note: Note) => { + const attributedTo = `${config.url}/users/${note.userId}`; + + const mentions = ( + JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers + ).map((x) => x.uri); + + let to: string[] = []; + let cc: string[] = []; + + if (note.visibility === "public") { + to = ["https://www.w3.org/ns/activitystreams#Public"]; + cc = [`${attributedTo}/followers`]; + } else if (note.visibility === "home") { + to = [`${attributedTo}/followers`]; + cc = ["https://www.w3.org/ns/activitystreams#Public"]; + } else if (note.visibility === "followers") { + to = [`${attributedTo}/followers`]; + } else if (note.visibility === "specified") { + to = mentions; + } else { + return null; + } + + return { + id: `${config.url}/notes/${note.id}/activity`, + actor: `${config.url}/users/${note.userId}`, + type: "Announce", + published: note.createdAt.toISOString(), + to, + cc, + object, + }; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/bite.ts b/packages/backend/src/remote/activitypub/renderer/bite.ts new file mode 100644 index 0000000..982a9dd --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/bite.ts @@ -0,0 +1,12 @@ +import config from "@/config/index.js"; +import { Bites } from "@/models/index.js"; +import { Bite } from "@/models/entities/bite.js"; + +export default async (bite: Bite) => ({ + id: `${config.url}/bites/${bite.id}`, + type: "Bite", + actor: `${config.url}/users/${bite.userId}`, + target: await Bites.targetUri(bite), + published: bite.createdAt.toISOString(), + to: await Bites.targetUserUri(bite), +}); diff --git a/packages/backend/src/remote/activitypub/renderer/block.ts b/packages/backend/src/remote/activitypub/renderer/block.ts new file mode 100644 index 0000000..c2ea267 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/block.ts @@ -0,0 +1,20 @@ +import config from "@/config/index.js"; +import type { Blocking } from "@/models/entities/blocking.js"; + +/** + * Renders a block into its ActivityPub representation. + * + * @param block The block to be rendered. The blockee relation must be loaded. + */ +export function renderBlock(block: Blocking) { + if (block.blockee?.uri == null) { + throw new Error("renderBlock: missing blockee uri"); + } + + return { + type: "Block", + id: `${config.url}/blocks/${block.id}`, + actor: `${config.url}/users/${block.blockerId}`, + object: block.blockee.uri, + }; +} diff --git a/packages/backend/src/remote/activitypub/renderer/create.ts b/packages/backend/src/remote/activitypub/renderer/create.ts new file mode 100644 index 0000000..857f572 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/create.ts @@ -0,0 +1,17 @@ +import config from "@/config/index.js"; +import type { Note } from "@/models/entities/note.js"; + +export default (object: any, note: Note) => { + const activity = { + id: `${config.url}/notes/${note.id}/activity`, + actor: `${config.url}/users/${note.userId}`, + type: "Create", + published: note.createdAt.toISOString(), + object, + } as any; + + if (object.to) activity.to = object.to; + if (object.cc) activity.cc = object.cc; + + return activity; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/delete.ts b/packages/backend/src/remote/activitypub/renderer/delete.ts new file mode 100644 index 0000000..70bdc34 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/delete.ts @@ -0,0 +1,9 @@ +import config from "@/config/index.js"; +import type { User } from "@/models/entities/user.js"; + +export default (object: any, user: { id: User["id"]; host: null }) => ({ + type: "Delete", + actor: `${config.url}/users/${user.id}`, + object, + published: new Date().toISOString(), +}); diff --git a/packages/backend/src/remote/activitypub/renderer/document.ts b/packages/backend/src/remote/activitypub/renderer/document.ts new file mode 100644 index 0000000..1c2ca89 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/document.ts @@ -0,0 +1,9 @@ +import type { DriveFile } from "@/models/entities/drive-file.js"; +import { DriveFiles } from "@/models/index.js"; + +export default (file: DriveFile) => ({ + type: "Document", + mediaType: file.type, + url: DriveFiles.getPublicUrl(file), + name: file.comment, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/emoji.ts b/packages/backend/src/remote/activitypub/renderer/emoji.ts new file mode 100644 index 0000000..c4d96dc --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/emoji.ts @@ -0,0 +1,19 @@ +import config from "@/config/index.js"; +import type { Emoji } from "@/models/entities/emoji.js"; + +export default (emoji: Emoji) => ({ + id: `${config.url}/emojis/${emoji.name}`, + type: "Emoji", + name: `:${emoji.name}:`, + updated: + emoji.updatedAt != null + ? emoji.updatedAt.toISOString() + : new Date().toISOString, + icon: { + type: "Image", + mediaType: emoji.glyph ? "image/svg+xml" : emoji.type || "image/png", + url: emoji.glyph + ? emoji.originalUrl + : emoji.publicUrl || emoji.originalUrl, // || emoji.originalUrl してるのは後方互換性のため + }, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/flag.ts b/packages/backend/src/remote/activitypub/renderer/flag.ts new file mode 100644 index 0000000..f94d508 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/flag.ts @@ -0,0 +1,20 @@ +import config from "@/config/index.js"; +import { IObject, IActivity } from "@/remote/activitypub/type.js"; +import type { ILocalUser } from "@/models/entities/user.js"; +import { IRemoteUser } from "@/models/entities/user.js"; +import { getInstanceActor } from "@/services/instance-actor.js"; + +// to anonymise reporters, the reporting actor must be a system user +// object has to be a uri or array of uris +export const renderFlag = ( + user: ILocalUser, + object: [string], + content: string, +) => { + return { + type: "Flag", + actor: `${config.url}/users/${user.id}`, + content, + object, + }; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/follow-relay.ts b/packages/backend/src/remote/activitypub/renderer/follow-relay.ts new file mode 100644 index 0000000..ad7f05b --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/follow-relay.ts @@ -0,0 +1,14 @@ +import config from "@/config/index.js"; +import type { Relay } from "@/models/entities/relay.js"; +import type { ILocalUser } from "@/models/entities/user.js"; + +export function renderFollowRelay(relay: Relay, relayActor: ILocalUser) { + const follow = { + id: `${config.url}/activities/follow-relay/${relay.id}`, + type: "Follow", + actor: `${config.url}/users/${relayActor.id}`, + object: "https://www.w3.org/ns/activitystreams#Public", + }; + + return follow; +} diff --git a/packages/backend/src/remote/activitypub/renderer/follow-user.ts b/packages/backend/src/remote/activitypub/renderer/follow-user.ts new file mode 100644 index 0000000..22ee429 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/follow-user.ts @@ -0,0 +1,12 @@ +import config from "@/config/index.js"; +import { Users } from "@/models/index.js"; +import type { User } from "@/models/entities/user.js"; + +/** + * Convert (local|remote)(Follower|Followee)ID to URL + * @param id Follower|Followee ID + */ +export default async function renderFollowUser(id: User["id"]): Promise { + const user = await Users.findOneByOrFail({ id: id }); + return Users.isLocalUser(user) ? `${config.url}/users/${user.id}` : user.uri; +} diff --git a/packages/backend/src/remote/activitypub/renderer/follow.ts b/packages/backend/src/remote/activitypub/renderer/follow.ts new file mode 100644 index 0000000..3ff89c1 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/follow.ts @@ -0,0 +1,22 @@ +import config from "@/config/index.js"; +import type { User } from "@/models/entities/user.js"; +import { Users } from "@/models/index.js"; + +export default ( + follower: { id: User["id"]; host: User["host"]; uri: User["host"] }, + followee: { id: User["id"]; host: User["host"]; uri: User["host"] }, + requestId?: string, +) => { + const follow = { + id: requestId ?? `${config.url}/follows/${follower.id}/${followee.id}`, + type: "Follow", + actor: Users.isLocalUser(follower) + ? `${config.url}/users/${follower.id}` + : follower.uri, + object: Users.isLocalUser(followee) + ? `${config.url}/users/${followee.id}` + : followee.uri, + } as any; + + return follow; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/hashtag.ts b/packages/backend/src/remote/activitypub/renderer/hashtag.ts new file mode 100644 index 0000000..a00cd1f --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/hashtag.ts @@ -0,0 +1,7 @@ +import config from "@/config/index.js"; + +export default (tag: string) => ({ + type: "Hashtag", + href: `${config.url}/tags/${encodeURIComponent(tag)}`, + name: `#${tag}`, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/image.ts b/packages/backend/src/remote/activitypub/renderer/image.ts new file mode 100644 index 0000000..96183c7 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/image.ts @@ -0,0 +1,9 @@ +import type { DriveFile } from "@/models/entities/drive-file.js"; +import { DriveFiles } from "@/models/index.js"; + +export default (file: DriveFile) => ({ + type: "Image", + url: DriveFiles.getPublicUrl(file), + sensitive: file.isSensitive, + name: file.comment, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/index.ts b/packages/backend/src/remote/activitypub/renderer/index.ts new file mode 100644 index 0000000..7abbef9 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/index.ts @@ -0,0 +1,36 @@ +import { v4 as uuid } from "uuid"; +import config from "@/config/index.js"; +import { getUserKeypair } from "@/misc/keypair-store.js"; +import type { User } from "@/models/entities/user.js"; +import { LdSignature } from "../misc/ld-signature.js"; +import type { IActivity } from "../type.js"; +import { WellKnownContext } from "@/remote/activitypub/misc/contexts.js"; + +export const renderActivity = (x: any): IActivity | null => { + if (x == null) return null; + + if (typeof x === "object" && x.id == null) { + x.id = `${config.url}/${uuid()}`; + } + + return Object.assign({}, WellKnownContext, x); +}; + +export const attachLdSignature = async ( + activity: any, + user: { id: User["id"]; host: null }, +): Promise => { + if (activity == null) return null; + + const keypair = await getUserKeypair(user.id); + + const ldSignature = new LdSignature(); + ldSignature.debug = false; + activity = await ldSignature.signRsaSignature2017( + activity, + keypair.privateKey, + `${config.url}/users/${user.id}#main-key`, + ); + + return activity; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/key.ts b/packages/backend/src/remote/activitypub/renderer/key.ts new file mode 100644 index 0000000..084bb53 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/key.ts @@ -0,0 +1,14 @@ +import config from "@/config/index.js"; +import type { ILocalUser } from "@/models/entities/user.js"; +import type { UserKeypair } from "@/models/entities/user-keypair.js"; +import { createPublicKey } from "node:crypto"; + +export default (user: ILocalUser, key: UserKeypair, postfix?: string) => ({ + id: `${config.url}/users/${user.id}${postfix || "/publickey"}`, + type: "Key", + owner: `${config.url}/users/${user.id}`, + publicKeyPem: createPublicKey(key.publicKey).export({ + type: "spki", + format: "pem", + }), +}); diff --git a/packages/backend/src/remote/activitypub/renderer/like.ts b/packages/backend/src/remote/activitypub/renderer/like.ts new file mode 100644 index 0000000..53c66c5 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/like.ts @@ -0,0 +1,37 @@ +import { IsNull } from "typeorm"; +import config from "@/config/index.js"; +import type { NoteReaction } from "@/models/entities/note-reaction.js"; +import type { Note } from "@/models/entities/note.js"; +import { Emojis } from "@/models/index.js"; +import renderEmoji from "./emoji.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; + +export const renderLike = async (noteReaction: NoteReaction, note: Note) => { + const reaction = noteReaction.reaction; + const meta = await fetchMeta(); + + const object = { + type: "Like", + id: `${config.url}/likes/${noteReaction.id}`, + actor: `${config.url}/users/${noteReaction.userId}`, + object: note.uri ? note.uri : `${config.url}/notes/${noteReaction.noteId}`, + ...(!meta.defaultReaction.includes(reaction) + ? { + content: reaction, + _misskey_reaction: reaction, + } + : {}), + } as any; + + if (reaction.startsWith(":")) { + const name = reaction.replace(/:/g, ""); + const emoji = await Emojis.findOneBy({ + name, + host: IsNull(), + }); + + if (emoji) object.tag = [renderEmoji(emoji)]; + } + + return object; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/mention.ts b/packages/backend/src/remote/activitypub/renderer/mention.ts new file mode 100644 index 0000000..e7f0435 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/mention.ts @@ -0,0 +1,13 @@ +import config from "@/config/index.js"; +import type { User, ILocalUser } from "@/models/entities/user.js"; +import { Users } from "@/models/index.js"; + +export default (mention: User) => ({ + type: "Mention", + href: Users.isRemoteUser(mention) + ? mention.uri + : `${config.url}/users/${(mention as ILocalUser).id}`, + name: Users.isRemoteUser(mention) + ? `@${mention.username}@${mention.host}` + : `@${(mention as ILocalUser).username}`, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/note.ts b/packages/backend/src/remote/activitypub/renderer/note.ts new file mode 100644 index 0000000..8e0dfec --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/note.ts @@ -0,0 +1,204 @@ +import { In, IsNull } from "typeorm"; +import config from "@/config/index.js"; +import type { Note, IMentionedRemoteUsers } from "@/models/entities/note.js"; +import type { DriveFile } from "@/models/entities/drive-file.js"; +import { DriveFiles, Notes, Users, Emojis, Polls } from "@/models/index.js"; +import type { Emoji } from "@/models/entities/emoji.js"; +import type { Poll } from "@/models/entities/poll.js"; +import toHtml from "../misc/get-note-html.js"; +import renderEmoji from "./emoji.js"; +import renderMention from "./mention.js"; +import renderHashtag from "./hashtag.js"; +import renderDocument from "./document.js"; +import sanitizeHtml from "sanitize-html"; + +export default async function renderNote( + note: Note, + dive = true, + isTalk = false, +): Promise> { + const getPromisedFiles = async (ids: string[]) => { + if (!ids || ids.length === 0) return []; + const items = await DriveFiles.findBy({ id: In(ids) }); + return ids + .map((id) => items.find((item) => item.id === id)) + .filter((item) => item != null) as DriveFile[]; + }; + + let inReplyTo; + let inReplyToNote: Note | null; + + if (note.replyId) { + inReplyToNote = await Notes.findOneBy({ id: note.replyId }); + + if (inReplyToNote != null) { + const inReplyToUser = await Users.findOneBy({ id: inReplyToNote.userId }); + + if (inReplyToUser != null) { + if (inReplyToNote.uri) { + inReplyTo = inReplyToNote.uri; + } else { + if (dive) { + inReplyTo = await renderNote(inReplyToNote, false); + } else { + inReplyTo = `${config.url}/notes/${inReplyToNote.id}`; + } + } + } + } + } else { + inReplyTo = null; + } + + let quoteId: string | undefined; + let quoteUrl: string | undefined; + + if (note.renoteId) { + const renote = await Notes.findOneBy({ id: note.renoteId }); + + if (renote) { + if (renote.userHost) { + quoteId = renote.uri!; + quoteUrl = renote.url ?? renote.uri!; + } else { + quoteId = quoteUrl = `${config.url}/notes/${renote.id}`; + } + } + } + + const attributedTo = `${config.url}/users/${note.userId}`; + + const mentions = ( + JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers + ).map((x) => x.uri); + + let to: string[] = []; + let cc: string[] = []; + + if (note.visibility === "public") { + to = ["https://www.w3.org/ns/activitystreams#Public"]; + cc = [`${attributedTo}/followers`].concat(mentions); + } else if (note.visibility === "home") { + to = [`${attributedTo}/followers`]; + cc = ["https://www.w3.org/ns/activitystreams#Public"].concat(mentions); + } else if (note.visibility === "followers") { + to = [`${attributedTo}/followers`]; + cc = mentions; + } else { + to = mentions; + } + + const mentionedUsers = + note.mentions.length > 0 + ? await Users.findBy({ + id: In(note.mentions), + }) + : []; + + const hashtagTags = (note.tags || []).map((tag) => renderHashtag(tag)); + const mentionTags = mentionedUsers.map((u) => renderMention(u)); + + const files = await getPromisedFiles(note.fileIds); + + const text = note.text ?? ""; + let poll: Poll | null = null; + + if (note.hasPoll) { + poll = await Polls.findOneBy({ noteId: note.id }); + } + + const summary = note.cw === "" ? String.fromCharCode(0x200b) : note.cw; + + let content = await toHtml( + Object.assign({}, note, { + text, + }), + ); + + if (quoteId) { + // wrapping in p.quote-inline lets mastodon automatically strip the link + const quoteHREFSan = (quoteUrl || quoteId).replaceAll("&", "&").replaceAll('"', """); + const quoteTextSan = sanitizeHtml(quoteUrl || quoteId); + content += `

RE: ${quoteTextSan}

`; + } + + const emojis = await getEmojis(note.emojis); + const apemojis = emojis.map((emoji) => renderEmoji(emoji)); + + const tag = [...hashtagTags, ...mentionTags, ...apemojis]; + + const asPoll = poll + ? { + type: "Question", + content: await toHtml( + Object.assign({}, note, { + text: text, + }), + ), + [poll.expiresAt && poll.expiresAt < new Date() ? "closed" : "endTime"]: + poll.expiresAt, + [poll.multiple ? "anyOf" : "oneOf"]: poll.choices.map((text, i) => ({ + type: "Note", + name: text, + replies: { + type: "Collection", + totalItems: poll!.votes[i], + }, + })), + } + : {}; + + const asTalk = isTalk + ? { + _misskey_talk: true, + } + : {}; + + return { + id: `${config.url}/notes/${note.id}`, + type: "Note", + attributedTo, + summary, + content, + _misskey_content: text, + source: { + content: text, + mediaType: "text/x.misskeymarkdown", + }, + _misskey_quote: quoteId, + quoteUri: quoteId, + quoteUrl: quoteId, + quote: note.canQuote ? quoteId : undefined, + published: note.createdAt.toISOString(), + to, + cc, + inReplyTo, + attachment: files.map(renderDocument), + sensitive: note.cw != null || files.some((file) => file.isSensitive), + tag, + interactionPolicy: (note.visibility === "public" || note.visibility === "home") ? { + canQuote: { + automaticApproval: "https://www.w3.org/ns/activitystreams#Public", + }, + } : undefined, + quoteAuthorization: note.quoteAuthorization || undefined, + ...asPoll, + ...asTalk, + }; +} + +export async function getEmojis(names: string[]): Promise { + if (names == null || names.length === 0) return []; + + const emojis = await Promise.all( + names.map((name) => { + const parts = name.split("@"); + return Emojis.findOneBy({ + name: parts[0], + host: parts.length === 2 ? parts[1] : IsNull(), + }); + }), + ); + + return emojis.filter((emoji) => emoji != null) as Emoji[]; +} diff --git a/packages/backend/src/remote/activitypub/renderer/ordered-collection-page.ts b/packages/backend/src/remote/activitypub/renderer/ordered-collection-page.ts new file mode 100644 index 0000000..2275c9c --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/ordered-collection-page.ts @@ -0,0 +1,30 @@ +/** + * Render OrderedCollectionPage + * @param id URL of self + * @param totalItems Number of total items + * @param orderedItems Items + * @param partOf URL of base + * @param prev URL of prev page (optional) + * @param next URL of next page (optional) + */ +export default function ( + id: string, + totalItems: any, + orderedItems: any, + partOf: string, + prev?: string, + next?: string, +) { + const page = { + id, + partOf, + type: "OrderedCollectionPage", + totalItems, + orderedItems, + } as any; + + if (prev) page.prev = prev; + if (next) page.next = next; + + return page; +} diff --git a/packages/backend/src/remote/activitypub/renderer/ordered-collection.ts b/packages/backend/src/remote/activitypub/renderer/ordered-collection.ts new file mode 100644 index 0000000..b545755 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/ordered-collection.ts @@ -0,0 +1,34 @@ +/** + * Render OrderedCollection + * @param id URL of self + * @param totalItems Total number of items + * @param first URL of first page (optional) + * @param last URL of last page (optional) + * @param orderedItems attached objects (optional) + */ +export default function ( + id: string | null, + totalItems: any, + first?: string, + last?: string, + orderedItems?: (Record | string)[], +): { + id: string | null; + type: "OrderedCollection"; + totalItems: any; + first?: string; + last?: string; + orderedItems?: (Record | string)[]; +} { + const page: any = { + id, + type: "OrderedCollection", + totalItems, + }; + + if (first) page.first = first; + if (last) page.last = last; + if (orderedItems) page.orderedItems = orderedItems; + + return page; +} diff --git a/packages/backend/src/remote/activitypub/renderer/person.ts b/packages/backend/src/remote/activitypub/renderer/person.ts new file mode 100644 index 0000000..d65634f --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/person.ts @@ -0,0 +1,108 @@ +import * as mfm from "mfm-js"; +import config from "@/config/index.js"; +import type { ILocalUser } from "@/models/entities/user.js"; +import { DriveFiles, UserProfiles } from "@/models/index.js"; +import { getUserKeypair } from "@/misc/keypair-store.js"; +import { toHtml } from "../../../mfm/to-html.js"; +import renderImage from "./image.js"; +import renderKey from "./key.js"; +import { getEmojis } from "./note.js"; +import renderEmoji from "./emoji.js"; +import renderHashtag from "./hashtag.js"; +import type { IIdentifier } from "../models/identifier.js"; + +export async function renderPerson(user: ILocalUser) { + const id = `${config.url}/users/${user.id}`; + const isSystem = !!user.username.match(/\./); + + const [avatar, banner, profile] = await Promise.all([ + user.avatarId + ? DriveFiles.findOneBy({ id: user.avatarId }) + : Promise.resolve(undefined), + user.bannerId + ? DriveFiles.findOneBy({ id: user.bannerId }) + : Promise.resolve(undefined), + UserProfiles.findOneByOrFail({ userId: user.id }), + ]); + + const attachment: { + type: "PropertyValue"; + name: string; + value: string; + identifier?: IIdentifier; + }[] = []; + + if (profile.fields) { + for (const field of profile.fields) { + const value = await toHtml(mfm.parse(field.value), profile.mentions, profile.userHost); + attachment.push({ + type: "PropertyValue", + name: field.name, + value: value ?? field.value, + }); + } + } + + const emojis = await getEmojis(user.emojis); + const apemojis = emojis.map((emoji) => renderEmoji(emoji)); + + const hashtagTags = (user.tags || []).map((tag) => renderHashtag(tag)); + + const tag = [...apemojis, ...hashtagTags]; + + const keypair = await getUserKeypair(user.id); + + let canBite; + if (user.canBite === "anyone") { + canBite = "https://www.w3.org/ns/activitystreams#Public"; + } else if (user.canBite === "followers") { + canBite = user.followersUri ?? `${config.url}/users/${user.id}/followers`; + } + + const person = { + type: isSystem ? "Application" : user.isBot ? "Service" : "Person", + id, + inbox: `${id}/inbox`, + outbox: `${id}/outbox`, + followers: `${id}/followers`, + following: `${id}/following`, + featured: `${id}/collections/featured`, + sharedInbox: `${config.url}/inbox`, + endpoints: { sharedInbox: `${config.url}/inbox` }, + url: `${config.url}/@${user.username}`, + preferredUsername: user.username, + name: user.name, + summary: profile.description + ? await toHtml(mfm.parse(profile.description), profile.mentions, profile.userHost) + : null, + _misskey_summary: profile.description, + icon: avatar ? renderImage(avatar) : null, + image: banner ? renderImage(banner) : null, + tag, + manuallyApprovesFollowers: user.isLocked, + discoverable: !!user.isExplorable, + publicKey: renderKey(user, keypair, "#main-key"), + isCat: user.isCat, + attachment: attachment.length ? attachment : undefined, + pronouns: profile.pronouns, + canBite, + } as any; + + if (user.movedToUri) { + person.movedTo = user.movedToUri; + } + + if (user.alsoKnownAs) { + person.alsoKnownAs = user.alsoKnownAs; + } + + if (profile.birthday) { + person["vcard:bday"] = profile.birthday; + } + + if (profile.location) { + person["vcard:Address"] = profile.location; + } + + return person; +} diff --git a/packages/backend/src/remote/activitypub/renderer/question.ts b/packages/backend/src/remote/activitypub/renderer/question.ts new file mode 100644 index 0000000..cb89aa7 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/question.ts @@ -0,0 +1,27 @@ +import config from "@/config/index.js"; +import type { User } from "@/models/entities/user.js"; +import type { Note } from "@/models/entities/note.js"; +import type { Poll } from "@/models/entities/poll.js"; + +export default async function renderQuestion( + user: { id: User["id"] }, + note: Note, + poll: Poll, +) { + const question = { + type: "Question", + id: `${config.url}/questions/${note.id}`, + actor: `${config.url}/users/${user.id}`, + content: note.text || "", + [poll.multiple ? "anyOf" : "oneOf"]: poll.choices.map((text, i) => ({ + name: text, + _misskey_votes: poll.votes[i], + replies: { + type: "Collection", + totalItems: poll.votes[i], + }, + })), + }; + + return question; +} diff --git a/packages/backend/src/remote/activitypub/renderer/quote-authorization.ts b/packages/backend/src/remote/activitypub/renderer/quote-authorization.ts new file mode 100644 index 0000000..dbfa010 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/quote-authorization.ts @@ -0,0 +1,14 @@ +import config from "@/config/index.js"; +import { InteractionStamp } from "@/models/entities/interaction-stamp"; + +// assumes stamp.note and stamp.targetNote are populated +export default (stamp: InteractionStamp) => ({ + id: `${config.url}/stamp/${stamp.id}`, + type: "QuoteAuthorization", + attributedTo: `${config.url}/users/${stamp.targetNote!.userId}`, + interactingObject: + stamp.note!.userHost === null + ? `${config.url}/notes/${stamp.note!.id}` + : stamp.note!.uri, + interactionTarget: `${config.url}/notes/${stamp.targetNoteId}`, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/quote-request.ts b/packages/backend/src/remote/activitypub/renderer/quote-request.ts new file mode 100644 index 0000000..d759409 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/quote-request.ts @@ -0,0 +1,11 @@ +import config from "@/config/index.js"; +import type { Note } from "@/models/entities/note.js"; + +export default function renderQuoteRequest(note: Note, targetNote: Note) { + return { + type: "QuoteRequest", + actor: `${config.url}/users/${note.userId}`, + object: targetNote.uri!, + instrument: `${config.url}/notes/${note.id}`, + }; +} diff --git a/packages/backend/src/remote/activitypub/renderer/read.ts b/packages/backend/src/remote/activitypub/renderer/read.ts new file mode 100644 index 0000000..212e7e8 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/read.ts @@ -0,0 +1,12 @@ +import config from "@/config/index.js"; +import type { User } from "@/models/entities/user.js"; +import type { MessagingMessage } from "@/models/entities/messaging-message.js"; + +export const renderReadActivity = ( + user: { id: User["id"] }, + message: MessagingMessage, +) => ({ + type: "Read", + actor: `${config.url}/users/${user.id}`, + object: message.uri, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/reject.ts b/packages/backend/src/remote/activitypub/renderer/reject.ts new file mode 100644 index 0000000..7ac4452 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/reject.ts @@ -0,0 +1,8 @@ +import config from "@/config/index.js"; +import type { User } from "@/models/entities/user.js"; + +export default (object: any, user: { id: User["id"] }) => ({ + type: "Reject", + actor: `${config.url}/users/${user.id}`, + object, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/remove.ts b/packages/backend/src/remote/activitypub/renderer/remove.ts new file mode 100644 index 0000000..e3b3fef --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/remove.ts @@ -0,0 +1,9 @@ +import config from "@/config/index.js"; +import type { User } from "@/models/entities/user.js"; + +export default (user: { id: User["id"] }, target: any, object: any) => ({ + type: "Remove", + actor: `${config.url}/users/${user.id}`, + target, + object, +}); diff --git a/packages/backend/src/remote/activitypub/renderer/tombstone.ts b/packages/backend/src/remote/activitypub/renderer/tombstone.ts new file mode 100644 index 0000000..5c4003c --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/tombstone.ts @@ -0,0 +1,4 @@ +export default (id: string) => ({ + id, + type: "Tombstone", +}); diff --git a/packages/backend/src/remote/activitypub/renderer/undo.ts b/packages/backend/src/remote/activitypub/renderer/undo.ts new file mode 100644 index 0000000..249d643 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/undo.ts @@ -0,0 +1,19 @@ +import config from "@/config/index.js"; +import type { User } from "@/models/entities/user.js"; +import { ILocalUser } from "@/models/entities/user.js"; + +export default (object: any, user: { id: User["id"] }) => { + if (object == null) return null; + const id = + typeof object.id === "string" && object.id.startsWith(config.url) + ? `${object.id}/undo` + : undefined; + + return { + type: "Undo", + ...(id ? { id } : {}), + actor: `${config.url}/users/${user.id}`, + object, + published: new Date().toISOString(), + }; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/update.ts b/packages/backend/src/remote/activitypub/renderer/update.ts new file mode 100644 index 0000000..765a52f --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/update.ts @@ -0,0 +1,15 @@ +import config from "@/config/index.js"; +import type { User } from "@/models/entities/user.js"; + +export default (object: any, user: { id: User["id"] }) => { + const activity = { + id: `${config.url}/users/${user.id}#updates/${new Date().getTime()}`, + actor: `${config.url}/users/${user.id}`, + type: "Update", + to: ["https://www.w3.org/ns/activitystreams#Public"], + object, + published: new Date().toISOString(), + } as any; + + return activity; +}; diff --git a/packages/backend/src/remote/activitypub/renderer/vote.ts b/packages/backend/src/remote/activitypub/renderer/vote.ts new file mode 100644 index 0000000..21234a1 --- /dev/null +++ b/packages/backend/src/remote/activitypub/renderer/vote.ts @@ -0,0 +1,29 @@ +import config from "@/config/index.js"; +import type { Note } from "@/models/entities/note.js"; +import type { IRemoteUser, User } from "@/models/entities/user.js"; +import type { PollVote } from "@/models/entities/poll-vote.js"; +import type { Poll } from "@/models/entities/poll.js"; + +export default async function renderVote( + user: { id: User["id"] }, + vote: PollVote, + note: Note, + poll: Poll, + pollOwner: IRemoteUser, +): Promise { + return { + id: `${config.url}/users/${user.id}#votes/${vote.id}/activity`, + actor: `${config.url}/users/${user.id}`, + type: "Create", + to: [pollOwner.uri], + published: new Date().toISOString(), + object: { + id: `${config.url}/users/${user.id}#votes/${vote.id}`, + type: "Note", + attributedTo: `${config.url}/users/${user.id}`, + to: [pollOwner.uri], + inReplyTo: note.uri, + name: poll.choices[vote.choice], + }, + }; +} diff --git a/packages/backend/src/remote/activitypub/request.ts b/packages/backend/src/remote/activitypub/request.ts new file mode 100644 index 0000000..e4aa190 --- /dev/null +++ b/packages/backend/src/remote/activitypub/request.ts @@ -0,0 +1,79 @@ +import config from "@/config/index.js"; +import { getUserKeypair } from "@/misc/keypair-store.js"; +import type { User } from "@/models/entities/user.js"; +import { getResponse } from "../../misc/fetch.js"; +import { createSignedPost, createSignedGet } from "./ap-request.js"; +import { apLogger } from "@/remote/activitypub/logger.js"; + +export default async (user: { id: User["id"] }, url: string, object: any) => { + const body = JSON.stringify(object); + + const keypair = await getUserKeypair(user.id); + + const req = createSignedPost({ + key: { + privateKeyPem: keypair.privateKey, + keyId: `${config.url}/users/${user.id}#main-key`, + }, + url, + body, + additionalHeaders: { + "User-Agent": config.userAgent, + }, + }); + + await getResponse({ + url, + method: req.request.method, + headers: req.request.headers, + body, + }); +}; + +/** + * Get AP object with http-signature + * @param user http-signature user + * @param url URL to fetch + * @param redirects whether or not to accept redirects + */ +export async function signedGet(url: string, user: { id: User["id"] }, redirects: boolean = true) { + apLogger.debug(`Running signedGet on url: ${url}`); + const keypair = await getUserKeypair(user.id); + + const req = createSignedGet({ + key: { + privateKeyPem: keypair.privateKey, + keyId: `${config.url}/users/${user.id}#main-key`, + }, + url, + additionalHeaders: { + "User-Agent": config.userAgent, + }, + }); + + const res = await getResponse({ + url, + method: req.request.method, + headers: req.request.headers, + redirect: redirects ? "manual" : "error" + }); + + if (redirects && [301,302,307,308].includes(res.status)) { + const newUrl = res.headers.get('location'); + if (!newUrl) throw new Error('signedGet got redirect but no target location'); + apLogger.debug(`signedGet is redirecting to ${newUrl}`); + return signedGet(newUrl, user, false); + } + + const contentType = res.headers.get('content-type'); + if (contentType == null || + (contentType !== 'application/activity+json' && !contentType.startsWith('application/activity+json;') && + (!contentType.startsWith('application/ld+json;') || !contentType.includes('profile="https://www.w3.org/ns/activitystreams"')))) { + throw new Error(`signedGet response had unexpected content-type: ${contentType}`); + } + + return { + finalUrl: res.url, + content: await res.json() + }; +} diff --git a/packages/backend/src/remote/activitypub/resolver.ts b/packages/backend/src/remote/activitypub/resolver.ts new file mode 100644 index 0000000..72ee218 --- /dev/null +++ b/packages/backend/src/remote/activitypub/resolver.ts @@ -0,0 +1,254 @@ +import config from "@/config/index.js"; +import { getJsonActivity } from "@/misc/fetch.js"; +import type { ILocalUser } from "@/models/entities/user.js"; +import { getInstanceActor } from "@/services/instance-actor.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { extractDbHost, isSelfHost } from "@/misc/convert-host.js"; +import { signedGet } from "./request.js"; +import type { IObject, ICollection, IOrderedCollection } from "./type.js"; +import { isCollectionOrOrderedCollection, getApId } from "./type.js"; +import { + FollowRequests, + Notes, + NoteReactions, + Polls, + Users, + Bites, + InteractionStamps, +} from "@/models/index.js"; +import { parseUri } from "./db-resolver.js"; +import renderNote from "@/remote/activitypub/renderer/note.js"; +import { renderLike } from "@/remote/activitypub/renderer/like.js"; +import { renderPerson } from "@/remote/activitypub/renderer/person.js"; +import renderQuestion from "@/remote/activitypub/renderer/question.js"; +import renderCreate from "@/remote/activitypub/renderer/create.js"; +import { renderActivity } from "@/remote/activitypub/renderer/index.js"; +import renderFollow from "@/remote/activitypub/renderer/follow.js"; +import { shouldBlockInstance } from "@/misc/should-block-instance.js"; +import { apLogger } from "@/remote/activitypub/logger.js"; +import { In, IsNull, Not } from "typeorm"; +import { tickResolve } from "@/metrics.js"; +import renderBite from "@/remote/activitypub/renderer/bite.js"; +import renderQuoteAuthorization from "@/remote/activitypub/renderer/quote-authorization.js"; + +export default class Resolver { + private history: Set; + private user?: ILocalUser; + private recursionLimit?: number; + + constructor(recursionLimit = 100) { + this.history = new Set(); + this.recursionLimit = recursionLimit; + } + + public setUser(user) { + this.user = user; + } + + public reset(): Resolver { + this.history = new Set(); + return this; + } + + public getHistory(): string[] { + return Array.from(this.history); + } + + public async resolveCollection( + value: string | IObject, + ): Promise { + const collection = await this.resolve(value); + + if (isCollectionOrOrderedCollection(collection)) { + return collection; + } else { + throw new Error(`unrecognized collection type: ${collection.type}`); + } + } + + public async resolve(value: string | IObject): Promise { + if (value == null) { + throw new Error("resolvee is null (or undefined)"); + } + + if (typeof value !== "string") { + apLogger.debug("Object to resolve is not a string"); + if (typeof value.id !== "undefined") { + const host = extractDbHost(getApId(value)); + if (await shouldBlockInstance(host)) { + throw new Error("instance is blocked"); + } + } + apLogger.debug("Returning existing object:"); + apLogger.debug(JSON.stringify(value, null, 2)); + return value; + } + + apLogger.debug(`Resolving: ${value}`); + + if (value.includes("#")) { + // URLs with fragment parts cannot be resolved correctly because + // the fragment part does not get transmitted over HTTP(S). + // Avoid strange behaviour by not trying to resolve these at all. + throw new Error(`cannot resolve URL with fragment: ${value}`); + } + + if (this.history.has(value)) { + throw new Error("cannot resolve already resolved one"); + } + if (this.recursionLimit && this.history.size > this.recursionLimit) { + throw new Error("hit recursion limit"); + } + this.history.add(value); + + const host = extractDbHost(value); + if (isSelfHost(host)) { + return (await this.resolveLocal(value))!; + } + + const meta = await fetchMeta(); + if (await shouldBlockInstance(host, meta)) { + throw new Error("Instance is blocked"); + } + + if ( + meta.privateMode && + config.host !== host && + config.domain !== host && + !meta.allowedHosts.includes(host) + ) { + throw new Error("Instance is not allowed"); + } + + if (!this.user) { + this.user = await getInstanceActor(); + } + + apLogger.debug("Getting object from remote, authenticated as user:"); + apLogger.debug(JSON.stringify(this.user, null, 2)); + + const {res, object} = await this.doFetch(value); + + if (object.id == null) throw new Error("Object has no ID"); + const objectId = new URL(object.id); + const resFinalUrl = new URL(res.finalUrl); + if (resFinalUrl.toString() === objectId.toString()) { + tickResolve(); + return object; + } + + if (resFinalUrl.host !== objectId.host) + throw new Error("Object ID host doesn't match final url host"); + + const {res: finalRes, object: finalObject} = await this.doFetch(object.id); + + if (finalObject.id == null) throw new Error("Final object has no ID"); + const finalObjectId = new URL(finalObject.id); + const finalResFinalUrl = new URL(finalRes.finalUrl); + + if (finalResFinalUrl.toString() !== finalObjectId.toString()) + throw new Error("Object ID still doesn't match final URL after second fetch attempt") + + tickResolve(); + return finalObject; + } + + private async doFetch(uri: string) { + let res = ( + this.user + ? await signedGet(uri, this.user) + : await getJsonActivity(uri) + ); + let object = res.content as IObject; + + if ( + object == null || + (Array.isArray(object["@context"]) + ? !(object["@context"] as unknown[]).includes( + "https://www.w3.org/ns/activitystreams", + ) + : object["@context"] !== "https://www.w3.org/ns/activitystreams") + ) { + throw new Error("invalid response"); + } + + return {res, object}; + } + + private async resolveLocal(url: string): Promise { + const parsed = parseUri(url); + if (!parsed.local) throw new Error("resolveLocal: not local"); + + switch (parsed.type) { + case "notes": { + const note = await Notes.findOneByOrFail({ id: parsed.id }); + if (parsed.rest === "activity") { + // this refers to the create activity and not the note itself + return renderActivity(renderCreate(await renderNote(note), note)); + } else { + return renderActivity(await renderNote(note)); + } + } + case "users": { + const user = await Users.findOneByOrFail({ id: parsed.id }); + return await renderPerson(user as ILocalUser); + } + case "questions": { + // Polls are indexed by the note they are attached to. + const [note, poll] = await Promise.all([ + Notes.findOneByOrFail({ id: parsed.id }), + Polls.findOneByOrFail({ noteId: parsed.id }), + ]); + return renderActivity(await renderQuestion({ id: note.userId }, note, poll)); + } + case "likes": { + const reaction = await NoteReactions.findOneByOrFail({ id: parsed.id }); + return renderActivity(await renderLike(reaction, { uri: null })); + } + case "follows": { + // if rest is a + if (parsed.rest != null && /^\w+$/.test(parsed.rest)) { + const follower = await Users.findOneByOrFail({ id: parsed.id }); + const followee = await Users.findOneByOrFail({ id: parsed.rest }); + return renderActivity(renderFollow(follower, followee, url)); + } + + // Another situation is there is only requestId, then obtained object from database. + const followRequest = await FollowRequests.findOneBy({ + id: parsed.id, + }); + if (followRequest == null) { + throw new Error("resolveLocal: invalid follow URI"); + } + const follower = await Users.findOneBy({ + id: followRequest.followerId, + host: IsNull(), + }); + const followee = await Users.findOneBy({ + id: followRequest.followeeId, + host: Not(IsNull()), + }); + if (follower == null || followee == null) { + throw new Error("resolveLocal: invalid follow URI"); + } + return renderActivity(renderFollow(follower, followee, url)); + } + case "bites": { + const bite = await Bites.findOneOrFail({ + where: { id: parsed.id }, + relations: ["targetUser", "targetBite", "targetNote"], + }); + return renderActivity(await renderBite(bite)); + } + case "stamp": { + const stamp = await InteractionStamps.findOneOrFail({ + where: { id: parsed.id }, + relations: ["note", "targetNote"], + }); + return renderActivity(renderQuoteAuthorization(stamp))!; + } + default: + throw new Error(`resolveLocal: type ${parsed.type} unhandled`); + } + } +} diff --git a/packages/backend/src/remote/activitypub/type.ts b/packages/backend/src/remote/activitypub/type.ts new file mode 100644 index 0000000..64beff2 --- /dev/null +++ b/packages/backend/src/remote/activitypub/type.ts @@ -0,0 +1,381 @@ +export type obj = { [x: string]: any }; +export type ApObject = IObject | string | (IObject | string)[]; + +export interface IObject { + "@context": string | string[] | obj | obj[]; + type: string | string[]; + id?: string; + summary?: string; + published?: string; + cc?: ApObject; + to?: ApObject; + attributedTo: ApObject; + attachment?: any[]; + inReplyTo?: any; + replies?: ICollection; + content?: string; + name?: string; + startTime?: Date; + endTime?: Date; + icon?: any; + image?: any; + url?: ApObject; + href?: string; + tag?: IObject | IObject[]; + sensitive?: boolean; +} + +/** + * Get array of ActivityStreams Objects id + */ +export function getApIds(value: ApObject | undefined): string[] { + if (value == null) return []; + const array = Array.isArray(value) ? value : [value]; + return array.map((x) => getApId(x)); +} + +/** + * Get first ActivityStreams Object id + */ +export function getOneApId(value: ApObject): string { + const firstOne = Array.isArray(value) ? value[0] : value; + return getApId(firstOne); +} + +/** + * Get ActivityStreams Object id + */ +export function getApId(value: string | IObject): string { + if (typeof value === "string") return value; + if (typeof value.id === "string") return value.id; + throw new Error("cannot detemine id"); +} + +/** + * Get ActivityStreams Object type + */ +export function getApType(value: IObject): string { + if (typeof value.type === "string") return value.type; + if (Array.isArray(value.type) && typeof value.type[0] === "string") + return value.type[0]; + throw new Error("cannot detect type"); +} + +export function getOneApHrefNullable( + value: ApObject | undefined, +): string | undefined { + const firstOne = Array.isArray(value) ? value[0] : value; + return getApHrefNullable(firstOne); +} + +export function getApHrefNullable( + value: string | IObject | undefined, +): string | undefined { + if (typeof value === "string") return value; + if (typeof value?.href === "string") return value.href; + return undefined; +} + +export interface IActivity extends IObject { + //type: 'Activity'; + actor: IObject | string; + object: IObject | string; + target?: IObject | string; + /** LD-Signature */ + signature?: { + type: string; + created: Date; + creator: string; + domain?: string; + nonce?: string; + signatureValue: string; + }; +} + +export interface ICollection extends IObject { + type: "Collection"; + totalItems: number; + items: ApObject; +} + +export interface IOrderedCollection extends IObject { + type: "OrderedCollection"; + totalItems: number; + orderedItems: ApObject; +} + +export const validPost = [ + "Note", + "Question", + "Article", + "Audio", + "Document", + "Image", + "Page", + "Video", + "Event", +]; + +export const isPost = (object: IObject): object is IPost => + validPost.includes(getApType(object)); + +export interface IPost extends IObject { + type: + | "Note" + | "Question" + | "Article" + | "Audio" + | "Document" + | "Image" + | "Page" + | "Video" + | "Event"; + source?: { + content: string; + mediaType: string; + }; + _misskey_quote?: string; + quoteUrl?: string; + quoteUri?: string; + quote?: string; + _misskey_talk: boolean; + _misskey_content?: string; + interactionPolicy?: { + canQuote?: { + automaticApproval?: string; + manualApproval?: string; + }; + }; +} + +export interface IQuestion extends IObject { + type: "Note" | "Question"; + source?: { + content: string; + mediaType: string; + }; + _misskey_quote?: string; + quoteUrl?: string; + oneOf?: IQuestionChoice[]; + anyOf?: IQuestionChoice[]; + endTime?: Date; + closed?: Date; +} + +export const isQuestion = (object: IObject): object is IQuestion => + getApType(object) === "Note" || getApType(object) === "Question"; + +interface IQuestionChoice { + name?: string; + replies?: ICollection; + _misskey_votes?: number; +} +export interface ITombstone extends IObject { + type: "Tombstone"; + formerType?: string; + deleted?: Date; +} + +export const isTombstone = (object: IObject): object is ITombstone => + getApType(object) === "Tombstone"; + +export const validActor = [ + "Person", + "Service", + "Group", + "Organization", + "Application", +]; + +export const isActor = (object: IObject): object is IActor => + validActor.includes(getApType(object)); + +export interface IActor extends IObject { + type: "Person" | "Service" | "Organization" | "Group" | "Application"; + name?: string; + preferredUsername?: string; + manuallyApprovesFollowers?: boolean; + movedTo?: string; + alsoKnownAs?: string[]; + discoverable?: boolean; + inbox: string; + sharedInbox?: string; // backward compatibility.. ig + publicKey?: { + id: string; + publicKeyPem: string; + }; + followers?: string | ICollection | IOrderedCollection; + following?: string | ICollection | IOrderedCollection; + featured?: string | IOrderedCollection; + outbox: string | IOrderedCollection; + endpoints?: { + sharedInbox?: string; + }; + "vcard:bday"?: string; + "vcard:Address"?: string; + _misskey_summary?: string; + pronouns?: Record; + canBite?: string; +} + +export const isCollection = (object: IObject): object is ICollection => + getApType(object) === "Collection"; + +export const isOrderedCollection = ( + object: IObject, +): object is IOrderedCollection => getApType(object) === "OrderedCollection"; + +export const isCollectionOrOrderedCollection = ( + object: IObject, +): object is ICollection | IOrderedCollection => + isCollection(object) || isOrderedCollection(object); + +export interface IApPropertyValue extends IObject { + type: "PropertyValue"; + identifier: IApPropertyValue; + name: string; + value: string; +} + +export const isPropertyValue = (object: IObject): object is IApPropertyValue => + object && + getApType(object) === "PropertyValue" && + typeof object.name === "string" && + typeof (object as any).value === "string"; + +export interface IApMention extends IObject { + type: "Mention"; + href: string; +} + +export const isMention = (object: IObject): object is IApMention => + getApType(object) === "Mention" && typeof object.href === "string"; + +export interface IApHashtag extends IObject { + type: "Hashtag"; + name: string; +} + +export const isHashtag = (object: IObject): object is IApHashtag => + getApType(object) === "Hashtag" && typeof object.name === "string"; + +export interface IApEmoji extends IObject { + type: "Emoji"; + updated: Date; +} + +export const isEmoji = (object: IObject): object is IApEmoji => + getApType(object) === "Emoji" && + !Array.isArray(object.icon) && + object.icon.url != null; + +export interface ICreate extends IActivity { + type: "Create"; +} + +export interface IDelete extends IActivity { + type: "Delete"; +} + +export interface IUpdate extends IActivity { + type: "Update"; +} + +export interface IRead extends IActivity { + type: "Read"; +} + +export interface IUndo extends IActivity { + type: "Undo"; +} + +export interface IFollow extends IActivity { + type: "Follow"; +} + +export interface IAccept extends IActivity { + type: "Accept"; + result?: string; +} + +export interface IReject extends IActivity { + type: "Reject"; +} + +export interface IAdd extends IActivity { + type: "Add"; +} + +export interface IRemove extends IActivity { + type: "Remove"; +} + +export interface ILike extends IActivity { + type: "Like" | "EmojiReaction" | "EmojiReact"; + _misskey_reaction?: string; +} + +export interface IAnnounce extends IActivity { + type: "Announce"; +} + +export interface IBlock extends IActivity { + type: "Block"; +} + +export interface IFlag extends IActivity { + type: "Flag"; +} + +export interface IMove extends IActivity { + type: "Move"; + target: IObject | string; +} + +export interface IBite extends IActivity { + type: "Bite"; + actor: string; + target: string; +} + +export interface IQuoteRequest extends IActivity { + type: "QuoteRequest"; + instrument: string | IObject; +} + +export const isCreate = (object: IObject): object is ICreate => + getApType(object) === "Create"; +export const isDelete = (object: IObject): object is IDelete => + getApType(object) === "Delete"; +export const isUpdate = (object: IObject): object is IUpdate => + getApType(object) === "Update"; +export const isRead = (object: IObject): object is IRead => + getApType(object) === "Read"; +export const isUndo = (object: IObject): object is IUndo => + getApType(object) === "Undo"; +export const isFollow = (object: IObject): object is IFollow => + getApType(object) === "Follow"; +export const isAccept = (object: IObject): object is IAccept => + getApType(object) === "Accept"; +export const isReject = (object: IObject): object is IReject => + getApType(object) === "Reject"; +export const isAdd = (object: IObject): object is IAdd => + getApType(object) === "Add"; +export const isRemove = (object: IObject): object is IRemove => + getApType(object) === "Remove"; +export const isLike = (object: IObject): object is ILike => + getApType(object) === "Like" || + getApType(object) === "EmojiReaction" || + getApType(object) === "EmojiReact"; +export const isAnnounce = (object: IObject): object is IAnnounce => + getApType(object) === "Announce"; +export const isBlock = (object: IObject): object is IBlock => + getApType(object) === "Block"; +export const isFlag = (object: IObject): object is IFlag => + getApType(object) === "Flag"; +export const isMove = (object: IObject): object is IMove => + getApType(object) === "Move"; +export const isBite = (object: IObject): object is IBite => + getApType(object) === "Bite"; +export const isQuoteRequest = (object: IObject): object is IQuoteRequest => + getApType(object) === "QuoteRequest"; diff --git a/packages/backend/src/remote/logger.ts b/packages/backend/src/remote/logger.ts new file mode 100644 index 0000000..b6bc5bf --- /dev/null +++ b/packages/backend/src/remote/logger.ts @@ -0,0 +1,3 @@ +import Logger from "@/services/logger.js"; + +export const remoteLogger = new Logger("remote", "cyan"); diff --git a/packages/backend/src/remote/resolve-user.ts b/packages/backend/src/remote/resolve-user.ts new file mode 100644 index 0000000..9d4d10b --- /dev/null +++ b/packages/backend/src/remote/resolve-user.ts @@ -0,0 +1,386 @@ +import { URL } from "node:url"; +import chalk from "chalk"; +import { IsNull } from "typeorm"; +import config from "@/config/index.js"; +import type { User, IRemoteUser } from "@/models/entities/user.js"; +import { UserProfiles, Users } from "@/models/index.js"; +import { toPuny } from "@/misc/convert-host.js"; +import webFinger from "./webfinger.js"; +import { createPerson, updatePerson } from "./activitypub/models/person.js"; +import { remoteLogger } from "./logger.js"; +import { Cache } from "@/misc/cache.js"; +import { IMentionedRemoteUsers } from "@/models/entities/note.js"; +import { UserProfile } from "@/models/entities/user-profile.js"; +import { RecursionLimiter } from "@/models/repositories/user-profile.js"; +import { promiseEarlyReturn } from "@/prelude/promise.js"; + +const logger = remoteLogger.createSubLogger("resolve-user"); +const uriHostCache = new Cache("resolveUserUriHost", 60 * 60 * 24); +const localUsernameCache = new Cache("localUserNameCapitalization", 60 * 60 * 24); +const profileMentionCache = new Cache("resolveProfileMentions", 60 * 60); + +type ProfileMention = { + user: User; + profile: UserProfile | null; + data: { + username: string; + host: string | null; + }; +}; + +type refreshType = 'refresh' | 'refresh-in-background' | 'refresh-timeout-1500ms' | 'no-refresh'; + +export async function resolveUser( + username: string, + host: string | null, + refresh: refreshType = 'refresh', + limiter: RecursionLimiter = new RecursionLimiter() +): Promise { + const usernameLower = username.toLowerCase(); + + // Return local user if host part is empty + + if (host == null) { + logger.info(`return local user: ${usernameLower}`); + return await Users.findOneBy({ usernameLower, host: IsNull() }).then( + (u) => { + if (u == null) { + throw new Error("user not found"); + } else { + return u; + } + }, + ); + } + + host = toPuny(host); + + // Also return local user if host part is specified but referencing the local instance + + if (config.host === host || config.domain === host) { + logger.info(`return local user: ${usernameLower}`); + return await Users.findOneBy({ usernameLower, host: IsNull() }).then( + (u) => { + if (u == null) { + throw new Error("user not found"); + } else { + return u; + } + }, + ); + } + + // Check if remote user is already in the database + + let user = (await Users.findOneBy({ + usernameLower, + host, + })) as IRemoteUser | null; + + const acctLower = `${usernameLower}@${host}`; + + // If not, look up the user on the remote server + + if (user == null) { + // Run WebFinger + const fingerRes = await resolveUserWebFinger(acctLower); + const finalAcct = subjectToAcct(fingerRes.subject); + const finalAcctLower = finalAcct.toLowerCase(); + const m = finalAcct.match(/^([^@]+)@(.*)/); + const subjectHost = m ? m[2] : undefined; + + // If subject is different, we're dealing with a split domain setup (that's already been validated by resolveUserWebFinger) + if (acctLower != finalAcctLower) { + logger.info('re-resolving split domain redirect user...'); + const m = finalAcct.match(/^([^@]+)@(.*)/); + if (m) { + // Re-check if we already have the user in the database post-redirect + user = (await Users.findOneBy({ + usernameLower: usernameLower, + host: subjectHost, + })) as IRemoteUser | null; + + // If yes, return existing user + if (user != null) { + logger.succ(`return existing remote user: ${chalk.magenta(finalAcctLower)}`); + return user; + } + // Otherwise create and return new user + else { + logger.succ(`return new remote user: ${chalk.magenta(finalAcctLower)}`); + return await createPerson(fingerRes.self.href, undefined, subjectHost, limiter); + } + } + } + + // Not a split domain setup, so we can simply create and return the new user + logger.succ(`return new remote user: ${chalk.magenta(finalAcctLower)}`); + return await createPerson(fingerRes.self.href, undefined, subjectHost, limiter); + } + + // If user information is out of date, return it by starting over from WebFinger + if ( + (refresh === 'refresh' || refresh === 'refresh-timeout-1500ms') && ( + user.lastFetchedAt == null || + Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24 + ) + ) { + // Prevent multiple attempts to connect to unconnected instances, update before each attempt to prevent subsequent similar attempts + await Users.update(user.id, { + lastFetchedAt: new Date(), + }); + + logger.info(`try resync: ${acctLower}`); + const fingerRes = await resolveUserWebFinger(acctLower); + + if (user.uri !== fingerRes.self.href) { + // if uri mismatch, Fix (user@host <=> AP's Person id(IRemoteUser.uri)) mapping. + logger.info(`uri missmatch: ${acctLower}`); + logger.info( + `recovery mismatch uri for (username=${username}, host=${host}) from ${user.uri} to ${fingerRes.self.href}`, + ); + + // validate uri + const uri = new URL(fingerRes.self.href); + if (uri.hostname !== host) { + throw new Error("Invalid uri"); + } + + await Users.update( + { + usernameLower, + host: host, + }, + { + uri: fingerRes.self.href, + }, + ); + } else { + logger.info(`uri is fine: ${acctLower}`); + } + + const finalAcct = subjectToAcct(fingerRes.subject); + const finalAcctLower = finalAcct.toLowerCase(); + const m = finalAcct.match(/^([^@]+)@(.*)/); + const finalHost = m ? m[2] : null; + + // Update user.host if we're dealing with an account that's part of a split domain setup that hasn't been fixed yet + if (m && user.host != finalHost) { + logger.info(`updating user host to subject acct host: ${user.host} -> ${finalHost}`); + await Users.update( + { + usernameLower, + host: user.host, + }, + { + host: finalHost, + }, + ); + } + + if (refresh === 'refresh') { + await updatePerson(fingerRes.self.href); + logger.info(`return resynced remote user: ${finalAcctLower}`); + } + else if (refresh === 'refresh-timeout-1500ms') { + const res = await promiseEarlyReturn(updatePerson(fingerRes.self.href), 1500); + logger.info(`return possibly resynced remote user: ${finalAcctLower}`); + } + + return await Users.findOneBy({ uri: fingerRes.self.href }).then((u) => { + if (u == null) { + throw new Error("user not found"); + } else { + return u; + } + }); + } else if (refresh === 'refresh-in-background' && (user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24)) { + // Run the refresh in the background + // noinspection ES6MissingAwait + resolveUser(username, host, 'refresh', limiter); + } + + logger.info(`return existing remote user: ${acctLower}`); + return user; +} + +export async function resolveMentionToUserAndProfile(username: string, host: string | null, objectHost: string | null, limiter: RecursionLimiter) { + return profileMentionCache.fetch(`${username}@${host ?? objectHost}`, async () => { + try { + const user = await resolveUser(username, host ?? objectHost, 'no-refresh', limiter); + const profile = await UserProfiles.findOneBy({ userId: user.id }); + const data = { username, host: host ?? objectHost }; + + return { user, profile, data }; + } + catch { + return null; + } + }); +} + +export function getMentionFallbackUri(username: string, host: string | null, objectHost: string | null): string { + let fallback = `${config.url}/@${username}`; + if (host !== null && host !== config.domain) + fallback += `@${host}`; + else if (objectHost !== null && objectHost !== config.domain && host !== config.domain) + fallback += `@${objectHost}`; + + return fallback; +} + +async function getLocalUsernameCached(username: string): Promise { + return localUsernameCache.fetch(username.toLowerCase(), () => + Users.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() }) + .then(p => p ? p.username : null)); +} + +export async function resolveMentionFromCache(username: string, host: string | null, objectHost: string | null, cache: IMentionedRemoteUsers): Promise<{ username: string, href: string } | null> { + const isLocal = (host === null && objectHost === null) || host === config.domain; + if (isLocal) { + const finalUsername = await getLocalUsernameCached(username); + if (finalUsername === null) return null; + username = finalUsername; + } + + const fallback = getMentionFallbackUri(username, host, objectHost); + const cached = cache.find(r => r.username.toLowerCase() === username.toLowerCase() && r.host === (host ?? objectHost)); + const href = cached?.url ?? cached?.uri; + if (cached && href != null) return { username: cached.username, href: href }; + if (isLocal) return { username: username, href: fallback }; + return null; +} + +export async function getSubjectHostFromUri(uri: string): Promise { + try { + const acct = subjectToAcct((await webFinger(uri)).subject); + const res = await resolveUserWebFinger(acct.toLowerCase()); + const finalAcct = subjectToAcct(res.subject); + const m = finalAcct.match(/^([^@]+)@(.*)/); + if (!m) { + return null; + } + return m[2]; + } + catch { + return null; + } +} + +export async function getSubjectHostFromUriAndUsernameCached(uri: string, username: string): Promise { + const url = new URL(uri); + const hostname = url.hostname; + username = username.substring(1); // remove leading @ from username + + // This resolves invalid mentions with the URL format https://host.tld/@user@otherhost.tld + const match = url.pathname.match(/^\/@(?[a-zA-Z0-9_]+|$)@(?[a-zA-Z0-9-.]+\.[a-zA-Z0-9-]+)$/) + if (match && match.groups?.host) { + return match.groups.host; + } + + if (hostname === config.hostname) { + // user is local, return local account domain + return config.domain; + } + + const user = await Users.findOneBy({ + usernameLower: username.toLowerCase(), + host: hostname + }); + + return user ? user.host : await uriHostCache.fetch(uri, async () => await getSubjectHostFromUri(uri) ?? await getSubjectHostFromAcctParts(username, hostname) ?? hostname); +} + +export async function getSubjectHostFromAcct(acct: string): Promise { + try { + const res = await resolveUserWebFinger(acct.toLowerCase()); + const finalAcct = subjectToAcct(res.subject); + const m = finalAcct.match(/^([^@]+)@(.*)/); + if (!m) { + return null; + } + return m[2]; + } + catch { + return null; + } +} + + +export async function getSubjectHostFromRemoteUser(user: IRemoteUser | undefined): Promise { + return user ? getSubjectHostFromAcct(`${user.username}@${user.host}`) : null; +} + +export async function getSubjectHostFromAcctParts(username?: string | undefined, host?: string | undefined): Promise { + return username !== null && host !== null ? getSubjectHostFromAcct(`${username}@${host}`) : null; +} + +async function resolveUserWebFinger(acctLower: string, recurse: boolean = true): Promise<{ + subject: string, + self: { + href: string; + rel?: string; + } +}> { + logger.info(`WebFinger for ${chalk.yellow(acctLower)}`); + const fingerRes = await webFinger(acctLower).catch((e) => { + logger.error( + `Failed to WebFinger for ${chalk.yellow(acctLower)}: ${ + e.statusCode || e.message + }`, + ); + throw new Error( + `Failed to WebFinger for ${acctLower}: ${e.statusCode || e.message}`, + ); + }); + const self = fingerRes.links.find( + (link) => link.rel != null && link.rel.toLowerCase() === "self", + ); + if (!self) { + logger.error( + `Failed to WebFinger for ${chalk.yellow(acctLower)}: self link not found`, + ); + throw new Error("self link not found"); + } + if (`${acctToSubject(acctLower)}` !== normalizeSubject(fingerRes.subject)) { + logger.info(`acct subject mismatch (${acctToSubject(acctLower)} !== ${normalizeSubject(fingerRes.subject)}), possible split domain deployment detected, repeating webfinger`) + if (!recurse){ + logger.error('split domain verification failed (recurse limit reached), aborting') + throw new Error('split domain verification failed (recurse limit reached), aborting'); + } + const initialAcct = subjectToAcct(fingerRes.subject); + const initialAcctLower = initialAcct.toLowerCase(); + const splitFingerRes = await resolveUserWebFinger(initialAcctLower, false); + const finalAcct = subjectToAcct(splitFingerRes.subject); + const finalAcctLower = finalAcct.toLowerCase(); + if (initialAcct !== finalAcct) { + logger.error('split domain verification failed (subject mismatch), aborting') + throw new Error('split domain verification failed (subject mismatch), aborting'); + } + + logger.info(`split domain configuration detected: ${acctLower} -> ${finalAcctLower}`); + + return splitFingerRes; + } + + return { + subject: fingerRes.subject, + self: self + }; +} + +function subjectToAcct(subject: string): string { + if (!subject.startsWith('acct:')) { + logger.error("Subject isnt a valid acct"); + throw ("Subject isnt a valid acct"); + } + return subject.substring(5); +} + +function acctToSubject(acct: string): string { + return normalizeSubject(`acct:${acct}`); +} + +function normalizeSubject(subject: string): string { + return subject.toLowerCase(); +} diff --git a/packages/backend/src/remote/webfinger.ts b/packages/backend/src/remote/webfinger.ts new file mode 100644 index 0000000..0a5baeb --- /dev/null +++ b/packages/backend/src/remote/webfinger.ts @@ -0,0 +1,100 @@ +import { URL } from "node:url"; +import {getJson, getResponse} from "@/misc/fetch.js"; +import { query as urlQuery } from "@/prelude/url.js"; +import config from "@/config/index.js"; +import { XMLParser } from "fast-xml-parser"; + +type ILink = { + href: string; + rel?: string; +}; + +type IWebFinger = { + links: ILink[]; + subject: string; +}; + +export default async function (query: string): Promise { + const hostMetaUrl = queryToHostMetaUrl(query); + const webFingerTemplate = await hostMetaToWebFingerTemplate(hostMetaUrl) ?? queryToWebFingerTemplate(query); + const url = genWebFingerUrl(query, webFingerTemplate); + + return (await getJson( + url, + "application/jrd+json, application/json", + )) as IWebFinger; +} + +async function hostMetaToWebFingerTemplate(url: string) { + try { + const res = await getResponse({ + url, + method: "GET", + headers: Object.assign( + { + "User-Agent": config.userAgent, + Accept: "application/xrd+xml", + }, + {}, + ), + timeout: 10000, + }); + const options = { + ignoreAttributes: false, + isArray: (_name: string, jpath: string) => jpath === 'XRD.Link', + }; + const parser = new XMLParser(options); + const hostMeta = parser.parse(await res.text()); + const template = (hostMeta['XRD']['Link'] as Array).filter(p => p['@_rel'] === 'lrdd')[0]['@_template']; + return template.indexOf('{uri}') < 0 ? null : template; + } + catch { + return null; + } +} + +function queryToWebFingerTemplate(query: string) { + if (query.match(/^https?:\/\//)) { + const u = new URL(query); + return `${u.protocol}//${u.hostname}/.well-known/webfinger?resource={uri}`; + } + + const m = query.match(/^([^@]+)@(.*)/); + if (m) { + const hostname = m[2]; + return `https://${hostname}/.well-known/webfinger?resource={uri}`; + } + + throw new Error(`Invalid query (${query})`); +} + +function queryToHostMetaUrl(query: string) { + if (query.match(/^https?:\/\//)) { + const u = new URL(query); + return `${u.protocol}//${u.hostname}/.well-known/host-meta`; + } + + const m = query.match(/^([^@]+)@(.*)/); + if (m) { + const hostname = m[2]; + return `https://${hostname}/.well-known/host-meta`; + } + + throw new Error(`Invalid query (${query})`); +} + +function genWebFingerUrl(query: string, webFingerTemplate: string) { + if (webFingerTemplate.indexOf('{uri}') < 0) + throw new Error(`Invalid webFingerUrl: ${webFingerTemplate}`); + + if (query.match(/^https?:\/\//)) { + return webFingerTemplate.replace('{uri}', encodeURIComponent(query)); + } + + const m = query.match(/^([^@]+)@(.*)/); + if (m) { + return webFingerTemplate.replace('{uri}', encodeURIComponent(`acct:${query}`)); + } + + throw new Error(`Invalid query (${query})`); +} diff --git a/packages/backend/src/server/activitypub.ts b/packages/backend/src/server/activitypub.ts new file mode 100644 index 0000000..57feb6a --- /dev/null +++ b/packages/backend/src/server/activitypub.ts @@ -0,0 +1,565 @@ +import Router from "@koa/router"; +import bodyParser from "koa-bodyparser"; +import httpSignature from "@peertube/http-signature"; + +import { In, IsNull, Not } from "typeorm"; +import { renderActivity } from "@/remote/activitypub/renderer/index.js"; +import renderNote from "@/remote/activitypub/renderer/note.js"; +import renderKey from "@/remote/activitypub/renderer/key.js"; +import { renderPerson } from "@/remote/activitypub/renderer/person.js"; +import renderEmoji from "@/remote/activitypub/renderer/emoji.js"; +import { inbox as processInbox } from "@/queue/index.js"; +import { isSelfHost, toPuny } from "@/misc/convert-host.js"; +import { + Notes, + Users, + Emojis, + NoteReactions, + FollowRequests, + Bites, + InteractionStamps, +} from "@/models/index.js"; +import type { ILocalUser, User } from "@/models/entities/user.js"; +import { renderLike } from "@/remote/activitypub/renderer/like.js"; +import { getUserKeypair } from "@/misc/keypair-store.js"; +import { + checkFetch, + getSignatureUser, + verifyDigest, +} from "@/remote/activitypub/check-fetch.js"; +import { getInstanceActor } from "@/services/instance-actor.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import renderFollow from "@/remote/activitypub/renderer/follow.js"; +import Featured from "./activitypub/featured.js"; +import Following from "./activitypub/following.js"; +import Followers from "./activitypub/followers.js"; +import Outbox, { packActivity } from "./activitypub/outbox.js"; +import { serverLogger } from "./index.js"; +import config from "@/config/index.js"; +import Koa from "koa"; +import { tickFetch } from "@/metrics.js"; +import renderBite from "@/remote/activitypub/renderer/bite.js"; +import renderQuoteAuthorization from "@/remote/activitypub/renderer/quote-authorization.js"; + +// Init router +const router = new Router(); + +//#region Routing + +function inbox(ctx: Router.RouterContext) { + if (ctx.req.headers.host !== config.host) { + ctx.status = 400; + return; + } + + let signature; + + try { + signature = httpSignature.parseRequest(ctx.req, { headers: ['(request-target)', 'digest', 'host', 'date'], authorizationHeaderName: 'signature' }); + } catch (e) { + ctx.status = 401; + return; + } + + if (!verifyDigest(ctx.request.rawBody, ctx.headers.digest)) { + ctx.status = 401; + return; + } + + processInbox(ctx.request.body, signature); + + ctx.status = 202; +} + +const ACTIVITY_JSON = "application/activity+json; charset=utf-8"; +const LD_JSON = + 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"; charset=utf-8'; + +function isActivityPubReq(ctx: Router.RouterContext) { + ctx.response.vary("Accept"); + const accepted = ctx.accepts("html", ACTIVITY_JSON, LD_JSON); + return typeof accepted === "string" && !accepted.match(/html/); +} + +export function setResponseType(ctx: Router.RouterContext) { + const accept = ctx.accepts(ACTIVITY_JSON, LD_JSON); + if (accept === LD_JSON) { + ctx.response.type = LD_JSON; + } else { + ctx.response.type = ACTIVITY_JSON; + } +} + +async function parseJsonBodyOrFail(ctx: Router.RouterContext, next: Koa.Next) { + const koaBodyParser = bodyParser({ + enableTypes: ["json"], + detectJSON: () => true, + }); + + try { + await koaBodyParser(ctx, next); + } + catch { + ctx.status = 400; + return; + } +} + +// inbox +router.post("/inbox", parseJsonBodyOrFail, inbox); +router.post("/users/:user/inbox", parseJsonBodyOrFail, inbox); + +// note +router.get("/notes/:note", async (ctx, next) => { + if (!isActivityPubReq(ctx)) return await next(); + + const verify = await checkFetch(ctx.req); + if (verify !== 200) { + ctx.status = verify; + return; + } + + const note = await Notes.findOneBy({ + id: ctx.params.note, + visibility: In(["public" as const, "home" as const, "followers" as const]), + localOnly: false, + }); + + if (note == null) { + ctx.status = 404; + return; + } + + // redirect if remote + if (note.userHost !== null) { + if (note.uri == null || isSelfHost(note.userHost)) { + ctx.status = 500; + return; + } + ctx.redirect(note.uri); + return; + } + + if (note.visibility === "followers") { + serverLogger.debug( + "Responding to request for follower-only note, validating access...", + ); + const remoteUser = await getSignatureUser(ctx.req); + serverLogger.debug("Local note author user:"); + serverLogger.debug(JSON.stringify(note, null, 2)); + serverLogger.debug("Authenticated remote user:"); + serverLogger.debug(JSON.stringify(remoteUser, null, 2)); + + if (remoteUser == null) { + serverLogger.debug("Rejecting: no user"); + ctx.status = 401; + return; + } + + const relation = await Users.getRelation(remoteUser.user.id, note.userId); + serverLogger.debug("Relation:"); + serverLogger.debug(JSON.stringify(relation, null, 2)); + + if (!relation.isFollowing || relation.isBlocked) { + serverLogger.debug( + "Rejecting: authenticated user is not following us or was blocked by us", + ); + ctx.status = 403; + return; + } + + serverLogger.debug("Accepting: access criteria met"); + } + + ctx.body = renderActivity(await renderNote(note, false)); + + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set("Cache-Control", "private, max-age=0, must-revalidate"); + } else { + ctx.set("Cache-Control", "public, max-age=180"); + } + setResponseType(ctx); +}); + +// note activity +router.get("/notes/:note/activity", async (ctx) => { + const verify = await checkFetch(ctx.req); + if (verify !== 200) { + ctx.status = verify; + return; + } + + const note = await Notes.findOneBy({ + id: ctx.params.note, + userHost: IsNull(), + visibility: In(["public" as const, "home" as const]), + localOnly: false, + }); + + if (note == null) { + ctx.status = 404; + return; + } + + ctx.body = renderActivity(await packActivity(note)); + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set("Cache-Control", "private, max-age=0, must-revalidate"); + } else { + ctx.set("Cache-Control", "public, max-age=180"); + } + setResponseType(ctx); +}); + +// outbox +router.get("/users/:user/outbox", Outbox); + +// followers +router.get("/users/:user/followers", Followers); + +// following +router.get("/users/:user/following", Following); + +// featured +router.get("/users/:user/collections/featured", Featured); + +// publickey +router.get("/users/:user/publickey", async (ctx) => { + const instanceActor = await getInstanceActor(); + if (ctx.params.user === instanceActor.id) { + tickFetch(); + ctx.body = renderActivity( + renderKey(instanceActor, await getUserKeypair(instanceActor.id)), + ); + ctx.set("Cache-Control", "public, max-age=180"); + setResponseType(ctx); + return; + } + + const verify = await checkFetch(ctx.req); + if (verify !== 200) { + ctx.status = verify; + return; + } + + const userId = ctx.params.user; + + const user = await Users.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + ctx.status = 404; + return; + } + + const keypair = await getUserKeypair(user.id); + + if (Users.isLocalUser(user)) { + ctx.body = renderActivity(renderKey(user, keypair)); + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set("Cache-Control", "private, max-age=0, must-revalidate"); + } else { + ctx.set("Cache-Control", "public, max-age=180"); + } + setResponseType(ctx); + } else { + ctx.status = 400; + } +}); + +// user +async function userInfo(ctx: Router.RouterContext, user: User | null) { + if (user == null) { + ctx.status = 404; + return; + } + + ctx.body = renderActivity(await renderPerson(user as ILocalUser)); + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set("Cache-Control", "private, max-age=0, must-revalidate"); + } else { + ctx.set("Cache-Control", "public, max-age=180"); + } + setResponseType(ctx); +} + +router.get("/users/:user", async (ctx, next) => { + if (!isActivityPubReq(ctx)) return await next(); + + const instanceActor = await getInstanceActor(); + if (ctx.params.user === instanceActor.id) { + tickFetch(); + await userInfo(ctx, instanceActor); + return; + } + + const verify = await checkFetch(ctx.req); + if (verify !== 200) { + ctx.status = verify; + return; + } + + const userId = ctx.params.user; + + const user = await Users.findOneBy({ + id: userId, + host: IsNull(), + isSuspended: false, + }); + + await userInfo(ctx, user); +}); + +router.get("/@:user", async (ctx, next) => { + if (!isActivityPubReq(ctx)) return await next(); + + if (ctx.params.user === "instance.actor") { + tickFetch(); + const instanceActor = await getInstanceActor(); + await userInfo(ctx, instanceActor); + return; + } + + const verify = await checkFetch(ctx.req); + if (verify !== 200) { + ctx.status = verify; + return; + } + + const user = await Users.findOneBy({ + usernameLower: ctx.params.user.toLowerCase(), + host: IsNull(), + isSuspended: false, + }); + + await userInfo(ctx, user); +}); + +router.get("/actor", async (ctx, next) => { + tickFetch(); + const instanceActor = await getInstanceActor(); + await userInfo(ctx, instanceActor); +}); +//#endregion + +// emoji +router.get("/emojis/:emoji", async (ctx) => { + const verify = await checkFetch(ctx.req); + if (verify !== 200) { + ctx.status = verify; + return; + } + + const emoji = await Emojis.findOneBy({ + host: IsNull(), + name: ctx.params.emoji, + }); + + if (emoji == null) { + ctx.status = 404; + return; + } + + ctx.body = renderActivity(await renderEmoji(emoji)); + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set("Cache-Control", "private, max-age=0, must-revalidate"); + } else { + ctx.set("Cache-Control", "public, max-age=180"); + } + setResponseType(ctx); +}); + +// like +router.get("/likes/:like", async (ctx) => { + const verify = await checkFetch(ctx.req); + if (verify !== 200) { + ctx.status = verify; + return; + } + + const reaction = await NoteReactions.findOneBy({ id: ctx.params.like }); + + if (reaction == null) { + ctx.status = 404; + return; + } + + const note = await Notes.findOneBy({ id: reaction.noteId }); + + if (note == null) { + ctx.status = 404; + return; + } + + ctx.body = renderActivity(await renderLike(reaction, note)); + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set("Cache-Control", "private, max-age=0, must-revalidate"); + } else { + ctx.set("Cache-Control", "public, max-age=180"); + } + setResponseType(ctx); +}); + +// follow +router.get( + "/follows/:follower/:followee", + async (ctx: Router.RouterContext) => { + const verify = await checkFetch(ctx.req); + if (verify !== 200) { + ctx.status = verify; + return; + } + // This may be used before the follow is completed, so we do not + // check if the following exists. + + const [follower, followee] = await Promise.all([ + Users.findOneBy({ + id: ctx.params.follower, + host: IsNull(), + }), + Users.findOneBy({ + id: ctx.params.followee, + host: Not(IsNull()), + }), + ]); + + if (follower == null || followee == null) { + ctx.status = 404; + return; + } + + ctx.body = renderActivity(renderFollow(follower, followee)); + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set("Cache-Control", "private, max-age=0, must-revalidate"); + } else { + ctx.set("Cache-Control", "public, max-age=180"); + } + setResponseType(ctx); + }, +); + +// follow request +router.get("/follows/:followRequestId", async (ctx: Router.RouterContext) => { + const verify = await checkFetch(ctx.req); + if (verify !== 200) { + ctx.status = verify; + return; + } + + const followRequest = await FollowRequests.findOneBy({ + id: ctx.params.followRequestId, + }); + + if (followRequest == null) { + ctx.status = 404; + return; + } + + const [follower, followee] = await Promise.all([ + Users.findOneBy({ + id: followRequest.followerId, + host: IsNull(), + }), + Users.findOneBy({ + id: followRequest.followeeId, + host: Not(IsNull()), + }), + ]); + + if (follower == null || followee == null) { + ctx.status = 404; + return; + } + + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set("Cache-Control", "private, max-age=0, must-revalidate"); + } else { + ctx.set("Cache-Control", "public, max-age=180"); + } + ctx.body = renderActivity(renderFollow(follower, followee)); + setResponseType(ctx); +}); + +// bite +router.get("/bites/:biteId", async (ctx: Router.RouterContext) => { + const verify = await checkFetch(ctx.req); + if (verify !== 200) { + ctx.status = verify; + return; + } + + const bite = await Bites.findOne({ + where: { id: ctx.params.biteId }, + relations: ["targetUser", "targetBite"], + }); + + if (bite === null) { + ctx.status = 404; + return; + } + + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set("Cache-Control", "private, max-age=0, must-revalidate"); + } else { + ctx.set("Cache-Control", "public, max-age=180"); + } + + ctx.body = renderActivity(await renderBite(bite)); + setResponseType(ctx); +}); + +// mastodon-style quote authorizations +router.get("/stamp/:id", async (ctx: Router.RouterContext) => { + const verify = await checkFetch(ctx.req); + if (verify !== 200) { + ctx.status = verify; + return; + } + + const stamp = await InteractionStamps.findOne({ + where: { id: ctx.params.id }, + relations: ["note", "targetNote"], + }); + + if (stamp === null) { + ctx.status = 404; + return; + } + + // make sure the requester has access to the note, otherwise this endpoint can leak the existence of a private note. + // we can't directly validate that the requester can view the quoting post, but servers should only get this uri + // from the metadata on the quote's note object, so the possibility and scope of leakage is minor + const requester = await getSignatureUser(ctx.req); + if (requester === null) { + ctx.status = 404; + return; + } + + const targetNoteVisibile = await Notes.isVisibleForMe(stamp.targetNote!, requester.user.id); + if (!targetNoteVisibile) { + ctx.status = 404; + return; + } + + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set("Cache-Control", "private, max-age=0, must-revalidate"); + } else { + ctx.set("Cache-Control", "public, max-age=180"); + } + + ctx.body = renderActivity(renderQuoteAuthorization(stamp)); + setResponseType(ctx); +}) + +export default router; diff --git a/packages/backend/src/server/activitypub/featured.ts b/packages/backend/src/server/activitypub/featured.ts new file mode 100644 index 0000000..1492024 --- /dev/null +++ b/packages/backend/src/server/activitypub/featured.ts @@ -0,0 +1,59 @@ +import { IsNull } from "typeorm"; +import config from "@/config/index.js"; +import { renderActivity } from "@/remote/activitypub/renderer/index.js"; +import renderOrderedCollection from "@/remote/activitypub/renderer/ordered-collection.js"; +import renderNote from "@/remote/activitypub/renderer/note.js"; +import { Users, Notes, UserNotePinings } from "@/models/index.js"; +import { checkFetch } from "@/remote/activitypub/check-fetch.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { setResponseType } from "../activitypub.js"; +import type Router from "@koa/router"; + +export default async (ctx: Router.RouterContext) => { + const verify = await checkFetch(ctx.req); + if (verify !== 200) { + ctx.status = verify; + return; + } + + const userId = ctx.params.user; + + const user = await Users.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + ctx.status = 404; + return; + } + + const pinings = await UserNotePinings.find({ + where: { userId: user.id }, + order: { id: "DESC" }, + }); + + const pinnedNotes = await Promise.all( + pinings.map((pining) => Notes.findOneByOrFail({ id: pining.noteId })), + ); + + const renderedNotes = pinnedNotes.map((note) => `${config.url}/notes/${note.id}`); + + const rendered = renderOrderedCollection( + `${config.url}/users/${userId}/collections/featured`, + renderedNotes.length, + undefined, + undefined, + renderedNotes, + ); + + ctx.body = renderActivity(rendered); + + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set("Cache-Control", "private, max-age=0, must-revalidate"); + } else { + ctx.set("Cache-Control", "public, max-age=180"); + } + setResponseType(ctx); +}; diff --git a/packages/backend/src/server/activitypub/followers.ts b/packages/backend/src/server/activitypub/followers.ts new file mode 100644 index 0000000..146ca51 --- /dev/null +++ b/packages/backend/src/server/activitypub/followers.ts @@ -0,0 +1,119 @@ +import { IsNull, LessThan } from "typeorm"; +import config from "@/config/index.js"; +import * as url from "@/prelude/url.js"; +import { renderActivity } from "@/remote/activitypub/renderer/index.js"; +import renderOrderedCollection from "@/remote/activitypub/renderer/ordered-collection.js"; +import renderOrderedCollectionPage from "@/remote/activitypub/renderer/ordered-collection-page.js"; +import renderFollowUser from "@/remote/activitypub/renderer/follow-user.js"; +import { Users, Followings, UserProfiles } from "@/models/index.js"; +import type { Following } from "@/models/entities/following.js"; +import { checkFetch } from "@/remote/activitypub/check-fetch.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { setResponseType } from "../activitypub.js"; +import type { FindOptionsWhere } from "typeorm"; +import type Router from "@koa/router"; + +export default async (ctx: Router.RouterContext) => { + const verify = await checkFetch(ctx.req); + if (verify !== 200) { + ctx.status = verify; + return; + } + + const userId = ctx.params.user; + + const cursor = ctx.request.query.cursor; + if (cursor != null && typeof cursor !== "string") { + ctx.status = 400; + return; + } + + const page = ctx.request.query.page === "true"; + + const user = await Users.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + ctx.status = 404; + return; + } + + //#region Check ff visibility + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + if (profile.ffVisibility === "private") { + ctx.status = 403; + ctx.set("Cache-Control", "public, max-age=30"); + return; + } else if (profile.ffVisibility === "followers") { + ctx.status = 403; + ctx.set("Cache-Control", "public, max-age=30"); + return; + } + //#endregion + + const limit = 10; + const partOf = `${config.url}/users/${userId}/followers`; + + if (page) { + const query = { + followeeId: user.id, + } as FindOptionsWhere; + + // カーソルが指定されている場合 + if (cursor) { + query.id = LessThan(cursor); + } + + // Get followers + const followings = await Followings.find({ + where: query, + take: limit + 1, + order: { id: -1 }, + }); + + // 「次のページ」があるかどうか + const inStock = followings.length === limit + 1; + if (inStock) followings.pop(); + + const renderedFollowers = await Promise.all( + followings.map((following) => renderFollowUser(following.followerId)), + ); + const rendered = renderOrderedCollectionPage( + `${partOf}?${url.query({ + page: "true", + cursor, + })}`, + user.followersCount, + renderedFollowers, + partOf, + undefined, + inStock + ? `${partOf}?${url.query({ + page: "true", + cursor: followings[followings.length - 1].id, + })}` + : undefined, + ); + + ctx.body = renderActivity(rendered); + setResponseType(ctx); + } else { + // index page + const rendered = renderOrderedCollection( + partOf, + user.followersCount, + `${partOf}?page=true`, + ); + ctx.body = renderActivity(rendered); + setResponseType(ctx); + } + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set("Cache-Control", "private, max-age=0, must-revalidate"); + } else { + ctx.set("Cache-Control", "public, max-age=180"); + } +}; diff --git a/packages/backend/src/server/activitypub/following.ts b/packages/backend/src/server/activitypub/following.ts new file mode 100644 index 0000000..eab513c --- /dev/null +++ b/packages/backend/src/server/activitypub/following.ts @@ -0,0 +1,119 @@ +import { LessThan, IsNull } from "typeorm"; +import config from "@/config/index.js"; +import * as url from "@/prelude/url.js"; +import { renderActivity } from "@/remote/activitypub/renderer/index.js"; +import renderOrderedCollection from "@/remote/activitypub/renderer/ordered-collection.js"; +import renderOrderedCollectionPage from "@/remote/activitypub/renderer/ordered-collection-page.js"; +import renderFollowUser from "@/remote/activitypub/renderer/follow-user.js"; +import { Users, Followings, UserProfiles } from "@/models/index.js"; +import type { Following } from "@/models/entities/following.js"; +import { checkFetch } from "@/remote/activitypub/check-fetch.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { setResponseType } from "../activitypub.js"; +import type { FindOptionsWhere } from "typeorm"; +import type Router from "@koa/router"; + +export default async (ctx: Router.RouterContext) => { + const verify = await checkFetch(ctx.req); + if (verify !== 200) { + ctx.status = verify; + return; + } + + const userId = ctx.params.user; + + const cursor = ctx.request.query.cursor; + if (cursor != null && typeof cursor !== "string") { + ctx.status = 400; + return; + } + + const page = ctx.request.query.page === "true"; + + const user = await Users.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + ctx.status = 404; + return; + } + + //#region Check ff visibility + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + if (profile.ffVisibility === "private") { + ctx.status = 403; + ctx.set("Cache-Control", "public, max-age=30"); + return; + } else if (profile.ffVisibility === "followers") { + ctx.status = 403; + ctx.set("Cache-Control", "public, max-age=30"); + return; + } + //#endregion + + const limit = 10; + const partOf = `${config.url}/users/${userId}/following`; + + if (page) { + const query = { + followerId: user.id, + } as FindOptionsWhere; + + // If a cursor is specified + if (cursor) { + query.id = LessThan(cursor); + } + + // Get followings + const followings = await Followings.find({ + where: query, + take: limit + 1, + order: { id: -1 }, + }); + + // Whether there is a "next page" or not + const inStock = followings.length === limit + 1; + if (inStock) followings.pop(); + + const renderedFollowees = await Promise.all( + followings.map((following) => renderFollowUser(following.followeeId)), + ); + const rendered = renderOrderedCollectionPage( + `${partOf}?${url.query({ + page: "true", + cursor, + })}`, + user.followingCount, + renderedFollowees, + partOf, + undefined, + inStock + ? `${partOf}?${url.query({ + page: "true", + cursor: followings[followings.length - 1].id, + })}` + : undefined, + ); + + ctx.body = renderActivity(rendered); + setResponseType(ctx); + } else { + // index page + const rendered = renderOrderedCollection( + partOf, + user.followingCount, + `${partOf}?page=true`, + ); + ctx.body = renderActivity(rendered); + setResponseType(ctx); + } + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set("Cache-Control", "private, max-age=0, must-revalidate"); + } else { + ctx.set("Cache-Control", "public, max-age=180"); + } +}; diff --git a/packages/backend/src/server/activitypub/outbox.ts b/packages/backend/src/server/activitypub/outbox.ts new file mode 100644 index 0000000..adc0679 --- /dev/null +++ b/packages/backend/src/server/activitypub/outbox.ts @@ -0,0 +1,148 @@ +import { Brackets, IsNull } from "typeorm"; +import config from "@/config/index.js"; +import { renderActivity } from "@/remote/activitypub/renderer/index.js"; +import renderOrderedCollection from "@/remote/activitypub/renderer/ordered-collection.js"; +import renderOrderedCollectionPage from "@/remote/activitypub/renderer/ordered-collection-page.js"; +import renderNote from "@/remote/activitypub/renderer/note.js"; +import renderCreate from "@/remote/activitypub/renderer/create.js"; +import renderAnnounce from "@/remote/activitypub/renderer/announce.js"; +import { countIf } from "@/prelude/array.js"; +import * as url from "@/prelude/url.js"; +import { Users, Notes } from "@/models/index.js"; +import type { Note } from "@/models/entities/note.js"; +import { checkFetch } from "@/remote/activitypub/check-fetch.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { makePaginationQuery } from "../api/common/make-pagination-query.js"; +import { setResponseType } from "../activitypub.js"; +import type Router from "@koa/router"; + +export default async (ctx: Router.RouterContext) => { + const verify = await checkFetch(ctx.req); + if (verify !== 200) { + ctx.status = verify; + return; + } + + const userId = ctx.params.user; + + const sinceId = ctx.request.query.since_id; + if (sinceId != null && typeof sinceId !== "string") { + ctx.status = 400; + return; + } + + const untilId = ctx.request.query.until_id; + if (untilId != null && typeof untilId !== "string") { + ctx.status = 400; + return; + } + + const page = ctx.request.query.page === "true"; + + if (countIf((x) => x != null, [sinceId, untilId]) > 1) { + ctx.status = 400; + return; + } + + const user = await Users.findOneBy({ + id: userId, + host: IsNull(), + }); + + if (user == null) { + ctx.status = 404; + return; + } + + const limit = 20; + const partOf = `${config.url}/users/${userId}/outbox`; + + if (page) { + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + sinceId, + untilId, + ) + .andWhere("note.userId = :userId", { userId: user.id }) + .andWhere( + new Brackets((qb) => { + qb.where("note.visibility = 'public'").orWhere( + "note.visibility = 'home'", + ); + }), + ) + .andWhere("note.localOnly = FALSE"); + + const notes = await query.take(limit).getMany(); + + if (sinceId) notes.reverse(); + + const activities = await Promise.all( + notes.map((note) => packActivity(note)), + ); + const rendered = renderOrderedCollectionPage( + `${partOf}?${url.query({ + page: "true", + since_id: sinceId, + until_id: untilId, + })}`, + user.notesCount, + activities, + partOf, + notes.length + ? `${partOf}?${url.query({ + page: "true", + since_id: notes[0].id, + })}` + : undefined, + notes.length + ? `${partOf}?${url.query({ + page: "true", + until_id: notes[notes.length - 1].id, + })}` + : undefined, + ); + + ctx.body = renderActivity(rendered); + setResponseType(ctx); + } else { + // index page + const rendered = renderOrderedCollection( + partOf, + user.notesCount, + `${partOf}?page=true`, + `${partOf}?page=true&since_id=000000000000000000000000`, + ); + ctx.body = renderActivity(rendered); + + setResponseType(ctx); + } + const meta = await fetchMeta(); + if (meta.secureMode || meta.privateMode) { + ctx.set("Cache-Control", "private, max-age=0, must-revalidate"); + } else { + ctx.set("Cache-Control", "public, max-age=180"); + } +}; + +/** + * Pack Create or Announce Activity + * @param note Note + */ +export async function packActivity(note: Note): Promise { + if ( + note.renoteId && + note.text == null && + note.cw == null && + !note.hasPoll && + (note.fileIds == null || note.fileIds.length === 0) + ) { + const renote = await Notes.findOneByOrFail({ id: note.renoteId }); + return renderAnnounce( + renote.uri ?? `${config.url}/notes/${renote.id}`, + note, + ); + } + + return renderCreate(await renderNote(note, false), note); +} diff --git a/packages/backend/src/server/api/2fa.ts b/packages/backend/src/server/api/2fa.ts new file mode 100644 index 0000000..7318f0f --- /dev/null +++ b/packages/backend/src/server/api/2fa.ts @@ -0,0 +1,417 @@ +import * as crypto from "node:crypto"; +import * as jsrsasign from "jsrsasign"; +import config from "@/config/index.js"; + +const ECC_PRELUDE = Buffer.from([0x04]); +const NULL_BYTE = Buffer.from([0]); +const PEM_PRELUDE = Buffer.from( + "3059301306072a8648ce3d020106082a8648ce3d030107034200", + "hex", +); + +// Android Safetynet attestations are signed with this cert: +const GSR2 = `-----BEGIN CERTIFICATE----- +MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G +A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp +Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1 +MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG +A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL +v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8 +eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq +tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd +C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa +zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB +mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH +V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n +bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG +3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs +J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO +291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS +ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd +AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7 +TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg== +-----END CERTIFICATE-----\n`; + +function base64URLDecode(source: string) { + return Buffer.from(source.replace(/\-/g, "+").replace(/_/g, "/"), "base64"); +} + +function getCertSubject(certificate: string) { + const subjectCert = new jsrsasign.X509(); + subjectCert.readCertPEM(certificate); + + const subjectString = subjectCert.getSubjectString(); + const subjectFields = subjectString.slice(1).split("/"); + + const fields = {} as Record; + for (const field of subjectFields) { + const eqIndex = field.indexOf("="); + fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1); + } + + return fields; +} + +function verifyCertificateChain(certificates: string[]) { + let valid = true; + + for (let i = 0; i < certificates.length; i++) { + const Cert = certificates[i]; + const certificate = new jsrsasign.X509(); + certificate.readCertPEM(Cert); + + const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1]; + + const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex!, 0, [0]); + const algorithm = certificate.getSignatureAlgorithmField(); + const signatureHex = certificate.getSignatureValueHex(); + + // Verify against CA + const Signature = new jsrsasign.KJUR.crypto.Signature({ alg: algorithm }); + Signature.init(CACert); + Signature.updateHex(certStruct); + valid = valid && !!Signature.verify(signatureHex); // true if CA signed the certificate + } + + return valid; +} + +function PEMString(pemBuffer: Buffer, type = "CERTIFICATE") { + if (pemBuffer.length === 65 && pemBuffer[0] === 0x04) { + pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91); + type = "PUBLIC KEY"; + } + const cert = pemBuffer.toString("base64"); + + const keyParts = []; + const max = Math.ceil(cert.length / 64); + let start = 0; + for (let i = 0; i < max; i++) { + keyParts.push(cert.substring(start, start + 64)); + start += 64; + } + + return `-----BEGIN ${type}-----\n${keyParts.join( + "\n", + )}\n-----END ${type}-----\n`; +} + +export function hash(data: Buffer) { + return crypto.createHash("sha256").update(data).digest(); +} + +export function verifyLogin({ + publicKey, + authenticatorData, + clientDataJSON, + clientData, + signature, + challenge, +}: { + publicKey: Buffer; + authenticatorData: Buffer; + clientDataJSON: Buffer; + clientData: any; + signature: Buffer; + challenge: string; +}) { + if (clientData.type !== "webauthn.get") { + throw new Error("type is not webauthn.get"); + } + + if (hash(clientData.challenge).toString("hex") !== challenge) { + throw new Error("challenge mismatch"); + } + if (clientData.origin !== `${config.scheme}://${config.host}`) { + throw new Error("origin mismatch"); + } + + const verificationData = Buffer.concat( + [authenticatorData, hash(clientDataJSON)], + 32 + authenticatorData.length, + ); + + return crypto + .createVerify("SHA256") + .update(verificationData) + .verify(PEMString(publicKey), signature); +} + +export const procedures = { + none: { + verify({ publicKey }: { publicKey: Map }) { + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error("invalid or no -2 key given"); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error("invalid or no -3 key given"); + } + + const publicKeyU2F = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + + return { + publicKey: publicKeyU2F, + valid: true, + }; + }, + }, + "android-key": { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId, + }: { + attStmt: any; + authenticatorData: Buffer; + clientDataHash: Buffer; + publicKey: Map; + rpIdHash: Buffer; + credentialId: Buffer; + }) { + if (attStmt.alg !== -7) { + throw new Error("alg mismatch"); + } + + const verificationData = Buffer.concat([ + authenticatorData, + clientDataHash, + ]); + + const attCert: Buffer = attStmt.x5c[0]; + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error("invalid or no -2 key given"); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error("invalid or no -3 key given"); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + + if (!attCert.equals(publicKeyData)) { + throw new Error("public key mismatch"); + } + + const isValid = crypto + .createVerify("SHA256") + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + // TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON) + + return { + valid: isValid, + publicKey: publicKeyData, + }; + }, + }, + // what a stupid attestation + "android-safetynet": { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId, + }: { + attStmt: any; + authenticatorData: Buffer; + clientDataHash: Buffer; + publicKey: Map; + rpIdHash: Buffer; + credentialId: Buffer; + }) { + const verificationData = hash( + Buffer.concat([authenticatorData, clientDataHash]), + ); + + const jwsParts = attStmt.response.toString("utf-8").split("."); + + const header = JSON.parse(base64URLDecode(jwsParts[0]).toString("utf-8")); + const response = JSON.parse( + base64URLDecode(jwsParts[1]).toString("utf-8"), + ); + const signature = jwsParts[2]; + + if (!verificationData.equals(Buffer.from(response.nonce, "base64"))) { + throw new Error("invalid nonce"); + } + + const certificateChain = header.x5c + .map((key: any) => PEMString(key)) + .concat([GSR2]); + + if (getCertSubject(certificateChain[0]).CN !== "attest.android.com") { + throw new Error("invalid common name"); + } + + if (!verifyCertificateChain(certificateChain)) { + throw new Error("Invalid certificate chain!"); + } + + const signatureBase = Buffer.from( + `${jwsParts[0]}.${jwsParts[1]}`, + "utf-8", + ); + + const valid = crypto + .createVerify("sha256") + .update(signatureBase) + .verify(certificateChain[0], base64URLDecode(signature)); + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error("invalid or no -2 key given"); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error("invalid or no -3 key given"); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + return { + valid, + publicKey: publicKeyData, + }; + }, + }, + packed: { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId, + }: { + attStmt: any; + authenticatorData: Buffer; + clientDataHash: Buffer; + publicKey: Map; + rpIdHash: Buffer; + credentialId: Buffer; + }) { + const verificationData = Buffer.concat([ + authenticatorData, + clientDataHash, + ]); + + if (attStmt.x5c) { + const attCert = attStmt.x5c[0]; + + const validSignature = crypto + .createVerify("SHA256") + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + const negTwo = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error("invalid or no -2 key given"); + } + const negThree = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error("invalid or no -3 key given"); + } + + const publicKeyData = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + + return { + valid: validSignature, + publicKey: publicKeyData, + }; + } else if (attStmt.ecdaaKeyId) { + // https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation + throw new Error("ECDAA-Verify is not supported"); + } else { + if (attStmt.alg !== -7) throw new Error("alg mismatch"); + + throw new Error("self attestation is not supported"); + } + }, + }, + + "fido-u2f": { + verify({ + attStmt, + authenticatorData, + clientDataHash, + publicKey, + rpIdHash, + credentialId, + }: { + attStmt: any; + authenticatorData: Buffer; + clientDataHash: Buffer; + publicKey: Map; + rpIdHash: Buffer; + credentialId: Buffer; + }) { + const x5c: Buffer[] = attStmt.x5c; + if (x5c.length !== 1) { + throw new Error("x5c length does not match expectation"); + } + + const attCert = x5c[0]; + + // TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve + + const negTwo: Buffer = publicKey.get(-2); + + if (!negTwo || negTwo.length !== 32) { + throw new Error("invalid or no -2 key given"); + } + const negThree: Buffer = publicKey.get(-3); + if (!negThree || negThree.length !== 32) { + throw new Error("invalid or no -3 key given"); + } + + const publicKeyU2F = Buffer.concat( + [ECC_PRELUDE, negTwo, negThree], + 1 + 32 + 32, + ); + + const verificationData = Buffer.concat([ + NULL_BYTE, + rpIdHash, + clientDataHash, + credentialId, + publicKeyU2F, + ]); + + const validSignature = crypto + .createVerify("SHA256") + .update(verificationData) + .verify(PEMString(attCert), attStmt.sig); + + return { + valid: validSignature, + publicKey: publicKeyU2F, + }; + }, + }, +}; diff --git a/packages/backend/src/server/api/api-handler.ts b/packages/backend/src/server/api/api-handler.ts new file mode 100644 index 0000000..99a12fd --- /dev/null +++ b/packages/backend/src/server/api/api-handler.ts @@ -0,0 +1,123 @@ +import type Koa from "koa"; + +import type { User } from "@/models/entities/user.js"; +import { UserIps } from "@/models/index.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import type { IEndpoint } from "./endpoints.js"; +import authenticate, { AuthenticationError } from "./authenticate.js"; +import call from "./call.js"; +import { ApiError } from "./error.js"; + +const userIpHistories = new Map>(); + +setInterval(() => { + userIpHistories.clear(); +}, 1000 * 60 * 60); + +export default (endpoint: IEndpoint, ctx: Koa.Context) => + new Promise((res) => { + const body = ctx.is("multipart/form-data") + ? (ctx.request as any).body + : ctx.method === "GET" + ? ctx.query + : ctx.request.body; + + const reply = (x?: any, y?: ApiError) => { + if (x == null) { + ctx.status = 204; + } else if (typeof x === "number" && y) { + ctx.status = x; + ctx.body = { + error: { + message: y!.message, + code: y!.code, + id: y!.id, + kind: y!.kind, + ...(y!.info ? { info: y!.info } : {}), + }, + }; + } else { + // 文字列を返す場合は、JSON.stringify通さないとJSONと認識されない + ctx.body = typeof x === "string" ? JSON.stringify(x) : x; + } + res(); + }; + + // Authentication + // for GET requests, do not even pass on the body parameter as it is considered unsafe + authenticate( + ctx.headers.authorization, + ctx.method === "GET" ? null : body["i"], + ) + .then(([user, app]) => { + // API invoking + call(endpoint.name, user, app, body, ctx) + .then((res: any) => { + if ( + ctx.method === "GET" && + endpoint.meta.cacheSec && + !body["i"] && + !user + ) { + ctx.set( + "Cache-Control", + `public, max-age=${endpoint.meta.cacheSec}`, + ); + } + reply(res); + }) + .catch((e: ApiError) => { + reply( + e.httpStatusCode + ? e.httpStatusCode + : e.kind === "client" + ? 400 + : 500, + e, + ); + }); + + // Log IP + if (user) { + fetchMeta().then((meta) => { + if (!meta.enableIpLogging) return; + const ip = ctx.ip; + const ips = userIpHistories.get(user.id); + if (ips == null || !ips.has(ip)) { + if (ips == null) { + userIpHistories.set(user.id, new Set([ip])); + } else { + ips.add(ip); + } + + try { + UserIps.createQueryBuilder() + .insert() + .values({ + createdAt: new Date(), + userId: user.id, + ip: ip, + }) + .orIgnore(true) + .execute(); + } catch {} + } + }); + } + }) + .catch((e) => { + if (e instanceof AuthenticationError) { + ctx.response.status = 403; + ctx.response.set("WWW-Authenticate", "Bearer"); + ctx.response.body = { + message: `Authentication failed: ${e.message}`, + code: "AUTHENTICATION_FAILED", + id: "b0a7f5f8-dc2f-4171-b91f-de88ad238e14", + kind: "client", + }; + res(); + } else { + reply(500, new ApiError()); + } + }); + }); diff --git a/packages/backend/src/server/api/authenticate.ts b/packages/backend/src/server/api/authenticate.ts new file mode 100644 index 0000000..8ebab52 --- /dev/null +++ b/packages/backend/src/server/api/authenticate.ts @@ -0,0 +1,114 @@ +import isNativeToken from "./common/is-native-token.js"; +import type { CacheableLocalUser, ILocalUser } from "@/models/entities/user.js"; +import { Users, AccessTokens, Apps } from "@/models/index.js"; +import type { AccessToken } from "@/models/entities/access-token.js"; +import { Cache } from "@/misc/cache.js"; +import type { App } from "@/models/entities/app.js"; +import { + localUserByIdCache, + localUserByNativeTokenCache, +} from "@/services/user-cache.js"; + +const appCache = new Cache("app", 60 * 30); + +export class AuthenticationError extends Error { + constructor(message: string) { + super(message); + this.name = "AuthenticationError"; + } +} + +export default async ( + authorization: string | null | undefined, + bodyToken: string | null, + bypassUserCache: boolean = false +): Promise< + [CacheableLocalUser | null | undefined, AccessToken | null | undefined] +> => { + let token: string | null = null; + + // check if there is an authorization header set + if (authorization != null) { + if (bodyToken != null) { + throw new AuthenticationError("using multiple authorization schemes"); + } + + // check if OAuth 2.0 Bearer tokens are being used + // Authorization schemes are case insensitive + if (authorization.substring(0, 7).toLowerCase() === "bearer ") { + token = authorization.substring(7); + } else { + throw new AuthenticationError("unsupported authentication scheme"); + } + } else if (bodyToken != null) { + token = bodyToken; + } else { + return [null, null]; + } + + if (isNativeToken(token)) { + const user = bypassUserCache + ? await Users.findOneBy({ token }) as ILocalUser | null + : await localUserByNativeTokenCache.fetch( + token, + () => Users.findOneBy({ token: token ?? undefined }) as Promise, + true, + ); + + if (user == null) { + throw new AuthenticationError("unknown token"); + } + + return [user, null]; + } else { + const accessToken = await AccessTokens.findOne({ + where: [ + { + hash: token.toLowerCase(), // app + }, + { + token: token, // miauth + }, + ], + }); + + if (accessToken == null) { + throw new AuthenticationError("unknown token"); + } + + AccessTokens.update(accessToken.id, { + lastUsedAt: new Date(), + }); + + const user = bypassUserCache + ? await Users.findOneBy({ + id: accessToken.userId, + }) as ILocalUser + : await localUserByIdCache.fetch( + accessToken.userId, + () => + Users.findOneBy({ + id: accessToken.userId, + }) as Promise, + true, + ); + + if (accessToken.appId) { + const app = await appCache.fetch( + accessToken.appId, + () => Apps.findOneByOrFail({ id: accessToken.appId! }), + true, + ); + + return [ + user, + { + id: accessToken.id, + permission: app.permission, + } as AccessToken, + ]; + } else { + return [user, accessToken]; + } + } +}; diff --git a/packages/backend/src/server/api/call.ts b/packages/backend/src/server/api/call.ts new file mode 100644 index 0000000..0a1027b --- /dev/null +++ b/packages/backend/src/server/api/call.ts @@ -0,0 +1,195 @@ +import { performance } from "perf_hooks"; +import type Koa from "koa"; +import type { CacheableLocalUser } from "@/models/entities/user.js"; +import { User } from "@/models/entities/user.js"; +import type { AccessToken } from "@/models/entities/access-token.js"; +import { getIpHash } from "@/misc/get-ip-hash.js"; +import { limiter } from "./limiter.js"; +import type { IEndpointMeta } from "./endpoints.js"; +import endpoints from "./endpoints.js"; +import compatibility from "./compatibility.js"; +import { ApiError } from "./error.js"; +import { apiLogger } from "./logger.js"; +import type { AccessToken } from "@/models/entities/access-token.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; + +const accessDenied = { + message: "Access denied.", + code: "ACCESS_DENIED", + id: "56f35758-7dd5-468b-8439-5d6fb8ec9b8e", +}; + +export default async ( + endpoint: string, + user: CacheableLocalUser | null | undefined, + token: AccessToken | null | undefined, + data: any, + ctx?: Koa.Context, +) => { + const isSecure = user != null && token == null; + const isModerator = user != null && (user.isModerator || user.isAdmin); + + const ep = + endpoints.find((e) => e.name === endpoint) || + compatibility.find((e) => e.name === endpoint); + + if (ep == null) { + throw new ApiError({ + message: "No such endpoint.", + code: "NO_SUCH_ENDPOINT", + id: "f8080b67-5f9c-4eb7-8c18-7f1eeae8f709", + httpStatusCode: 404, + }); + } + + if (ep.meta.secure && !isSecure) { + throw new ApiError(accessDenied); + } + + if (ep.meta.limit) { + // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. + let limitActor: string; + if (user) { + limitActor = user.id; + } else { + limitActor = getIpHash(ctx!.ip); + } + + const limit = Object.assign({}, ep.meta.limit); + + if (!limit.key) { + limit.key = ep.name; + } + + // Rate limit + await limiter( + limit as IEndpointMeta["limit"] & { key: NonNullable }, + limitActor, + ).catch((e) => { + const remainingTime = e.remainingTime + ? `Please try again in ${e.remainingTime}.` + : "Please try again later."; + throw new ApiError({ + message: `Rate limit exceeded. ${remainingTime}`, + code: "RATE_LIMIT_EXCEEDED", + id: "d5826d14-3982-4d2e-8011-b9e9f02499ef", + httpStatusCode: 429, + }); + }); + } + + if (ep.meta.requireCredential && user == null) { + throw new ApiError({ + message: "Credential required.", + code: "CREDENTIAL_REQUIRED", + id: "1384574d-a912-4b81-8601-c7b1c4085df1", + httpStatusCode: 401, + }); + } + + if (ep.meta.requireCredential && user!.isSuspended) { + throw new ApiError({ + message: "Your account has been suspended.", + code: "YOUR_ACCOUNT_SUSPENDED", + id: "a8c724b3-6e9c-4b46-b1a8-bc3ed6258370", + httpStatusCode: 403, + }); + } + + if (ep.meta.requireAdmin && !user!.isAdmin) { + throw new ApiError(accessDenied, { reason: "You are not an admin." }); + } + + if (ep.meta.requireModerator && !isModerator) { + throw new ApiError(accessDenied, { reason: "You are not a moderator." }); + } + + if ( + token && + ep.meta.kind && + !token.permission.some((p) => p === ep.meta.kind) + ) { + throw new ApiError({ + message: + "Your app does not have the necessary permissions to use this endpoint.", + code: "PERMISSION_DENIED", + id: "1370e5b7-d4eb-4566-bb1d-7748ee6a1838", + }); + } + + // private mode + const meta = await fetchMeta(); + if ( + meta.privateMode && + ep.meta.requireCredentialPrivateMode && + user == null + ) { + throw new ApiError({ + message: "Credential required.", + code: "CREDENTIAL_REQUIRED", + id: "1384574d-a912-4b81-8601-c7b1c4085df1", + httpStatusCode: 401, + }); + } + + // Cast non JSON input + if ((ep.meta.requireFile || ctx?.method === "GET") && ep.params.properties) { + for (const k of Object.keys(ep.params.properties)) { + const param = ep.params.properties![k]; + if ( + ["boolean", "number", "integer"].includes(param.type ?? "") && + typeof data[k] === "string" + ) { + try { + data[k] = JSON.parse(data[k]); + } catch (e) { + throw new ApiError( + { + message: "Invalid param.", + code: "INVALID_PARAM", + id: "0b5f1631-7c1a-41a6-b399-cce335f34d85", + }, + { + param: k, + reason: `cannot cast to ${param.type}`, + }, + ); + } + } + } + } + + // API invoking + const before = performance.now(); + return await ep + .exec(data, user, token, ctx?.file, ctx?.ip, ctx?.headers) + .catch((e: Error) => { + if (e instanceof ApiError) { + throw e; + } else { + apiLogger.error(`Internal error occurred in ${ep.name}: ${e.message}`, { + ep: ep.name, + ps: data, + e: { + message: e.message, + code: e.name, + stack: e.stack, + }, + }); + throw new ApiError(null, { + e: { + message: e.message, + code: e.name, + stack: e.stack, + }, + }); + } + }) + .finally(() => { + const after = performance.now(); + const time = after - before; + if (time > 1000) { + apiLogger.warn(`SLOW API CALL DETECTED: ${ep.name} (${time}ms)`); + } + }); +}; diff --git a/packages/backend/src/server/api/common/generate-block-query.ts b/packages/backend/src/server/api/common/generate-block-query.ts new file mode 100644 index 0000000..8ca9db2 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-block-query.ts @@ -0,0 +1,68 @@ +import type { User } from "@/models/entities/user.js"; +import { Blockings } from "@/models/index.js"; +import type { SelectQueryBuilder } from "typeorm"; +import { Brackets } from "typeorm"; + +// ここでいうBlockedは被Blockedの意 +export function generateBlockedUserQuery( + q: SelectQueryBuilder, + me: { id: User["id"] }, +) { + const blockingQuery = Blockings.createQueryBuilder("blocking") + .select("blocking.blockerId") + .where("blocking.blockeeId = :blockeeId", { blockeeId: me.id }) + .andWhere("blocking.groupId IS NULL"); + + const groupBlockingQuery = Blockings.createQueryBuilder("blocking") + .select("blocking.groupId") + .where("blocking.blockeeId = :groupBlockeeId", { groupBlockeeId: me.id }) + .andWhere("blocking.groupId IS NOT NULL"); + + // 投稿の作者にブロックされていない かつ + // 投稿の返信先の作者にブロックされていない かつ + // 投稿の引用元の作者にブロックされていない + q.andWhere(`note.userId NOT IN (${blockingQuery.getQuery()})`) + .andWhere( + new Brackets((qb) => { + qb.where("note.groupId IS NULL").orWhere( + `note.groupId NOT IN (${groupBlockingQuery.getQuery()})`, + ); + }), + ) + .andWhere( + new Brackets((qb) => { + qb.where("note.replyUserId IS NULL").orWhere( + `note.replyUserId NOT IN (${blockingQuery.getQuery()})`, + ); + }), + ) + .andWhere( + new Brackets((qb) => { + qb.where("note.renoteUserId IS NULL").orWhere( + `note.renoteUserId NOT IN (${blockingQuery.getQuery()})`, + ); + }), + ); + + q.setParameters(blockingQuery.getParameters()); + q.setParameters(groupBlockingQuery.getParameters()); +} + +export function generateBlockQueryForUsers( + q: SelectQueryBuilder, + me: { id: User["id"] }, +) { + const blockingQuery = Blockings.createQueryBuilder("blocking") + .select("blocking.blockeeId") + .where("blocking.blockerId = :blockerId", { blockerId: me.id }); + + const blockedQuery = Blockings.createQueryBuilder("blocking") + .select("blocking.blockerId") + .where("blocking.blockeeId = :blockeeId", { blockeeId: me.id }); + + q.andWhere(`user.id NOT IN (${blockingQuery.getQuery()})`); + q.setParameters(blockingQuery.getParameters()); + + q.andWhere(`user.id NOT IN (${blockedQuery.getQuery()})`); + q.setParameters(blockedQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/common/generate-channel-query.ts b/packages/backend/src/server/api/common/generate-channel-query.ts new file mode 100644 index 0000000..3180622 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-channel-query.ts @@ -0,0 +1,35 @@ +import type { User } from "@/models/entities/user.js"; +import { ChannelFollowings } from "@/models/index.js"; +import type { SelectQueryBuilder } from "typeorm"; +import { Brackets } from "typeorm"; + +export function generateChannelQuery( + q: SelectQueryBuilder, + me?: { id: User["id"] } | null, +) { + if (me == null) { + q.andWhere("note.channelId IS NULL"); + } else { + q.leftJoinAndSelect("note.channel", "channel"); + + const channelFollowingQuery = ChannelFollowings.createQueryBuilder( + "channelFollowing", + ) + .select("channelFollowing.followeeId") + .where("channelFollowing.followerId = :followerId", { + followerId: me.id, + }); + + q.andWhere( + new Brackets((qb) => { + qb + // チャンネルのノートではない + .where("note.channelId IS NULL") + // または自分がフォローしているチャンネルのノート + .orWhere(`note.channelId IN (${channelFollowingQuery.getQuery()})`); + }), + ); + + q.setParameters(channelFollowingQuery.getParameters()); + } +} diff --git a/packages/backend/src/server/api/common/generate-exclude-memoriet-query.ts b/packages/backend/src/server/api/common/generate-exclude-memoriet-query.ts new file mode 100644 index 0000000..fd758b4 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-exclude-memoriet-query.ts @@ -0,0 +1,8 @@ +import type { SelectQueryBuilder } from "typeorm"; +import type { Note } from "@/models/entities/note.js"; + +export function generateExcludeMemorietQuery(query: SelectQueryBuilder) { + query.andWhere( + `NOT EXISTS (SELECT 1 FROM "memoriet" "memoriet_exclude" WHERE "memoriet_exclude"."noteId" = note.id)`, + ); +} diff --git a/packages/backend/src/server/api/common/generate-following-query.ts b/packages/backend/src/server/api/common/generate-following-query.ts new file mode 100644 index 0000000..c6a592d --- /dev/null +++ b/packages/backend/src/server/api/common/generate-following-query.ts @@ -0,0 +1,53 @@ +import { Brackets, SelectQueryBuilder } from "typeorm"; +import { User } from "@/models/entities/user.js"; +import { Followings, Notes } from "@/models/index.js"; +import { Cache } from "@/misc/cache.js"; +import { apiLogger } from "@/server/api/logger.js"; + +export const cache = new Cache("homeTlQueryData", 60 * 60 * 24); +const cutoff = 250; // 250 posts in the last 7 days, constant determined by comparing benchmarks for cutoff values between 100 and 2500 +const logger = apiLogger.createSubLogger("heuristics"); + +export async function generateFollowingQuery( + q: SelectQueryBuilder, + me: { id: User["id"] }, +): Promise { + const followingQuery = Followings.createQueryBuilder("following") + .select("following.followeeId") + .where("following.followerId = :meId"); + + const heuristic = await cache.fetch(me.id, async () => { + let curr = new Date(); + let prev = new Date(); + prev.setDate(prev.getDate() - 7); + return Notes.createQueryBuilder('note') + .where(`note.createdAt > :prev`, { prev }) + .andWhere(`note.createdAt < :curr`, { curr }) + .andWhere( + new Brackets((qb) => { + qb.where(`note.userId IN (${followingQuery.getQuery()})`); + qb.orWhere(`note.userId = :meId`, { meId: me.id }); + }) + ) + .getCount() + .then(res => { + logger.info(`Calculating heuristics for user ${me.id} took ${new Date().getTime() - curr.getTime()}ms`); + return res; + }); + }); + + const shouldUseUnion = heuristic < cutoff ; + + q.andWhere( + new Brackets((qb) => { + if (shouldUseUnion) { + qb.where(`note.userId = ANY(array(${followingQuery.getQuery()} UNION ALL VALUES (:meId)))`); + } else { + qb.where(`note.userId = :meId`); + qb.orWhere(`note.userId IN (${followingQuery.getQuery()})`); + } + }), + ) + + q.setParameters({ meId: me.id }); +} diff --git a/packages/backend/src/server/api/common/generate-fts-query.ts b/packages/backend/src/server/api/common/generate-fts-query.ts new file mode 100644 index 0000000..916bcbb --- /dev/null +++ b/packages/backend/src/server/api/common/generate-fts-query.ts @@ -0,0 +1,284 @@ +import { Brackets, SelectQueryBuilder, WhereExpressionBuilder } from "typeorm"; +import { sqlLikeEscape } from "@/misc/sql-like-escape.js"; +import { sqlRegexEscape } from "@/misc/sql-regex-escape.js"; +import { Followings, NoteFavorites, NoteReactions, Users } from "@/models/index.js"; + +const filters = { + "from": fromFilter, + "-from": fromFilterInverse, + "mention": mentionFilter, + "-mention": mentionFilterInverse, + "reply": replyFilter, + "-reply": replyFilterInverse, + "to": replyFilter, + "-to": replyFilterInverse, + "before": beforeFilter, + "until": beforeFilter, + "after": afterFilter, + "since": afterFilter, + "instance": instanceFilter, + "-instance": instanceFilterInverse, + "domain": instanceFilter, + "-domain": instanceFilterInverse, + "host": instanceFilter, + "-host": instanceFilterInverse, + "filter": miscFilter, + "-filter": miscFilterInverse, + "in": inFilter, + "-in": inFilterInverse, + "has": attachmentFilter, +} as Record, search: string, id: number) => any> + +export function generateFtsQuery(query: SelectQueryBuilder, q: string): void { + const components = q.trim().split(" "); + const terms: string[] = []; + let finalTerms: string[] = []; + let counter = 0; + let caseSensitive = false; + let matchWords = false; + + for (const component of components) { + const split = component.split(":"); + if (split.length > 1 && filters[split[0]] !== undefined) + filters[split[0]](query, split.slice(1).join(":"), counter++); + else if(split.length > 1 && (split[0] === "search" || split[0] === "match")) + matchWords = split[1] === 'word' || split[1] === 'words'; + else if(split.length > 1 && split[0] === "case") + caseSensitive = split[1] === 'sensitive'; + else terms.push(component); + } + + let idx = 0; + let state: 'idle' | 'quote' | 'parenthesis' = 'idle'; + for (let i = 0; i < terms.length; i++) { + if (state === 'idle') { + if ((terms[i].startsWith('"') && terms[i].endsWith('"')) || (terms[i].startsWith('(') && terms[i].endsWith(')'))) { + finalTerms.push(trimStartAndEnd(terms[i])); + } else if (terms[i].startsWith('"')) { + idx = i; + state = 'quote'; + } else if (terms[i].startsWith('(')) { + idx = i; + state = 'parenthesis'; + } else { + finalTerms.push(terms[i]); + } + } else if (state === 'quote' && terms[i].endsWith('"')) { + finalTerms.push(extractToken(terms, idx, i)); + state = 'idle'; + } else if (state === 'parenthesis' && terms[i].endsWith(')')) { + query.andWhere(new Brackets(qb => { + for (const term of extractToken(terms, idx, i).split(' OR ')) { + const id = counter++; + appendSearchQuery(term, 'or', query, qb, id, term.startsWith('-'), matchWords, caseSensitive); + } + })); + state = 'idle'; + } + } + + if (state != "idle") { + finalTerms.push(...extractToken(terms, idx, terms.length - 1, false).substring(1).split(' ')); + } + + for (const term of finalTerms) { + const id = counter++; + appendSearchQuery(term, 'and', query, query, id, term.startsWith('-'), matchWords, caseSensitive); + } +} + +function fromFilter(query: SelectQueryBuilder, filter: string, id: number) { + const userQuery = generateUserSubquery(filter, id); + query.andWhere(`note.userId = (${userQuery.getQuery()})`); + query.setParameters(userQuery.getParameters()); +} + +function fromFilterInverse(query: SelectQueryBuilder, filter: string, id: number) { + const userQuery = generateUserSubquery(filter, id); + query.andWhere(`note.userId <> (${userQuery.getQuery()})`); + query.setParameters(userQuery.getParameters()); +} + +function mentionFilter(query: SelectQueryBuilder, filter: string, id: number) { + const userQuery = generateUserSubquery(filter, id); + query.addCommonTableExpression(userQuery.getQuery(), `cte_${id}`, { materialized: true }) + query.andWhere(`note.mentions @> array[(SELECT * FROM cte_${id})]::varchar[]`); + query.setParameters(userQuery.getParameters()); +} + +function mentionFilterInverse(query: SelectQueryBuilder, filter: string, id: number) { + const userQuery = generateUserSubquery(filter, id); + query.addCommonTableExpression(userQuery.getQuery(), `cte_${id}`, { materialized: true }) + query.andWhere(`NOT (note.mentions @> array[(SELECT * FROM cte_${id})]::varchar[])`); + query.setParameters(userQuery.getParameters()); +} + +function replyFilter(query: SelectQueryBuilder, filter: string, id: number) { + const userQuery = generateUserSubquery(filter, id); + query.andWhere(`note.replyUserId = (${userQuery.getQuery()})`); + query.setParameters(userQuery.getParameters()); +} + +function replyFilterInverse(query: SelectQueryBuilder, filter: string, id: number) { + const userQuery = generateUserSubquery(filter, id); + query.andWhere(`note.replyUserId <> (${userQuery.getQuery()})`); + query.setParameters(userQuery.getParameters()); +} + +function beforeFilter(query: SelectQueryBuilder, filter: string) { + query.andWhere('note.createdAt < :before', { before: filter }); +} + +function afterFilter(query: SelectQueryBuilder, filter: string) { + query.andWhere('note.createdAt > :after', { after: filter }); +} + +function instanceFilter(query: SelectQueryBuilder, filter: string, id: number) { + if (filter === 'local') { + query.andWhere(`note.userHost IS NULL`); + } else { + query.andWhere(`note.userHost = :instance_${id}`); + query.setParameter(`instance_${id}`, filter); + } +} + +function instanceFilterInverse(query: SelectQueryBuilder, filter: string, id: number) { + if (filter === 'local') { + query.andWhere(`note.userHost IS NOT NULL`); + } else { + query.andWhere(`note.userHost <> :instance_${id}`); + query.setParameter(`instance_${id}`, filter); + } +} + +function miscFilter(query: SelectQueryBuilder, filter: string) { + let subQuery: SelectQueryBuilder | null = null; + if (filter === 'followers') { + subQuery = Followings.createQueryBuilder('following') + .select('following.followerId') + .where('following.followeeId = :meId'); + } else if (filter === 'following') { + subQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :meId'); + } else if (filter === 'replies' || filter === 'reply') { + query.andWhere('note.replyId IS NOT NULL'); + } else if (filter === 'boosts' || filter === 'boost' || filter === 'renotes' || filter === 'renote') { + query.andWhere('note.renoteId IS NOT NULL'); + } + + if (subQuery !== null) query.andWhere(`note.userId IN (${subQuery.getQuery()})`); +} + +function miscFilterInverse(query: SelectQueryBuilder, filter: string) { + let subQuery: SelectQueryBuilder | null = null; + if (filter === 'followers') { + subQuery = Followings.createQueryBuilder('following') + .select('following.followerId') + .where('following.followeeId = :meId'); + } else if (filter === 'following') { + subQuery = Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :meId'); + } else if (filter === 'replies' || filter === 'reply') { + query.andWhere('note.replyId IS NULL'); + } else if (filter === 'boosts' || filter === 'boost' || filter === 'renotes' || filter === 'renote') { + query.andWhere('note.renoteId IS NULL'); + } + + if (subQuery !== null) query.andWhere(`note.userId NOT IN (${subQuery.getQuery()})`); +} + +function inFilter(query: SelectQueryBuilder, filter: string) { + let subQuery: SelectQueryBuilder | null = null; + if (filter === 'bookmarks') { + subQuery = NoteFavorites.createQueryBuilder('bookmark') + .select('bookmark.noteId') + .where('bookmark.userId = :meId'); + } else if (filter === 'favorites' || filter === 'favourites' || filter === 'reactions' || filter === 'likes') { + subQuery = NoteReactions.createQueryBuilder('react') + .select('react.noteId') + .where('react.userId = :meId'); + } + + if (subQuery !== null) query.andWhere(`note.id IN (${subQuery.getQuery()})`); +} + +function inFilterInverse(query: SelectQueryBuilder, filter: string) { + let subQuery: SelectQueryBuilder | null = null; + if (filter === 'bookmarks') { + subQuery = NoteFavorites.createQueryBuilder('bookmark') + .select('bookmark.noteId') + .where('bookmark.userId = :meId'); + } else if (filter === 'favorites' || filter === 'favourites' || filter === 'reactions' || filter === 'likes') { + subQuery = NoteReactions.createQueryBuilder('react') + .select('react.noteId') + .where('react.userId = :meId'); + } + + if (subQuery !== null) query.andWhere(`note.id NOT IN (${subQuery.getQuery()})`); +} + +function attachmentFilter(query: SelectQueryBuilder, filter: string) { + switch(filter) { + case 'image': + query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%image/%'`); + break; + case 'video': + query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%video/%'`); + break; + case 'audio': + query.andWhere(`note."attachedFileTypes"::varchar ILIKE '%audio/%'`); + break; + case 'file': + query.andWhere(`note."attachedFileTypes" <> '{}'`); + query.andWhere(`NOT (note."attachedFileTypes"::varchar ILIKE '%image/%')`); + query.andWhere(`NOT (note."attachedFileTypes"::varchar ILIKE '%video/%')`); + query.andWhere(`NOT (note."attachedFileTypes"::varchar ILIKE '%audio/%')`); + break; + default: + break; + } +} + +function generateUserSubquery(filter: string, id: number) { + if (filter.startsWith('@')) filter = filter.substring(1); + const split = filter.split('@'); + + const query = Users.createQueryBuilder('user') + .select('user.id') + .where(`user.usernameLower = :user_${id}`) + .andWhere(`user.host ${split[1] !== undefined ? `= :host_${id}` : 'IS NULL'}`); + + query.setParameter(`user_${id}`, split[0].toLowerCase()); + + if (split[1] !== undefined) + query.setParameter(`host_${id}`, split[1].toLowerCase()); + + return query; +} + +function extractToken(array: string[], start: number, end: number, trim: boolean = true) { + const slice = array.slice(start, end+1).join(" "); + return trim ? trimStartAndEnd(slice) : slice; +} + +function trimStartAndEnd(str: string) { + return str.substring(1, str.length - 1); +} + +function appendSearchQuery(term: string, mode: 'and' | 'or', query: SelectQueryBuilder, qb: SelectQueryBuilder | WhereExpressionBuilder, id: number, negate: boolean, matchWords: boolean, caseSensitive: boolean) { + const sql = `note.text ${getSearchMatchOperator(negate, matchWords, caseSensitive)} :q_${id}`; + if (mode === 'and') qb.andWhere(sql); + else if (mode === 'or') qb.orWhere(sql); + query.setParameter(`q_${id}`, escapeSqlSearchParam(term.substring(negate ? 1 : 0), matchWords)); +} + +function getSearchMatchOperator(negate: boolean, matchWords: boolean, caseSensitive: boolean) { + const negatePrefix = matchWords ? '!' : 'NOT '; + return `${negate ? negatePrefix : ''}${matchWords ? caseSensitive ? '~' : '~*' : caseSensitive ? 'LIKE' : 'ILIKE'}`; +} + +function escapeSqlSearchParam(param: string, matchWords: boolean) { + return matchWords ? `\\y${sqlRegexEscape(param)}\\y` : `%${sqlLikeEscape(param)}%`; +} diff --git a/packages/backend/src/server/api/common/generate-list-query.ts b/packages/backend/src/server/api/common/generate-list-query.ts new file mode 100644 index 0000000..47bb709 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-list-query.ts @@ -0,0 +1,24 @@ +import { Brackets, SelectQueryBuilder } from "typeorm"; +import { User } from "@/models/entities/user.js"; +import { UserListJoinings, UserLists } from "@/models/index.js"; + +export function generateListQuery( + q: SelectQueryBuilder, + me: { id: User["id"] }, +): void { + const listQuery = UserLists.createQueryBuilder("list") + .select("list.id") + .where("list.hideFromHomeTl = TRUE") + .andWhere("list.userId = :meId"); + + const memberQuery = UserListJoinings.createQueryBuilder("member") + .select("member.userId") + .where(`member.userListId IN (${listQuery.getQuery()})`) + + q.andWhere(new Brackets((qb) => { + qb.where(`note.userId = :meId`); + qb.orWhere(`note.userId NOT IN (${memberQuery.getQuery()})`); + })); + + q.setParameters({ meId: me.id }); +} diff --git a/packages/backend/src/server/api/common/generate-minor-badge-visibility-query.ts b/packages/backend/src/server/api/common/generate-minor-badge-visibility-query.ts new file mode 100644 index 0000000..dc7a897 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-minor-badge-visibility-query.ts @@ -0,0 +1,49 @@ +import { Brackets, SelectQueryBuilder } from "typeorm"; +import type { User } from "@/models/entities/user.js"; + +type Viewer = Pick & Partial>; + +export function shouldHideEUsersFor(viewer?: Viewer | null): boolean { + return !!viewer && + !viewer.isAdmin && + !viewer.isModerator && + (viewer.minorBadges ?? []).some((badge) => badge === "K" || badge === "T"); +} + +export function generateMinorBadgeUserVisibilityQuery( + q: SelectQueryBuilder, + viewer?: Viewer | null, + alias = "user", +) { + if (!shouldHideEUsersFor(viewer)) return; + + q.andWhere( + new Brackets((qb) => { + qb + .where(`${alias}.id = :minorBadgeViewerId`) + .orWhere(`NOT ('E' = ANY(${alias}."minorBadges"))`); + }), + ); + q.setParameter("minorBadgeViewerId", viewer!.id); +} + +export function generateMinorBadgeNoteVisibilityQuery( + q: SelectQueryBuilder, + viewer?: Viewer | null, + noteAlias = "note", +) { + if (!shouldHideEUsersFor(viewer)) return; + + q.andWhere( + new Brackets((qb) => { + qb + .where(`${noteAlias}."userId" = :minorBadgeViewerId`) + .orWhere( + `${noteAlias}."userId" NOT IN (` + + `SELECT "id" FROM "user" WHERE 'E' = ANY("minorBadges")` + + `)`, + ); + }), + ); + q.setParameter("minorBadgeViewerId", viewer!.id); +} diff --git a/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts b/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts new file mode 100644 index 0000000..61f44f4 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-muted-note-thread-query.ts @@ -0,0 +1,24 @@ +import type { User } from "@/models/entities/user.js"; +import { NoteThreadMutings } from "@/models/index.js"; +import type { SelectQueryBuilder } from "typeorm"; +import { Brackets } from "typeorm"; + +export function generateMutedNoteThreadQuery( + q: SelectQueryBuilder, + me: { id: User["id"] }, +) { + const mutedQuery = NoteThreadMutings.createQueryBuilder("threadMuted") + .select("threadMuted.threadId") + .where("threadMuted.userId = :userId", { userId: me.id }); + + q.andWhere(`note.id NOT IN (${mutedQuery.getQuery()})`); + q.andWhere( + new Brackets((qb) => { + qb.where("note.threadId IS NULL").orWhere( + `note.threadId NOT IN (${mutedQuery.getQuery()})`, + ); + }), + ); + + q.setParameters(mutedQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/common/generate-muted-user-query.ts b/packages/backend/src/server/api/common/generate-muted-user-query.ts new file mode 100644 index 0000000..3538fbf --- /dev/null +++ b/packages/backend/src/server/api/common/generate-muted-user-query.ts @@ -0,0 +1,81 @@ +import type { SelectQueryBuilder } from "typeorm"; +import { Brackets } from "typeorm"; +import type { User } from "@/models/entities/user.js"; +import { Mutings, UserProfiles } from "@/models/index.js"; + +export function generateMutedUserQuery( + q: SelectQueryBuilder, + me: { id: User["id"] }, + exclude?: User, +) { + const mutingQuery = Mutings.createQueryBuilder("muting") + .select("muting.muteeId") + .where("muting.muterId = :muterId", { muterId: me.id }); + + if (exclude) { + mutingQuery.andWhere("muting.muteeId != :excludeId", { + excludeId: exclude.id, + }); + } + + const mutingInstanceQuery = UserProfiles.createQueryBuilder("user_profile") + .select("user_profile.mutedInstances") + .where("user_profile.userId = :muterId", { muterId: me.id }); + + // 投稿の作者をミュートしていない かつ + // 投稿の返信先の作者をミュートしていない かつ + // 投稿の引用元の作者をミュートしていない + q.andWhere(`note.userId NOT IN (${mutingQuery.getQuery()})`) + .andWhere( + new Brackets((qb) => { + qb.where("note.replyUserId IS NULL").orWhere( + `note.replyUserId NOT IN (${mutingQuery.getQuery()})`, + ); + }), + ) + .andWhere( + new Brackets((qb) => { + qb.where("note.renoteUserId IS NULL").orWhere( + `note.renoteUserId NOT IN (${mutingQuery.getQuery()})`, + ); + }), + ) + // mute instances + .andWhere( + new Brackets((qb) => { + qb.andWhere("note.userHost IS NULL").orWhere( + `NOT ((${mutingInstanceQuery.getQuery()})::jsonb ? note.userHost)`, + ); + }), + ) + .andWhere( + new Brackets((qb) => { + qb.where("note.replyUserHost IS NULL").orWhere( + `NOT ((${mutingInstanceQuery.getQuery()})::jsonb ? note.replyUserHost)`, + ); + }), + ) + .andWhere( + new Brackets((qb) => { + qb.where("note.renoteUserHost IS NULL").orWhere( + `NOT ((${mutingInstanceQuery.getQuery()})::jsonb ? note.renoteUserHost)`, + ); + }), + ); + + q.setParameters(mutingQuery.getParameters()); + q.setParameters(mutingInstanceQuery.getParameters()); +} + +export function generateMutedUserQueryForUsers( + q: SelectQueryBuilder, + me: { id: User["id"] }, +) { + const mutingQuery = Mutings.createQueryBuilder("muting") + .select("muting.muteeId") + .where("muting.muterId = :muterId", { muterId: me.id }); + + q.andWhere(`user.id NOT IN (${mutingQuery.getQuery()})`); + + q.setParameters(mutingQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/common/generate-native-user-token.ts b/packages/backend/src/server/api/common/generate-native-user-token.ts new file mode 100644 index 0000000..5531fca --- /dev/null +++ b/packages/backend/src/server/api/common/generate-native-user-token.ts @@ -0,0 +1,3 @@ +import { secureRndstr } from "@/misc/secure-rndstr.js"; + +export default () => secureRndstr(16); diff --git a/packages/backend/src/server/api/common/generate-replies-query.ts b/packages/backend/src/server/api/common/generate-replies-query.ts new file mode 100644 index 0000000..845fef1 --- /dev/null +++ b/packages/backend/src/server/api/common/generate-replies-query.ts @@ -0,0 +1,44 @@ +import type { User } from "@/models/entities/user.js"; +import type { SelectQueryBuilder } from "typeorm"; +import { Brackets } from "typeorm"; + +export function generateRepliesQuery( + q: SelectQueryBuilder, + withReplies: boolean, + me?: Pick | null, +) { + if (me == null) { + q.andWhere( + new Brackets((qb) => { + qb.where("note.replyId IS NULL") // 返信ではない + .orWhere( + new Brackets((qb) => { + qb.where( + // 返信だけど投稿者自身への返信 + "note.replyId IS NOT NULL", + ).andWhere("note.replyUserId = note.userId"); + }), + ); + }), + ); + } else if (!withReplies) { + q.andWhere( + new Brackets((qb) => { + qb.where("note.replyId IS NULL") // 返信ではない + .orWhere("note.replyUserId = :meId", { meId: me.id }) // 返信だけど自分のノートへの返信 + .orWhere( + new Brackets((qb) => { + qb.where("note.replyId IS NOT NULL") // 返信だけど自分の行った返信 + .andWhere("note.userId = :meId", { meId: me.id }); + }), + ) + .orWhere( + new Brackets((qb) => { + qb.where("note.replyId IS NOT NULL") // 返信だけど投稿者自身への返信 + .andWhere("note.replyUserId = note.userId"); + }), + ); + }), + ); + } +} diff --git a/packages/backend/src/server/api/common/generate-visibility-query.ts b/packages/backend/src/server/api/common/generate-visibility-query.ts new file mode 100644 index 0000000..dc3d91c --- /dev/null +++ b/packages/backend/src/server/api/common/generate-visibility-query.ts @@ -0,0 +1,85 @@ +import type { User } from "@/models/entities/user.js"; +import { Followings } from "@/models/index.js"; +import type { SelectQueryBuilder } from "typeorm"; +import { Brackets } from "typeorm"; +import { generateMinorBadgeNoteVisibilityQuery } from "./generate-minor-badge-visibility-query.js"; + +export function generateVisibilityQuery( + q: SelectQueryBuilder, + me?: { id: User["id"] } | null, + options?: { + allowAdservice?: boolean; + }, +) { + // This code must always be synchronized with the checks in Notes.isVisibleForMe. + if (me == null) { + q.andWhere( + new Brackets((qb) => { + qb.where(`note.visibility = 'public'`).orWhere( + `note.visibility = 'home'`, + ); + }), + ) + .andWhere('note.localOnly = FALSE'); + } else { + const followingQuery = Followings.createQueryBuilder("following") + .select("following.followeeId") + .where("following.followerId = :meId"); + + q.andWhere( + new Brackets((qb) => { + qb + // 公開投稿である + .where( + new Brackets((qb) => { + qb.where(`note.visibility = 'public'`).orWhere( + `note.visibility = 'home'`, + ); + }), + ) + // または 自分自身 + .orWhere("note.userId = :meId") + // または 自分宛て + .orWhere(":meId = ANY(note.visibleUserIds)") + .orWhere(":meId = ANY(note.mentions)") + .orWhere( + new Brackets((qb) => { + qb + // または フォロワー宛ての投稿であり、 + .where(`note.visibility = 'followers'`) + .andWhere( + new Brackets((qb) => { + qb + // 自分がフォロワーである + .where(`note.userId IN (${followingQuery.getQuery()})`) + // または 自分の投稿へのリプライ + .orWhere("note.replyUserId = :meId"); + }), + ); + }), + ); + }), + ); + + q.andWhere(new Brackets((qb) => { + qb.where(`note.visibility != 'hidden'`).orWhere( + `note.userId = :meId`, + ); + })); + + q.setParameters({ meId: me.id }); + } + + if (!options?.allowAdservice) { + q.andWhere( + new Brackets((qb) => { + qb.where(`NOT ('adservice' = ANY(note.tags))`); + if (me) { + qb.orWhere("note.userId = :meId"); + } + }), + ); + } + + generateMinorBadgeNoteVisibilityQuery(q, me); +} diff --git a/packages/backend/src/server/api/common/generated-muted-renote-query.ts b/packages/backend/src/server/api/common/generated-muted-renote-query.ts new file mode 100644 index 0000000..3fcd9b2 --- /dev/null +++ b/packages/backend/src/server/api/common/generated-muted-renote-query.ts @@ -0,0 +1,28 @@ +import { Brackets, SelectQueryBuilder } from "typeorm"; +import { User } from "@/models/entities/user.js"; +import { RenoteMutings } from "@/models/index.js"; + +export function generateMutedUserRenotesQueryForNotes( + q: SelectQueryBuilder, + me: { id: User["id"] }, +): void { + const mutingQuery = RenoteMutings.createQueryBuilder("renote_muting") + .select("renote_muting.muteeId") + .where("renote_muting.muterId = :muterId", { muterId: me.id }); + + q.andWhere( + new Brackets((qb) => { + qb.where( + new Brackets((qb) => { + qb.where("note.renoteId IS NOT NULL"); + qb.andWhere("note.text IS NULL"); + qb.andWhere(`note.userId NOT IN (${mutingQuery.getQuery()})`); + }), + ) + .orWhere("note.renoteId IS NULL") + .orWhere("note.text IS NOT NULL"); + }), + ); + + q.setParameters(mutingQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/common/get-group-actor.ts b/packages/backend/src/server/api/common/get-group-actor.ts new file mode 100644 index 0000000..42ce2b6 --- /dev/null +++ b/packages/backend/src/server/api/common/get-group-actor.ts @@ -0,0 +1,22 @@ +import { UserGroupJoinings, UserGroups } from "@/models/index.js"; +import type { User } from "@/models/entities/user.js"; +import type { UserGroup } from "@/models/entities/user-group.js"; + +export async function getGroupActor( + groupId: UserGroup["id"] | null | undefined, + user: { id: User["id"] }, +): Promise { + if (groupId == null) return null; + + const group = await UserGroups.findOneBy({ id: groupId }); + if (group == null) return null; + + if (group.userId === user.id) return group; + + const joining = await UserGroupJoinings.findOneBy({ + userId: user.id, + userGroupId: group.id, + }); + + return joining == null ? null : group; +} diff --git a/packages/backend/src/server/api/common/getters.ts b/packages/backend/src/server/api/common/getters.ts new file mode 100644 index 0000000..3daff9d --- /dev/null +++ b/packages/backend/src/server/api/common/getters.ts @@ -0,0 +1,75 @@ +import { IdentifiableError } from "@/misc/identifiable-error.js"; +import type { User } from "@/models/entities/user.js"; +import type { Note } from "@/models/entities/note.js"; +import { Notes, Users } from "@/models/index.js"; +import { generateVisibilityQuery } from "./generate-visibility-query.js"; + +/** + * Get note for API processing, taking into account visibility. + */ +export async function getNote( + noteId: Note["id"], + me: { id: User["id"] } | null, + options?: { + allowAdservice?: boolean; + }, +) { + const query = Notes.createQueryBuilder("note").where("note.id = :id", { + id: noteId, + }); + + generateVisibilityQuery(query, me, options); + + const note = await query.getOne(); + + if (note == null || (me == null && note.localOnly)) { + throw new IdentifiableError( + "9725d0ce-ba28-4dde-95a7-2cbb2c15de24", + "No such note.", + ); + } + + return note; +} + +/** + * Get user for API processing + */ +export async function getUser(userId: User["id"]) { + const user = await Users.findOneBy({ id: userId }); + + if (user == null) { + throw new IdentifiableError( + "15348ddd-432d-49c2-8a5a-8069753becff", + "No such user.", + ); + } + + return user; +} + +/** + * Get remote user for API processing + */ +export async function getRemoteUser(userId: User["id"]) { + const user = await getUser(userId); + + if (!Users.isRemoteUser(user)) { + throw new Error("user is not a remote user"); + } + + return user; +} + +/** + * Get local user for API processing + */ +export async function getLocalUser(userId: User["id"]) { + const user = await getUser(userId); + + if (!Users.isLocalUser(user)) { + throw new Error("user is not a local user"); + } + + return user; +} diff --git a/packages/backend/src/server/api/common/inject-featured.ts b/packages/backend/src/server/api/common/inject-featured.ts new file mode 100644 index 0000000..30ba3ec --- /dev/null +++ b/packages/backend/src/server/api/common/inject-featured.ts @@ -0,0 +1,53 @@ +import rndstr from "rndstr"; +import type { Note } from "@/models/entities/note.js"; +import type { User } from "@/models/entities/user.js"; +import { Notes, UserProfiles, NoteReactions } from "@/models/index.js"; +import { generateMutedUserQuery } from "./generate-muted-user-query.js"; +import { generateBlockedUserQuery } from "./generate-block-query.js"; + +// TODO: リアクション、Renote、返信などをしたノートは除外する + +export async function injectFeatured(timeline: Note[], user?: User | null) { + if (timeline.length < 5) return; + + if (user) { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + if (!profile.injectFeaturedNote) return; + } + + const max = 30; + const day = 1000 * 60 * 60 * 24 * 3; // 3日前まで + + const query = Notes.createQueryBuilder("note") + .addSelect("note.score") + .where("note.userHost IS NULL") + .andWhere("note.score > 0") + .andWhere("note.createdAt > :date", { date: new Date(Date.now() - day) }) + .andWhere(`note.visibility = 'public'`) + .innerJoinAndSelect("note.user", "user"); + + if (user) { + query.andWhere("note.userId != :userId", { userId: user.id }); + + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + + const reactionQuery = NoteReactions.createQueryBuilder("reaction") + .select("reaction.noteId") + .where("reaction.userId = :userId", { userId: user.id }); + + query.andWhere(`note.id NOT IN (${reactionQuery.getQuery()})`); + } + + const notes = await query.orderBy("note.score", "DESC").take(max).getMany(); + + if (notes.length === 0) return; + + // Pick random one + const featured = notes[Math.floor(Math.random() * notes.length)]; + + (featured as any)._featuredId_ = rndstr("a-z0-9", 8); + + // Inject featured + timeline.splice(3, 0, featured); +} diff --git a/packages/backend/src/server/api/common/inject-promo.ts b/packages/backend/src/server/api/common/inject-promo.ts new file mode 100644 index 0000000..e6282c7 --- /dev/null +++ b/packages/backend/src/server/api/common/inject-promo.ts @@ -0,0 +1,70 @@ +import rndstr from "rndstr"; +import type { Note } from "@/models/entities/note.js"; +import type { User } from "@/models/entities/user.js"; +import { PromoReads, PromoNotes, Notes, Users } from "@/models/index.js"; +import { shouldHideEUsersFor } from "./generate-minor-badge-visibility-query.js"; +import { readPromo } from "./read-promo.js"; + +const systemTags = new Set([ + "adservice", + "videoservice", + "audioservice", + "imageservice", + "karaokeservice", + "lua4frozen", +]); + +function isExplicitAd(note: Note): boolean { + return note.tags.includes("explicit") || note.user?.minorBadges?.includes("E") === true; +} + +export async function injectPromo(timeline: Note[], user?: User | null, preferredTag?: string | null) { + // TODO: readやexpireフィルタはクエリ側でやる + + const readDay = new Date().toISOString().slice(0, 10); + const reads = user + ? await PromoReads.findBy({ + userId: user.id, + readDay, + }) + : []; + + let promos = await PromoNotes.find(); + + promos = promos.filter((n) => n.expiresAt.getTime() > Date.now()); + promos = promos.filter((n) => n.remainingCredits > 0); + promos = promos.filter((n) => !reads.map((r) => r.noteId).includes(n.noteId)); + + if (promos.length === 0) return; + + const candidates: Note[] = []; + for (const promo of promos) { + const note = await Notes.findOneBy({ id: promo.noteId }); + if (!note?.tags.includes("adservice")) continue; + + note.user = await Users.findOneByOrFail({ id: note.userId }); + if (user && shouldHideEUsersFor(user) && isExplicitAd(note)) continue; + candidates.push(note); + } + + if (candidates.length === 0) return; + + const normalizedPreferredTag = preferredTag?.trim().toLowerCase().replace(/^#/, ""); + const priority = normalizedPreferredTag && !systemTags.has(normalizedPreferredTag) + ? candidates.filter((note) => note.tags.includes(normalizedPreferredTag)) + : []; + const pool = priority.length > 0 ? priority : candidates; + + // Pick random promo + const note = pool[Math.floor(Math.random() * pool.length)]; + const promo = promos.find((promo) => promo.noteId === note.id); + + (note as any)._prId_ = rndstr("a-z0-9", 8); + + if (user && promo) { + await readPromo(note, promo, user); + } + + // Inject promo + timeline.splice(Math.min(3, timeline.length), 0, note); +} diff --git a/packages/backend/src/server/api/common/is-native-token.ts b/packages/backend/src/server/api/common/is-native-token.ts new file mode 100644 index 0000000..2833c57 --- /dev/null +++ b/packages/backend/src/server/api/common/is-native-token.ts @@ -0,0 +1 @@ +export default (token: string) => token.length === 16; diff --git a/packages/backend/src/server/api/common/make-pagination-query.ts b/packages/backend/src/server/api/common/make-pagination-query.ts new file mode 100644 index 0000000..a2c3275 --- /dev/null +++ b/packages/backend/src/server/api/common/make-pagination-query.ts @@ -0,0 +1,42 @@ +import type { SelectQueryBuilder } from "typeorm"; + +export function makePaginationQuery( + q: SelectQueryBuilder, + sinceId?: string, + untilId?: string, + sinceDate?: number, + untilDate?: number, +) { + if (sinceId && untilId) { + q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); + q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); + q.orderBy(`${q.alias}.id`, "DESC"); + } else if (sinceId) { + q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId }); + q.orderBy(`${q.alias}.id`, "ASC"); + } else if (untilId) { + q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId }); + q.orderBy(`${q.alias}.id`, "DESC"); + } else if (sinceDate && untilDate) { + q.andWhere(`${q.alias}.createdAt > :sinceDate`, { + sinceDate: new Date(sinceDate), + }); + q.andWhere(`${q.alias}.createdAt < :untilDate`, { + untilDate: new Date(untilDate), + }); + q.orderBy(`${q.alias}.createdAt`, "DESC"); + } else if (sinceDate) { + q.andWhere(`${q.alias}.createdAt > :sinceDate`, { + sinceDate: new Date(sinceDate), + }); + q.orderBy(`${q.alias}.createdAt`, "ASC"); + } else if (untilDate) { + q.andWhere(`${q.alias}.createdAt < :untilDate`, { + untilDate: new Date(untilDate), + }); + q.orderBy(`${q.alias}.createdAt`, "DESC"); + } else { + q.orderBy(`${q.alias}.id`, "DESC"); + } + return q; +} diff --git a/packages/backend/src/server/api/common/read-messaging-message.ts b/packages/backend/src/server/api/common/read-messaging-message.ts new file mode 100644 index 0000000..fc22c84 --- /dev/null +++ b/packages/backend/src/server/api/common/read-messaging-message.ts @@ -0,0 +1,179 @@ +import { + publishMainStream, + publishGroupMessagingStream, +} from "@/services/stream.js"; +import { publishMessagingStream } from "@/services/stream.js"; +import { publishMessagingIndexStream } from "@/services/stream.js"; +import { pushNotification } from "@/services/push-notification.js"; +import type { User, IRemoteUser } from "@/models/entities/user.js"; +import type { MessagingMessage } from "@/models/entities/messaging-message.js"; +import { MessagingMessages, UserGroupJoinings, Users } from "@/models/index.js"; +import { In } from "typeorm"; +import { IdentifiableError } from "@/misc/identifiable-error.js"; +import type { UserGroup } from "@/models/entities/user-group.js"; +import { toArray } from "@/prelude/array.js"; +import { renderReadActivity } from "@/remote/activitypub/renderer/read.js"; +import { renderActivity } from "@/remote/activitypub/renderer/index.js"; +import { deliver } from "@/queue/index.js"; +import orderedCollection from "@/remote/activitypub/renderer/ordered-collection.js"; + +/** + * Mark messages as read + */ +export async function readUserMessagingMessage( + userId: User["id"], + otherpartyId: User["id"], + messageIds: MessagingMessage["id"][], +) { + if (messageIds.length === 0) return; + + const messages = await MessagingMessages.findBy({ + id: In(messageIds), + }); + + for (const message of messages) { + if (message.recipientId !== userId) { + throw new IdentifiableError( + "e140a4bf-49ce-4fb6-b67c-b78dadf6b52f", + "Access denied (user).", + ); + } + } + + // Update documents + await MessagingMessages.update( + { + id: In(messageIds), + userId: otherpartyId, + recipientId: userId, + isRead: false, + }, + { + isRead: true, + }, + ); + + // Publish event + publishMessagingStream(otherpartyId, userId, "read", messageIds); + publishMessagingIndexStream(userId, "read", messageIds); + + if (!(await Users.getHasUnreadMessagingMessage(userId))) { + // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 + publishMainStream(userId, "readAllMessagingMessages"); + pushNotification(userId, "readAllMessagingMessages", undefined); + } else { + // そのユーザーとのメッセージで未読がなければイベント発行 + const count = await MessagingMessages.count({ + where: { + userId: otherpartyId, + recipientId: userId, + isRead: false, + }, + take: 1, + }); + + if (!count) { + pushNotification(userId, "readAllMessagingMessagesOfARoom", { + userId: otherpartyId, + }); + } + } +} + +/** + * Mark messages as read + */ +export async function readGroupMessagingMessage( + userId: User["id"], + groupId: UserGroup["id"], + messageIds: MessagingMessage["id"][], +) { + if (messageIds.length === 0) return; + + // check joined + const joining = await UserGroupJoinings.findOneBy({ + userId: userId, + userGroupId: groupId, + }); + + if (joining == null) { + throw new IdentifiableError( + "930a270c-714a-46b2-b776-ad27276dc569", + "Access denied (group).", + ); + } + + const messages = await MessagingMessages.findBy({ + id: In(messageIds), + }); + + const reads: MessagingMessage["id"][] = []; + + for (const message of messages) { + if (message.userId === userId) continue; + if (message.reads.includes(userId)) continue; + + // Update document + await MessagingMessages.createQueryBuilder() + .update() + .set({ + reads: (() => `array_append("reads", '${joining.userId}')`) as any, + }) + .where("id = :id", { id: message.id }) + .execute(); + + reads.push(message.id); + } + + // Publish event + publishGroupMessagingStream(groupId, "read", { + ids: reads, + userId: userId, + }); + publishMessagingIndexStream(userId, "read", reads); + + if (!(await Users.getHasUnreadMessagingMessage(userId))) { + // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 + publishMainStream(userId, "readAllMessagingMessages"); + pushNotification(userId, "readAllMessagingMessages", undefined); + } else { + // そのグループにおいて未読がなければイベント発行 + const unreadExist = await MessagingMessages.createQueryBuilder("message") + .where("message.groupId = :groupId", { groupId: groupId }) + .andWhere("message.userId != :userId", { userId: userId }) + .andWhere("NOT (:userId = ANY(message.reads))", { userId: userId }) + .andWhere("message.createdAt > :joinedAt", { + joinedAt: joining.createdAt, + }) // 自分が加入する前の会話については、未読扱いしない + .getOne() + .then((x) => x != null); + + if (!unreadExist) { + pushNotification(userId, "readAllMessagingMessagesOfARoom", { groupId }); + } + } +} + +export async function deliverReadActivity( + user: { id: User["id"]; host: null }, + recipient: IRemoteUser, + messages: MessagingMessage | MessagingMessage[], +) { + messages = toArray(messages).filter((x) => x.uri); + const contents = messages.map((x) => renderReadActivity(user, x)); + + if (contents.length > 1) { + const collection = orderedCollection( + null, + contents.length, + undefined, + undefined, + contents, + ); + deliver(user, renderActivity(collection), recipient.inbox); + } else { + for (const content of contents) { + deliver(user, renderActivity(content), recipient.inbox); + } + } +} diff --git a/packages/backend/src/server/api/common/read-notification.ts b/packages/backend/src/server/api/common/read-notification.ts new file mode 100644 index 0000000..1fb1d64 --- /dev/null +++ b/packages/backend/src/server/api/common/read-notification.ts @@ -0,0 +1,59 @@ +import { In } from "typeorm"; +import { publishMainStream } from "@/services/stream.js"; +import { pushNotification } from "@/services/push-notification.js"; +import type { User } from "@/models/entities/user.js"; +import type { Notification } from "@/models/entities/notification.js"; +import { Notifications, Users } from "@/models/index.js"; + +export async function readNotification( + userId: User["id"], + notificationIds: Notification["id"][], +) { + if (notificationIds.length === 0) return; + + // Update documents + const result = await Notifications.update( + { + notifieeId: userId, + id: In(notificationIds), + isRead: false, + }, + { + isRead: true, + }, + ); + + if (result.affected === 0) return; + + if (!(await Users.getHasUnreadNotification(userId))) + return postReadAllNotifications(userId); + else return postReadNotifications(userId, notificationIds); +} + +export async function readNotificationByQuery( + userId: User["id"], + query: Record, +) { + const notificationIds = await Notifications.findBy({ + ...query, + notifieeId: userId, + isRead: false, + }).then((notifications) => + notifications.map((notification) => notification.id), + ); + + return readNotification(userId, notificationIds); +} + +function postReadAllNotifications(userId: User["id"]) { + publishMainStream(userId, "readAllNotifications"); + return pushNotification(userId, "readAllNotifications", undefined); +} + +function postReadNotifications( + userId: User["id"], + notificationIds: Notification["id"][], +) { + publishMainStream(userId, "readNotifications", notificationIds); + return pushNotification(userId, "readNotifications", { notificationIds }); +} diff --git a/packages/backend/src/server/api/common/read-promo.ts b/packages/backend/src/server/api/common/read-promo.ts new file mode 100644 index 0000000..be74feb --- /dev/null +++ b/packages/backend/src/server/api/common/read-promo.ts @@ -0,0 +1,50 @@ +import { PromoNotes, PromoReads } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import type { Note } from "@/models/entities/note.js"; +import type { PromoNote } from "@/models/entities/promo-note.js"; +import type { User } from "@/models/entities/user.js"; + +export async function readPromo( + note: Pick, + promo: Pick, + user: User, +) { + if ( + promo.expiresAt.getTime() <= Date.now() || + promo.remainingCredits <= 0 || + promo.userId === user.id || + note.userId === user.id || + user.isAdmin || + user.isModerator || + user.isBot + ) { + return; + } + + const readDay = new Date().toISOString().slice(0, 10); + const result = await PromoReads.createQueryBuilder() + .insert() + .values({ + id: genId(), + createdAt: new Date(), + noteId: note.id, + userId: user.id, + readDay, + }) + .orIgnore() + .returning("id") + .execute(); + + if (result.raw.length === 0) { + return; + } + + await PromoNotes.createQueryBuilder() + .update() + .set({ + remainingCredits: () => `"remainingCredits" - 1`, + }) + .where(`"noteId" = :noteId`, { noteId: note.id }) + .andWhere(`"remainingCredits" > 0`) + .execute(); +} diff --git a/packages/backend/src/server/api/common/signin.ts b/packages/backend/src/server/api/common/signin.ts new file mode 100644 index 0000000..a8a4358 --- /dev/null +++ b/packages/backend/src/server/api/common/signin.ts @@ -0,0 +1,44 @@ +import type Koa from "koa"; + +import config from "@/config/index.js"; +import type { ILocalUser } from "@/models/entities/user.js"; +import { Signins } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { publishMainStream } from "@/services/stream.js"; + +export default function (ctx: Koa.Context, user: ILocalUser, redirect = false) { + if (redirect) { + //#region Cookie + ctx.cookies.set("igi", user.token!, { + path: "/", + // SEE: https://github.com/koajs/koa/issues/974 + // When using a SSL proxy it should be configured to add the "X-Forwarded-Proto: https" header + secure: config.url.startsWith("https"), + httpOnly: false, + }); + //#endregion + + ctx.redirect(config.url); + } else { + ctx.body = { + id: user.id, + i: user.token, + }; + ctx.status = 200; + } + + (async () => { + // Append signin history + const record = await Signins.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + ip: ctx.ip, + headers: ctx.headers, + success: true, + }).then((x) => Signins.findOneByOrFail(x.identifiers[0])); + + // Publish signin event + publishMainStream(user.id, "signin", await Signins.pack(record)); + })(); +} diff --git a/packages/backend/src/server/api/common/signup.ts b/packages/backend/src/server/api/common/signup.ts new file mode 100644 index 0000000..51fd14f --- /dev/null +++ b/packages/backend/src/server/api/common/signup.ts @@ -0,0 +1,155 @@ +import { generateKeyPair } from "node:crypto"; +import generateUserToken from "./generate-native-user-token.js"; +import { User } from "@/models/entities/user.js"; +import { Users, UsedUsernames } from "@/models/index.js"; +import { UserProfile } from "@/models/entities/user-profile.js"; +import { IsNull } from "typeorm"; +import { genId } from "@/misc/gen-id.js"; +import { toPunyNullable } from "@/misc/convert-host.js"; +import { UserKeypair } from "@/models/entities/user-keypair.js"; +import { usersChart } from "@/services/chart/index.js"; +import { UsedUsername } from "@/models/entities/used-username.js"; +import { db } from "@/db/postgre.js"; +import config from "@/config/index.js"; +import { hashPassword } from "@/misc/password.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import follow from "@/services/following/create.js"; + +export async function signup(opts: { + username: User["username"]; + password?: string | null; + passwordHash?: UserProfile["password"] | null; + host?: string | null; +}) { + const { username, password, passwordHash, host } = opts; + let hash = passwordHash; + + const userCount = await Users.countBy({ + host: IsNull(), + }); + + if (config.maxUserSignups != null && userCount > config.maxUserSignups) { + throw new Error("MAX_USERS_REACHED"); + } + + // Validate username + if (!Users.validateLocalUsername(username)) { + throw new Error("INVALID_USERNAME"); + } + + if (password != null && passwordHash == null) { + // Validate password + if (!Users.validatePassword(password)) { + throw new Error("INVALID_PASSWORD"); + } + + // Generate hash of password + hash = await hashPassword(password); + } + + // Generate secret + const secret = generateUserToken(); + + // Check username duplication + if ( + await Users.findOneBy({ + usernameLower: username.toLowerCase(), + host: IsNull(), + }) + ) { + throw new Error("DUPLICATED_USERNAME"); + } + + // Check deleted username duplication + if (await UsedUsernames.findOneBy({ username: username.toLowerCase() })) { + throw new Error("USED_USERNAME"); + } + + const keyPair = await new Promise((res, rej) => + generateKeyPair( + "rsa", + { + modulusLength: 4096, + publicKeyEncoding: { + type: "spki", + format: "pem", + }, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + cipher: undefined, + passphrase: undefined, + }, + } as any, + (err, publicKey, privateKey) => + err ? rej(err) : res([publicKey, privateKey]), + ), + ); + + const exist = await Users.findOneBy({ + usernameLower: username.toLowerCase(), + host: IsNull(), + }); + + if (exist) throw new Error("The username is already in use"); + + // Prepare objects + const user = new User({ + id: genId(), + createdAt: new Date(), + username: username, + usernameLower: username.toLowerCase(), + host: toPunyNullable(host), + token: secret, + isAdmin: + (await Users.countBy({ + host: IsNull(), + isAdmin: true, + })) === 0, + }); + + const userKeypair = new UserKeypair({ + publicKey: keyPair[0], + privateKey: keyPair[1], + userId: user.id, + }); + + const userProfile = new UserProfile({ + userId: user.id, + autoAcceptFollowed: true, + allowCalls: false, + password: hash, + }); + + const usedUsername = new UsedUsername({ + createdAt: new Date(), + username: username.toLowerCase(), + }); + + // Save the objects atomically using a db transaction, note that we should never run any code in a transaction block directly + await db.transaction(async (transactionalEntityManager) => { + await transactionalEntityManager.save(user); + await transactionalEntityManager.save(userKeypair); + await transactionalEntityManager.save(userProfile); + await transactionalEntityManager.save(usedUsername); + }); + + const account = await Users.findOneByOrFail({ id: user.id }); + + const meta = await fetchMeta(); + + // If an autofollow account exists, follow it + if (meta.autofollowedAccount) { + const autofollowedAccount = await Users.findOneByOrFail({ + usernameLower: meta.autofollowedAccount.toLowerCase(), + host: IsNull(), + }); + + if (autofollowedAccount) { + await follow(account, autofollowedAccount) + } + } + + usersChart.update(account, true); + return { account, secret }; +} diff --git a/packages/backend/src/server/api/compatibility.ts b/packages/backend/src/server/api/compatibility.ts new file mode 100644 index 0000000..624e5ff --- /dev/null +++ b/packages/backend/src/server/api/compatibility.ts @@ -0,0 +1,20 @@ +import type { IEndpoint } from "./endpoints"; + +import * as cp___custom_emojis from "./endpoints/compatibility/custom-emojis.js"; +import * as ep___instance_peers from "./endpoints/compatibility/peers.js"; + +const cps = [ + ["v1/custom_emojis", cp___custom_emojis], + ["v1/instance/peers", ep___instance_peers], +]; + +const compatibility: IEndpoint[] = cps.map(([name, cp]) => { + return { + name: name, + exec: cp.default, + meta: cp.meta || {}, + params: cp.paramDef, + } as IEndpoint; +}); + +export default compatibility; diff --git a/packages/backend/src/server/api/define.ts b/packages/backend/src/server/api/define.ts new file mode 100644 index 0000000..ee08441 --- /dev/null +++ b/packages/backend/src/server/api/define.ts @@ -0,0 +1,105 @@ +import * as fs from "node:fs"; +import Ajv from "ajv"; +import type { CacheableLocalUser } from "@/models/entities/user.js"; +import { ILocalUser } from "@/models/entities/user.js"; +import type { Schema, SchemaType } from "@/misc/schema.js"; +import type { AccessToken } from "@/models/entities/access-token.js"; +import type { IEndpointMeta } from "./endpoints.js"; +import { ApiError } from "./error.js"; + +export type Response = Record | void; + +// TODO: paramsの型をT['params']のスキーマ定義から推論する +type executor = ( + params: SchemaType, + user: T["requireCredential"] extends true + ? CacheableLocalUser + : CacheableLocalUser | null, + token: AccessToken | null, + file?: any, + cleanup?: () => any, + ip?: string | null, + headers?: Record | null, +) => Promise< + T["res"] extends undefined ? Response : SchemaType> +>; + +const ajv = new Ajv({ + useDefaults: true, +}); + +ajv.addFormat("misskey:id", /^[a-zA-Z0-9]+$/); + +export default function ( + meta: T, + paramDef: Ps, + cb: executor, +): ( + params: any, + user: T["requireCredential"] extends true + ? CacheableLocalUser + : CacheableLocalUser | null, + token: AccessToken | null, + file?: any, + ip?: string | null, + headers?: Record | null, +) => Promise { + const validate = ajv.compile(paramDef); + + return ( + params: any, + user: T["requireCredential"] extends true + ? CacheableLocalUser + : CacheableLocalUser | null, + token: AccessToken | null, + file?: any, + ip?: string | null, + headers?: Record | null, + ) => { + let cleanup: undefined | (() => void) = undefined; + + if (meta.requireFile) { + cleanup = () => { + fs.unlink(file.path, () => {}); + }; + + if (file == null) + return Promise.reject( + new ApiError({ + message: "File required.", + code: "FILE_REQUIRED", + id: "4267801e-70d1-416a-b011-4ee502885d8b", + }), + ); + } + + const valid = validate(params); + if (!valid) { + if (file) cleanup!(); + + const errors = validate.errors!; + const err = new ApiError( + { + message: "Invalid param.", + code: "INVALID_PARAM", + id: "3d81ceae-475f-4600-b2a8-2bc116157532", + }, + { + param: errors[0].schemaPath, + reason: errors[0].message, + }, + ); + return Promise.reject(err); + } + + return cb( + params as SchemaType, + user, + token, + file, + cleanup, + ip, + headers, + ); + }; +} diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts new file mode 100644 index 0000000..187ac0f --- /dev/null +++ b/packages/backend/src/server/api/endpoints.ts @@ -0,0 +1,905 @@ +import type { Schema } from "@/misc/schema.js"; + +import * as ep___admin_meta from "./endpoints/admin/meta.js"; +import * as ep___admin_abuseUserReports from "./endpoints/admin/abuse-user-reports.js"; +import * as ep___admin_accounts_create from "./endpoints/admin/accounts/create.js"; +import * as ep___admin_accounts_delete from "./endpoints/admin/accounts/delete.js"; +import * as ep___admin_accounts_hosted from "./endpoints/admin/accounts/hosted.js"; +import * as ep___admin_announcements_create from "./endpoints/admin/announcements/create.js"; +import * as ep___admin_announcements_delete from "./endpoints/admin/announcements/delete.js"; +import * as ep___admin_announcements_list from "./endpoints/admin/announcements/list.js"; +import * as ep___admin_announcements_update from "./endpoints/admin/announcements/update.js"; +import * as ep___admin_createBackup from "./endpoints/admin/create-backup.js"; +import * as ep___admin_deleteAllFilesOfAUser from "./endpoints/admin/delete-all-files-of-a-user.js"; +import * as ep___admin_drive_cleanRemoteFiles from "./endpoints/admin/drive/clean-remote-files.js"; +import * as ep___admin_drive_cleanup from "./endpoints/admin/drive/cleanup.js"; +import * as ep___admin_drive_files from "./endpoints/admin/drive/files.js"; +import * as ep___admin_drive_showFile from "./endpoints/admin/drive/show-file.js"; +import * as ep___admin_emoji_addAliasesBulk from "./endpoints/admin/emoji/add-aliases-bulk.js"; +import * as ep___admin_emoji_add from "./endpoints/admin/emoji/add.js"; +import * as ep___admin_emoji_copy from "./endpoints/admin/emoji/copy.js"; +import * as ep___admin_emoji_deleteBulk from "./endpoints/admin/emoji/delete-bulk.js"; +import * as ep___admin_emoji_delete from "./endpoints/admin/emoji/delete.js"; +import * as ep___admin_emoji_importZip from "./endpoints/admin/emoji/import-zip.js"; +import * as ep___admin_emoji_listRemote from "./endpoints/admin/emoji/list-remote.js"; +import * as ep___admin_emoji_list from "./endpoints/admin/emoji/list.js"; +import * as ep___admin_emoji_removeAliasesBulk from "./endpoints/admin/emoji/remove-aliases-bulk.js"; +import * as ep___admin_emoji_setAliasesBulk from "./endpoints/admin/emoji/set-aliases-bulk.js"; +import * as ep___admin_emoji_setCategoryBulk from "./endpoints/admin/emoji/set-category-bulk.js"; +import * as ep___admin_emoji_setLicenseBulk from "./endpoints/admin/emoji/set-license-bulk.js"; +import * as ep___admin_emoji_update from "./endpoints/admin/emoji/update.js"; +import * as ep___admin_federation_deleteAllFiles from "./endpoints/admin/federation/delete-all-files.js"; +import * as ep___admin_federation_refreshRemoteInstanceMetadata from "./endpoints/admin/federation/refresh-remote-instance-metadata.js"; +import * as ep___admin_federation_removeAllFollowing from "./endpoints/admin/federation/remove-all-following.js"; +import * as ep___admin_federation_updateInstance from "./endpoints/admin/federation/update-instance.js"; +import * as ep___admin_getIndexStats from "./endpoints/admin/get-index-stats.js"; +import * as ep___admin_getTableStats from "./endpoints/admin/get-table-stats.js"; +import * as ep___admin_getUserIps from "./endpoints/admin/get-user-ips.js"; +import * as ep___admin_invite from "./endpoints/admin/invite.js"; +import * as ep___admin_moderators_add from "./endpoints/admin/moderators/add.js"; +import * as ep___admin_moderators_remove from "./endpoints/admin/moderators/remove.js"; +import * as ep___admin_plans_create from "./endpoints/admin/plans/create.js"; +import * as ep___admin_plans_delete from "./endpoints/admin/plans/delete.js"; +import * as ep___admin_plans_list from "./endpoints/admin/plans/list.js"; +import * as ep___admin_plans_update from "./endpoints/admin/plans/update.js"; +import * as ep___admin_promo_create from "./endpoints/admin/promo/create.js"; +import * as ep___admin_promo_list from "./endpoints/admin/promo/list.js"; +import * as ep___admin_queue_clear from "./endpoints/admin/queue/clear.js"; +import * as ep___admin_queue_deliverDelayed from "./endpoints/admin/queue/deliver-delayed.js"; +import * as ep___admin_queue_inboxDelayed from "./endpoints/admin/queue/inbox-delayed.js"; +import * as ep___admin_queue_stats from "./endpoints/admin/queue/stats.js"; +import * as ep___admin_relays_add from "./endpoints/admin/relays/add.js"; +import * as ep___admin_relays_list from "./endpoints/admin/relays/list.js"; +import * as ep___admin_relays_remove from "./endpoints/admin/relays/remove.js"; +import * as ep___admin_resetPassword from "./endpoints/admin/reset-password.js"; +import * as ep___admin_resolveAbuseUserReport from "./endpoints/admin/resolve-abuse-user-report.js"; +import * as ep___admin_sendEmail from "./endpoints/admin/send-email.js"; +import * as ep___admin_sendModMail from "./endpoints/admin/send-mod-mail.js"; +import * as ep___admin_serverInfo from "./endpoints/admin/server-info.js"; +import * as ep___admin_setUserVerified from "./endpoints/admin/set-user-verified.js"; +import * as ep___admin_showModerationLogs from "./endpoints/admin/show-moderation-logs.js"; +import * as ep___admin_showUser from "./endpoints/admin/show-user.js"; +import * as ep___admin_showUsers from "./endpoints/admin/show-users.js"; +import * as ep___admin_silenceUser from "./endpoints/admin/silence-user.js"; +import * as ep___admin_suspendUser from "./endpoints/admin/suspend-user.js"; +import * as ep___admin_unsilenceUser from "./endpoints/admin/unsilence-user.js"; +import * as ep___admin_unsuspendUser from "./endpoints/admin/unsuspend-user.js"; +import * as ep___admin_updateMeta from "./endpoints/admin/update-meta.js"; +import * as ep___admin_vacuum from "./endpoints/admin/vacuum.js"; +import * as ep___admin_userPlans_add from "./endpoints/admin/user-plans/add.js"; +import * as ep___admin_userPlans_list from "./endpoints/admin/user-plans/list.js"; +import * as ep___admin_userPlans_remove from "./endpoints/admin/user-plans/remove.js"; +import * as ep___admin_verifiedBadgeRequests from "./endpoints/admin/verified-badge-requests.js"; +import * as ep___admin_resolveVerifiedBadgeRequest from "./endpoints/admin/resolve-verified-badge-request.js"; +import * as ep___admin_deleteAccount from "./endpoints/admin/delete-account.js"; +import * as ep___admin_updateUserNote from "./endpoints/admin/update-user-note.js"; +import * as ep___announcements from "./endpoints/announcements.js"; +import * as ep___antennas_create from "./endpoints/antennas/create.js"; +import * as ep___antennas_delete from "./endpoints/antennas/delete.js"; +import * as ep___antennas_list from "./endpoints/antennas/list.js"; +import * as ep___antennas_markRead from "./endpoints/antennas/markread.js"; +import * as ep___antennas_notes from "./endpoints/antennas/notes.js"; +import * as ep___antennas_show from "./endpoints/antennas/show.js"; +import * as ep___antennas_update from "./endpoints/antennas/update.js"; +import * as ep___ap_get from "./endpoints/ap/get.js"; +import * as ep___ap_show from "./endpoints/ap/show.js"; +import * as ep___app_create from "./endpoints/app/create.js"; +import * as ep___app_show from "./endpoints/app/show.js"; +import * as ep___auth_accept from "./endpoints/auth/accept.js"; +import * as ep___auth_session_generate from "./endpoints/auth/session/generate.js"; +import * as ep___auth_session_show from "./endpoints/auth/session/show.js"; +import * as ep___auth_session_userkey from "./endpoints/auth/session/userkey.js"; +import * as ep___blocking_create from "./endpoints/blocking/create.js"; +import * as ep___blocking_delete from "./endpoints/blocking/delete.js"; +import * as ep___blocking_list from "./endpoints/blocking/list.js"; +import * as ep___call_blocking_create from "./endpoints/call-blocking/create.js"; +import * as ep___call_blocking_delete from "./endpoints/call-blocking/delete.js"; +import * as ep___call_blocking_list from "./endpoints/call-blocking/list.js"; +import * as ep___channels_create from "./endpoints/channels/create.js"; +import * as ep___channels_featured from "./endpoints/channels/featured.js"; +import * as ep___channels_follow from "./endpoints/channels/follow.js"; +import * as ep___channels_followed from "./endpoints/channels/followed.js"; +import * as ep___channels_owned from "./endpoints/channels/owned.js"; +import * as ep___channels_search from "./endpoints/channels/search.js"; +import * as ep___channels_show from "./endpoints/channels/show.js"; +import * as ep___channels_timeline from "./endpoints/channels/timeline.js"; +import * as ep___channels_unfollow from "./endpoints/channels/unfollow.js"; +import * as ep___channels_update from "./endpoints/channels/update.js"; +import * as ep___charts_activeUsers from "./endpoints/charts/active-users.js"; +import * as ep___charts_apRequest from "./endpoints/charts/ap-request.js"; +import * as ep___charts_drive from "./endpoints/charts/drive.js"; +import * as ep___charts_federation from "./endpoints/charts/federation.js"; +import * as ep___charts_hashtag from "./endpoints/charts/hashtag.js"; +import * as ep___charts_instance from "./endpoints/charts/instance.js"; +import * as ep___charts_notes from "./endpoints/charts/notes.js"; +import * as ep___charts_user_drive from "./endpoints/charts/user/drive.js"; +import * as ep___charts_user_following from "./endpoints/charts/user/following.js"; +import * as ep___charts_user_notes from "./endpoints/charts/user/notes.js"; +import * as ep___charts_user_reactions from "./endpoints/charts/user/reactions.js"; +import * as ep___charts_users from "./endpoints/charts/users.js"; +import * as ep___clips_addNote from "./endpoints/clips/add-note.js"; +import * as ep___clips_removeNote from "./endpoints/clips/remove-note.js"; +import * as ep___clips_create from "./endpoints/clips/create.js"; +import * as ep___clips_delete from "./endpoints/clips/delete.js"; +import * as ep___clips_list from "./endpoints/clips/list.js"; +import * as ep___clips_notes from "./endpoints/clips/notes.js"; +import * as ep___clips_show from "./endpoints/clips/show.js"; +import * as ep___clips_update from "./endpoints/clips/update.js"; +import * as ep___drive from "./endpoints/drive.js"; +import * as ep___drive_files from "./endpoints/drive/files.js"; +import * as ep___drive_files_attachedNotes from "./endpoints/drive/files/attached-notes.js"; +import * as ep___drive_files_checkExistence from "./endpoints/drive/files/check-existence.js"; +import * as ep___drive_files_captionImage from "./endpoints/drive/files/caption-image.js"; +import * as ep___drive_files_create from "./endpoints/drive/files/create.js"; +import * as ep___drive_files_delete from "./endpoints/drive/files/delete.js"; +import * as ep___drive_files_findByHash from "./endpoints/drive/files/find-by-hash.js"; +import * as ep___drive_files_find from "./endpoints/drive/files/find.js"; +import * as ep___drive_files_show from "./endpoints/drive/files/show.js"; +import * as ep___drive_files_update from "./endpoints/drive/files/update.js"; +import * as ep___drive_files_uploadFromUrl from "./endpoints/drive/files/upload-from-url.js"; +import * as ep___drive_folders from "./endpoints/drive/folders.js"; +import * as ep___drive_folders_create from "./endpoints/drive/folders/create.js"; +import * as ep___drive_folders_delete from "./endpoints/drive/folders/delete.js"; +import * as ep___drive_folders_find from "./endpoints/drive/folders/find.js"; +import * as ep___drive_folders_show from "./endpoints/drive/folders/show.js"; +import * as ep___drive_folders_update from "./endpoints/drive/folders/update.js"; +import * as ep___drive_stream from "./endpoints/drive/stream.js"; +import * as ep___emailAddress_available from "./endpoints/email-address/available.js"; +import * as ep___emoji from "./endpoints/emoji.js"; +import * as ep___endpoint from "./endpoints/endpoint.js"; +import * as ep___endpoints from "./endpoints/endpoints.js"; +import * as ep___exportCustomEmojis from "./endpoints/export-custom-emojis.js"; +import * as ep___federation_followers from "./endpoints/federation/followers.js"; +import * as ep___federation_following from "./endpoints/federation/following.js"; +import * as ep___federation_instances from "./endpoints/federation/instances.js"; +import * as ep___federation_showInstance from "./endpoints/federation/show-instance.js"; +import * as ep___federation_updateRemoteUser from "./endpoints/federation/update-remote-user.js"; +import * as ep___federation_users from "./endpoints/federation/users.js"; +import * as ep___federation_stats from "./endpoints/federation/stats.js"; +import * as ep___following_create from "./endpoints/following/create.js"; +import * as ep___following_delete from "./endpoints/following/delete.js"; +import * as ep___following_invalidate from "./endpoints/following/invalidate.js"; +import * as ep___following_requests_accept from "./endpoints/following/requests/accept.js"; +import * as ep___following_requests_cancel from "./endpoints/following/requests/cancel.js"; +import * as ep___following_requests_list from "./endpoints/following/requests/list.js"; +import * as ep___following_requests_reject from "./endpoints/following/requests/reject.js"; +import * as ep___gallery_featured from "./endpoints/gallery/featured.js"; +import * as ep___gallery_popular from "./endpoints/gallery/popular.js"; +import * as ep___gallery_posts from "./endpoints/gallery/posts.js"; +import * as ep___gallery_posts_create from "./endpoints/gallery/posts/create.js"; +import * as ep___gallery_posts_delete from "./endpoints/gallery/posts/delete.js"; +import * as ep___gallery_posts_like from "./endpoints/gallery/posts/like.js"; +import * as ep___gallery_posts_show from "./endpoints/gallery/posts/show.js"; +import * as ep___gallery_posts_unlike from "./endpoints/gallery/posts/unlike.js"; +import * as ep___gallery_posts_update from "./endpoints/gallery/posts/update.js"; +import * as ep___getOnlineUsersCount from "./endpoints/get-online-users-count.js"; +import * as ep___hashtags_list from "./endpoints/hashtags/list.js"; +import * as ep___hashtags_search from "./endpoints/hashtags/search.js"; +import * as ep___hashtags_show from "./endpoints/hashtags/show.js"; +import * as ep___hashtags_trend from "./endpoints/hashtags/trend.js"; +import * as ep___hashtags_users from "./endpoints/hashtags/users.js"; +import * as ep___i from "./endpoints/i.js"; +import * as ep___i_2fa_done from "./endpoints/i/2fa/done.js"; +import * as ep___i_2fa_keyDone from "./endpoints/i/2fa/key-done.js"; +import * as ep___i_2fa_passwordLess from "./endpoints/i/2fa/password-less.js"; +import * as ep___i_2fa_registerKey from "./endpoints/i/2fa/register-key.js"; +import * as ep___i_2fa_register from "./endpoints/i/2fa/register.js"; +import * as ep___i_2fa_updateKey from "./endpoints/i/2fa/update-key.js"; +import * as ep___i_2fa_removeKey from "./endpoints/i/2fa/remove-key.js"; +import * as ep___i_2fa_unregister from "./endpoints/i/2fa/unregister.js"; +import * as ep___i_apps from "./endpoints/i/apps.js"; +import * as ep___i_authorizedApps from "./endpoints/i/authorized-apps.js"; +import * as ep___i_changePassword from "./endpoints/i/change-password.js"; +import * as ep___i_deleteAccount from "./endpoints/i/delete-account.js"; +import * as ep___i_exportBlocking from "./endpoints/i/export-blocking.js"; +import * as ep___i_exportFollowing from "./endpoints/i/export-following.js"; +import * as ep___i_exportMute from "./endpoints/i/export-mute.js"; +import * as ep___i_exportNotes from "./endpoints/i/export-notes.js"; +import * as ep___i_importPosts from "./endpoints/i/import-posts.js"; +import * as ep___i_exportUserLists from "./endpoints/i/export-user-lists.js"; +import * as ep___i_favorites from "./endpoints/i/favorites.js"; +import * as ep___i_gallery_likes from "./endpoints/i/gallery/likes.js"; +import * as ep___i_gallery_posts from "./endpoints/i/gallery/posts.js"; +import * as ep___i_importBlocking from "./endpoints/i/import-blocking.js"; +import * as ep___i_importFollowing from "./endpoints/i/import-following.js"; +import * as ep___i_importMuting from "./endpoints/i/import-muting.js"; +import * as ep___i_importUserLists from "./endpoints/i/import-user-lists.js"; +import * as ep___i_notifications from "./endpoints/i/notifications.js"; +import * as ep___i_pageLikes from "./endpoints/i/page-likes.js"; +import * as ep___i_pages from "./endpoints/i/pages.js"; +import * as ep___i_pin from "./endpoints/i/pin.js"; +import * as ep___i_readAllMessagingMessages from "./endpoints/i/read-all-messaging-messages.js"; +import * as ep___i_readAllUnreadNotes from "./endpoints/i/read-all-unread-notes.js"; +import * as ep___i_readAnnouncement from "./endpoints/i/read-announcement.js"; +import * as ep___i_requestVerifiedBadge from "./endpoints/i/request-verified-badge.js"; +import * as ep___i_regenerateToken from "./endpoints/i/regenerate-token.js"; +import * as ep___i_registry_getAll from "./endpoints/i/registry/get-all.js"; +import * as ep___i_registry_getDetail from "./endpoints/i/registry/get-detail.js"; +import * as ep___i_registry_get from "./endpoints/i/registry/get.js"; +import * as ep___i_registry_keysWithType from "./endpoints/i/registry/keys-with-type.js"; +import * as ep___i_registry_keys from "./endpoints/i/registry/keys.js"; +import * as ep___i_registry_remove from "./endpoints/i/registry/remove.js"; +import * as ep___i_registry_scopes from "./endpoints/i/registry/scopes.js"; +import * as ep___i_registry_set from "./endpoints/i/registry/set.js"; +import * as ep___i_revokeToken from "./endpoints/i/revoke-token.js"; +import * as ep___i_signinHistory from "./endpoints/i/signin-history.js"; +import * as ep___i_unpin from "./endpoints/i/unpin.js"; +import * as ep___i_updateEmail from "./endpoints/i/update-email.js"; +import * as ep___i_update from "./endpoints/i/update.js"; +import * as ep___i_userEmojis_create from "./endpoints/i/user-emojis/create.js"; +import * as ep___i_userEmojis_delete from "./endpoints/i/user-emojis/delete.js"; +import * as ep___i_userEmojis_list from "./endpoints/i/user-emojis/list.js"; +import * as ep___i_userGroupInvites from "./endpoints/i/user-group-invites.js"; +import * as ep___i_webhooks_create from "./endpoints/i/webhooks/create.js"; +import * as ep___i_webhooks_show from "./endpoints/i/webhooks/show.js"; +import * as ep___i_webhooks_list from "./endpoints/i/webhooks/list.js"; +import * as ep___i_webhooks_update from "./endpoints/i/webhooks/update.js"; +import * as ep___i_webhooks_delete from "./endpoints/i/webhooks/delete.js"; +import * as ep___messaging_history from "./endpoints/messaging/history.js"; +import * as ep___messaging_messages from "./endpoints/messaging/messages.js"; +import * as ep___messaging_messages_create from "./endpoints/messaging/messages/create.js"; +import * as ep___messaging_messages_delete from "./endpoints/messaging/messages/delete.js"; +import * as ep___messaging_messages_read from "./endpoints/messaging/messages/read.js"; +import * as ep___meta from "./endpoints/meta.js"; +import * as ep___sounds from "./endpoints/get-sounds.js"; +import * as ep___miauth_genToken from "./endpoints/miauth/gen-token.js"; +import * as ep___mute_create from "./endpoints/mute/create.js"; +import * as ep___mute_delete from "./endpoints/mute/delete.js"; +import * as ep___mute_list from "./endpoints/mute/list.js"; +import * as ep___renote_mute_create from "./endpoints/renote-mute/create.js"; +import * as ep___renote_mute_delete from "./endpoints/renote-mute/delete.js"; +import * as ep___renote_mute_list from "./endpoints/renote-mute/list.js"; +import * as ep___my_apps from "./endpoints/my/apps.js"; +import * as ep___notes from "./endpoints/notes.js"; +import * as ep___notes_children from "./endpoints/notes/children.js"; +import * as ep___notes_clips from "./endpoints/notes/clips.js"; +import * as ep___notes_conversation from "./endpoints/notes/conversation.js"; +import * as ep___notes_create from "./endpoints/notes/create.js"; +import * as ep___notes_delete from "./endpoints/notes/delete.js"; +import * as ep___notes_edit from "./endpoints/notes/edit.js"; +import * as ep___notes_favorites_create from "./endpoints/notes/favorites/create.js"; +import * as ep___notes_favorites_delete from "./endpoints/notes/favorites/delete.js"; +import * as ep___notes_featured from "./endpoints/notes/featured.js"; +import * as ep___notes_globalTimeline from "./endpoints/notes/global-timeline.js"; +import * as ep___notes_hybridTimeline from "./endpoints/notes/hybrid-timeline.js"; +import * as ep___notes_localTimeline from "./endpoints/notes/local-timeline.js"; +import * as ep___notes_recommendedTimeline from "./endpoints/notes/recommended-timeline.js"; +import * as ep___notes_mentions from "./endpoints/notes/mentions.js"; +import * as ep___notes_polls_recommendation from "./endpoints/notes/polls/recommendation.js"; +import * as ep___notes_polls_vote from "./endpoints/notes/polls/vote.js"; +import * as ep___notes_reactions from "./endpoints/notes/reactions.js"; +import * as ep___notes_reactions_create from "./endpoints/notes/reactions/create.js"; +import * as ep___notes_reactions_delete from "./endpoints/notes/reactions/delete.js"; +import * as ep___notes_renotes from "./endpoints/notes/renotes.js"; +import * as ep___notes_replies from "./endpoints/notes/replies.js"; +import * as ep___notes_incrementServiceView from "./endpoints/notes/increment-service-view.js"; +import * as ep___notes_lua4frozenSearch from "./endpoints/notes/lua4frozen-search.js"; +import * as ep___notes_mediaServiceSearch from "./endpoints/notes/media-service-search.js"; +import * as ep___notes_karaokeServiceSearch from "./endpoints/notes/karaoke-service-search.js"; +import * as ep___memoriet_create from "./endpoints/memoriet/create.js"; +import * as ep___memoriet_deletedList from "./endpoints/memoriet/deleted-list.js"; +import * as ep___memoriet_list from "./endpoints/memoriet/list.js"; +import * as ep___memoriet_repost from "./endpoints/memoriet/repost.js"; +import * as ep___memoriet_viewers from "./endpoints/memoriet/viewers.js"; +import * as ep___notes_searchByTag from "./endpoints/notes/search-by-tag.js"; +import * as ep___notes_search from "./endpoints/notes/search.js"; +import * as ep___notes_show from "./endpoints/notes/show.js"; +import * as ep___notes_state from "./endpoints/notes/state.js"; +import * as ep___notes_threadMuting_create from "./endpoints/notes/thread-muting/create.js"; +import * as ep___notes_threadMuting_delete from "./endpoints/notes/thread-muting/delete.js"; +import * as ep___notes_timeline from "./endpoints/notes/timeline.js"; +import * as ep___notes_translate from "./endpoints/notes/translate.js"; +import * as ep___notes_unrenote from "./endpoints/notes/unrenote.js"; +import * as ep___notes_userListTimeline from "./endpoints/notes/user-list-timeline.js"; +import * as ep___notes_watching_create from "./endpoints/notes/watching/create.js"; +import * as ep___notes_watching_delete from "./endpoints/notes/watching/delete.js"; +import * as ep___notifications_create from "./endpoints/notifications/create.js"; +import * as ep___notifications_markAllAsRead from "./endpoints/notifications/mark-all-as-read.js"; +import * as ep___notifications_read from "./endpoints/notifications/read.js"; +import * as ep___pagePush from "./endpoints/page-push.js"; +import * as ep___pages_create from "./endpoints/pages/create.js"; +import * as ep___pages_delete from "./endpoints/pages/delete.js"; +import * as ep___pages_featured from "./endpoints/pages/featured.js"; +import * as ep___pages_like from "./endpoints/pages/like.js"; +import * as ep___pages_show from "./endpoints/pages/show.js"; +import * as ep___pages_unlike from "./endpoints/pages/unlike.js"; +import * as ep___pages_update from "./endpoints/pages/update.js"; +import * as ep___ping from "./endpoints/ping.js"; +import * as ep___recommendedInstances from "./endpoints/recommended-instances.js"; +import * as ep___pinnedUsers from "./endpoints/pinned-users.js"; +import * as ep___customMOTD from "./endpoints/custom-motd.js"; +import * as ep___customSplashIcons from "./endpoints/custom-splash-icons.js"; +import * as ep___latestVersion from "./endpoints/latest-version.js"; +import * as ep___release from "./endpoints/release.js"; +import * as ep___reversi_cancelMatch from "./endpoints/reversi/cancel-match.js"; +import * as ep___reversi_games from "./endpoints/reversi/games.js"; +import * as ep___reversi_invitations from "./endpoints/reversi/invitations.js"; +import * as ep___reversi_match from "./endpoints/reversi/match.js"; +import * as ep___reversi_showGame from "./endpoints/reversi/show-game.js"; +import * as ep___reversi_surrender from "./endpoints/reversi/surrender.js"; +import * as ep___reversi_verify from "./endpoints/reversi/verify.js"; +import * as ep___shogi_cancelMatch from "./endpoints/shogi/cancel-match.js"; +import * as ep___shogi_games from "./endpoints/shogi/games.js"; +import * as ep___shogi_invitations from "./endpoints/shogi/invitations.js"; +import * as ep___shogi_match from "./endpoints/shogi/match.js"; +import * as ep___shogi_showGame from "./endpoints/shogi/show-game.js"; +import * as ep___shogi_surrender from "./endpoints/shogi/surrender.js"; +import * as ep___promo_read from "./endpoints/promo/read.js"; +import * as ep___promo_show from "./endpoints/promo/show.js"; +import * as ep___requestResetPassword from "./endpoints/request-reset-password.js"; +import * as ep___resetDb from "./endpoints/reset-db.js"; +import * as ep___resetPassword from "./endpoints/reset-password.js"; +import * as ep___serverInfo from "./endpoints/server-info.js"; +import * as ep___stats from "./endpoints/stats.js"; +import * as ep___sw_show_registration from "./endpoints/sw/show-registration.js"; +import * as ep___sw_update_registration from "./endpoints/sw/update-registration.js"; +import * as ep___sw_register from "./endpoints/sw/register.js"; +import * as ep___sw_unregister from "./endpoints/sw/unregister.js"; +import * as ep___test from "./endpoints/test.js"; +import * as ep___username_available from "./endpoints/username/available.js"; +import * as ep___users from "./endpoints/users.js"; +import * as ep___users_clips from "./endpoints/users/clips.js"; +import * as ep___users_followers from "./endpoints/users/followers.js"; +import * as ep___users_following from "./endpoints/users/following.js"; +import * as ep___users_gallery_posts from "./endpoints/users/gallery/posts.js"; +import * as ep___users_getFrequentlyRepliedUsers from "./endpoints/users/get-frequently-replied-users.js"; +import * as ep___users_groups_create from "./endpoints/users/groups/create.js"; +import * as ep___users_groups_delete from "./endpoints/users/groups/delete.js"; +import * as ep___users_groups_invitations_accept from "./endpoints/users/groups/invitations/accept.js"; +import * as ep___users_groups_invitations_reject from "./endpoints/users/groups/invitations/reject.js"; +import * as ep___users_groups_invite from "./endpoints/users/groups/invite.js"; +import * as ep___users_groups_joined from "./endpoints/users/groups/joined.js"; +import * as ep___users_groups_leave from "./endpoints/users/groups/leave.js"; +import * as ep___users_groups_owned from "./endpoints/users/groups/owned.js"; +import * as ep___users_groups_pull from "./endpoints/users/groups/pull.js"; +import * as ep___users_groups_emojis_create from "./endpoints/users/groups/emojis/create.js"; +import * as ep___users_groups_emojis_delete from "./endpoints/users/groups/emojis/delete.js"; +import * as ep___users_groups_emojis_list from "./endpoints/users/groups/emojis/list.js"; +import * as ep___users_groups_show from "./endpoints/users/groups/show.js"; +import * as ep___users_groups_showByUsername from "./endpoints/users/groups/show-by-username.js"; +import * as ep___users_groups_transfer from "./endpoints/users/groups/transfer.js"; +import * as ep___users_groups_update from "./endpoints/users/groups/update.js"; +import * as ep___users_lists_create from "./endpoints/users/lists/create.js"; +import * as ep___users_lists_delete from "./endpoints/users/lists/delete.js"; +import * as ep___users_lists_delete_all from "./endpoints/users/lists/delete-all.js"; +import * as ep___users_lists_list from "./endpoints/users/lists/list.js"; +import * as ep___users_lists_pull from "./endpoints/users/lists/pull.js"; +import * as ep___users_lists_push from "./endpoints/users/lists/push.js"; +import * as ep___users_lists_show from "./endpoints/users/lists/show.js"; +import * as ep___users_lists_update from "./endpoints/users/lists/update.js"; +import * as ep___users_notes from "./endpoints/users/notes.js"; +import * as ep___users_pages from "./endpoints/users/pages.js"; +import * as ep___users_reactions from "./endpoints/users/reactions.js"; +import * as ep___users_recommendation from "./endpoints/users/recommendation.js"; +import * as ep___users_relation from "./endpoints/users/relation.js"; +import * as ep___users_reportAbuse from "./endpoints/users/report-abuse.js"; +import * as ep___users_searchByUsernameAndHost from "./endpoints/users/search-by-username-and-host.js"; +import * as ep___users_search from "./endpoints/users/search.js"; +import * as ep___users_show from "./endpoints/users/show.js"; +import * as ep___users_stats from "./endpoints/users/stats.js"; +import * as ep___fetchRss from "./endpoints/fetch-rss.js"; +import * as ep___admin_driveCapOverride from "./endpoints/admin/drive-capacity-override.js"; +import * as ep___bites_create from "./endpoints/bites/create.js"; +import * as ep___bites_show from "./endpoints/bites/show.js"; + +//Iceshrimp Move +import * as ep___i_move from "./endpoints/i/move.js"; +import * as ep___i_known_as from "./endpoints/i/known-as.js"; + +const eps = [ + ["admin/meta", ep___admin_meta], + ["admin/abuse-user-reports", ep___admin_abuseUserReports], + ["admin/accounts/create", ep___admin_accounts_create], + ["admin/accounts/delete", ep___admin_accounts_delete], + ["admin/accounts/hosted", ep___admin_accounts_hosted], + ["admin/announcements/create", ep___admin_announcements_create], + ["admin/announcements/delete", ep___admin_announcements_delete], + ["admin/announcements/list", ep___admin_announcements_list], + ["admin/announcements/update", ep___admin_announcements_update], + ["admin/create-backup", ep___admin_createBackup], + ["admin/delete-all-files-of-a-user", ep___admin_deleteAllFilesOfAUser], + ["admin/drive/clean-remote-files", ep___admin_drive_cleanRemoteFiles], + ["admin/drive/cleanup", ep___admin_drive_cleanup], + ["admin/drive/files", ep___admin_drive_files], + ["admin/drive/show-file", ep___admin_drive_showFile], + ["admin/emoji/add-aliases-bulk", ep___admin_emoji_addAliasesBulk], + ["admin/emoji/add", ep___admin_emoji_add], + ["admin/emoji/copy", ep___admin_emoji_copy], + ["admin/emoji/delete-bulk", ep___admin_emoji_deleteBulk], + ["admin/emoji/delete", ep___admin_emoji_delete], + ["admin/emoji/import-zip", ep___admin_emoji_importZip], + ["admin/emoji/list-remote", ep___admin_emoji_listRemote], + ["admin/emoji/list", ep___admin_emoji_list], + ["admin/emoji/remove-aliases-bulk", ep___admin_emoji_removeAliasesBulk], + ["admin/emoji/set-aliases-bulk", ep___admin_emoji_setAliasesBulk], + ["admin/emoji/set-category-bulk", ep___admin_emoji_setCategoryBulk], + ["admin/emoji/set-license-bulk", ep___admin_emoji_setLicenseBulk], + ["admin/emoji/update", ep___admin_emoji_update], + ["admin/federation/delete-all-files", ep___admin_federation_deleteAllFiles], + [ + "admin/federation/refresh-remote-instance-metadata", + ep___admin_federation_refreshRemoteInstanceMetadata, + ], + [ + "admin/federation/remove-all-following", + ep___admin_federation_removeAllFollowing, + ], + ["admin/federation/update-instance", ep___admin_federation_updateInstance], + ["admin/get-index-stats", ep___admin_getIndexStats], + ["admin/get-table-stats", ep___admin_getTableStats], + ["admin/get-user-ips", ep___admin_getUserIps], + ["admin/invite", ep___admin_invite], + ["admin/moderators/add", ep___admin_moderators_add], + ["admin/moderators/remove", ep___admin_moderators_remove], + ["admin/plans/create", ep___admin_plans_create], + ["admin/plans/delete", ep___admin_plans_delete], + ["admin/plans/list", ep___admin_plans_list], + ["admin/plans/update", ep___admin_plans_update], + ["admin/promo/create", ep___admin_promo_create], + ["admin/promo/list", ep___admin_promo_list], + ["admin/queue/clear", ep___admin_queue_clear], + ["admin/queue/deliver-delayed", ep___admin_queue_deliverDelayed], + ["admin/queue/inbox-delayed", ep___admin_queue_inboxDelayed], + ["admin/queue/stats", ep___admin_queue_stats], + ["admin/relays/add", ep___admin_relays_add], + ["admin/relays/list", ep___admin_relays_list], + ["admin/relays/remove", ep___admin_relays_remove], + ["admin/reset-password", ep___admin_resetPassword], + ["admin/resolve-abuse-user-report", ep___admin_resolveAbuseUserReport], + ["admin/send-email", ep___admin_sendEmail], + ["admin/send-mod-mail", ep___admin_sendModMail], + ["admin/server-info", ep___admin_serverInfo], + ["admin/set-user-verified", ep___admin_setUserVerified], + ["admin/show-moderation-logs", ep___admin_showModerationLogs], + ["admin/show-user", ep___admin_showUser], + ["admin/show-users", ep___admin_showUsers], + ["admin/silence-user", ep___admin_silenceUser], + ["admin/suspend-user", ep___admin_suspendUser], + ["admin/unsilence-user", ep___admin_unsilenceUser], + ["admin/unsuspend-user", ep___admin_unsuspendUser], + ["admin/update-meta", ep___admin_updateMeta], + ["admin/vacuum", ep___admin_vacuum], + ["admin/user-plans/add", ep___admin_userPlans_add], + ["admin/user-plans/list", ep___admin_userPlans_list], + ["admin/user-plans/remove", ep___admin_userPlans_remove], + ["admin/verified-badge-requests", ep___admin_verifiedBadgeRequests], + ["admin/resolve-verified-badge-request", ep___admin_resolveVerifiedBadgeRequest], + ["admin/delete-account", ep___admin_deleteAccount], + ["admin/update-user-note", ep___admin_updateUserNote], + ["announcements", ep___announcements], + ["antennas/create", ep___antennas_create], + ["antennas/delete", ep___antennas_delete], + ["antennas/list", ep___antennas_list], + ["antennas/mark-read", ep___antennas_markRead], + ["antennas/notes", ep___antennas_notes], + ["antennas/show", ep___antennas_show], + ["antennas/update", ep___antennas_update], + ["ap/get", ep___ap_get], + ["ap/show", ep___ap_show], + ["app/create", ep___app_create], + ["app/show", ep___app_show], + ["auth/accept", ep___auth_accept], + ["auth/session/generate", ep___auth_session_generate], + ["auth/session/show", ep___auth_session_show], + ["auth/session/userkey", ep___auth_session_userkey], + ["blocking/create", ep___blocking_create], + ["blocking/delete", ep___blocking_delete], + ["blocking/list", ep___blocking_list], + ["call-blocking/create", ep___call_blocking_create], + ["call-blocking/delete", ep___call_blocking_delete], + ["call-blocking/list", ep___call_blocking_list], + ["channels/create", ep___channels_create], + ["channels/featured", ep___channels_featured], + ["channels/follow", ep___channels_follow], + ["channels/followed", ep___channels_followed], + ["channels/owned", ep___channels_owned], + ["channels/search", ep___channels_search], + ["channels/show", ep___channels_show], + ["channels/timeline", ep___channels_timeline], + ["channels/unfollow", ep___channels_unfollow], + ["channels/update", ep___channels_update], + ["charts/active-users", ep___charts_activeUsers], + ["charts/ap-request", ep___charts_apRequest], + ["charts/drive", ep___charts_drive], + ["charts/federation", ep___charts_federation], + ["charts/hashtag", ep___charts_hashtag], + ["charts/instance", ep___charts_instance], + ["charts/notes", ep___charts_notes], + ["charts/user/drive", ep___charts_user_drive], + ["charts/user/following", ep___charts_user_following], + ["charts/user/notes", ep___charts_user_notes], + ["charts/user/reactions", ep___charts_user_reactions], + ["charts/users", ep___charts_users], + ["clips/add-note", ep___clips_addNote], + ["clips/remove-note", ep___clips_removeNote], + ["clips/create", ep___clips_create], + ["clips/delete", ep___clips_delete], + ["clips/list", ep___clips_list], + ["clips/notes", ep___clips_notes], + ["clips/show", ep___clips_show], + ["clips/update", ep___clips_update], + ["drive", ep___drive], + ["drive/files", ep___drive_files], + ["drive/files/attached-notes", ep___drive_files_attachedNotes], + ["drive/files/caption-image", ep___drive_files_captionImage], + ["drive/files/check-existence", ep___drive_files_checkExistence], + ["drive/files/create", ep___drive_files_create], + ["drive/files/delete", ep___drive_files_delete], + ["drive/files/find-by-hash", ep___drive_files_findByHash], + ["drive/files/find", ep___drive_files_find], + ["drive/files/show", ep___drive_files_show], + ["drive/files/update", ep___drive_files_update], + ["drive/files/upload-from-url", ep___drive_files_uploadFromUrl], + ["drive/folders", ep___drive_folders], + ["drive/folders/create", ep___drive_folders_create], + ["drive/folders/delete", ep___drive_folders_delete], + ["drive/folders/find", ep___drive_folders_find], + ["drive/folders/show", ep___drive_folders_show], + ["drive/folders/update", ep___drive_folders_update], + ["drive/stream", ep___drive_stream], + ["email-address/available", ep___emailAddress_available], + ["emoji", ep___emoji], + ["endpoint", ep___endpoint], + ["endpoints", ep___endpoints], + ["export-custom-emojis", ep___exportCustomEmojis], + ["federation/followers", ep___federation_followers], + ["federation/following", ep___federation_following], + ["federation/instances", ep___federation_instances], + ["federation/show-instance", ep___federation_showInstance], + ["federation/update-remote-user", ep___federation_updateRemoteUser], + ["federation/users", ep___federation_users], + ["federation/stats", ep___federation_stats], + ["following/create", ep___following_create], + ["following/delete", ep___following_delete], + ["following/invalidate", ep___following_invalidate], + ["following/requests/accept", ep___following_requests_accept], + ["following/requests/cancel", ep___following_requests_cancel], + ["following/requests/list", ep___following_requests_list], + ["following/requests/reject", ep___following_requests_reject], + ["gallery/featured", ep___gallery_featured], + ["gallery/popular", ep___gallery_popular], + ["gallery/posts", ep___gallery_posts], + ["gallery/posts/create", ep___gallery_posts_create], + ["gallery/posts/delete", ep___gallery_posts_delete], + ["gallery/posts/like", ep___gallery_posts_like], + ["gallery/posts/show", ep___gallery_posts_show], + ["gallery/posts/unlike", ep___gallery_posts_unlike], + ["gallery/posts/update", ep___gallery_posts_update], + ["get-online-users-count", ep___getOnlineUsersCount], + ["hashtags/list", ep___hashtags_list], + ["hashtags/search", ep___hashtags_search], + ["hashtags/show", ep___hashtags_show], + ["hashtags/trend", ep___hashtags_trend], + ["hashtags/users", ep___hashtags_users], + ["i", ep___i], + ["i/known-as", ep___i_known_as], + ["i/move", ep___i_move], + ["i/2fa/done", ep___i_2fa_done], + ["i/2fa/key-done", ep___i_2fa_keyDone], + ["i/2fa/password-less", ep___i_2fa_passwordLess], + ["i/2fa/register-key", ep___i_2fa_registerKey], + ["i/2fa/register", ep___i_2fa_register], + ["i/2fa/update-key", ep___i_2fa_updateKey], + ["i/2fa/remove-key", ep___i_2fa_removeKey], + ["i/2fa/unregister", ep___i_2fa_unregister], + ["i/apps", ep___i_apps], + ["i/authorized-apps", ep___i_authorizedApps], + ["i/change-password", ep___i_changePassword], + ["i/delete-account", ep___i_deleteAccount], + ["i/export-blocking", ep___i_exportBlocking], + ["i/export-following", ep___i_exportFollowing], + ["i/export-mute", ep___i_exportMute], + ["i/export-notes", ep___i_exportNotes], + ["i/import-posts", ep___i_importPosts], + ["i/export-user-lists", ep___i_exportUserLists], + ["i/favorites", ep___i_favorites], + ["i/gallery/likes", ep___i_gallery_likes], + ["i/gallery/posts", ep___i_gallery_posts], + ["i/import-blocking", ep___i_importBlocking], + ["i/import-following", ep___i_importFollowing], + ["i/import-muting", ep___i_importMuting], + ["i/import-user-lists", ep___i_importUserLists], + ["i/notifications", ep___i_notifications], + ["i/page-likes", ep___i_pageLikes], + ["i/pages", ep___i_pages], + ["i/pin", ep___i_pin], + ["i/read-all-messaging-messages", ep___i_readAllMessagingMessages], + ["i/read-all-unread-notes", ep___i_readAllUnreadNotes], + ["i/read-announcement", ep___i_readAnnouncement], + ["i/request-verified-badge", ep___i_requestVerifiedBadge], + ["i/regenerate-token", ep___i_regenerateToken], + ["i/registry/get-all", ep___i_registry_getAll], + ["i/registry/get-detail", ep___i_registry_getDetail], + ["i/registry/get", ep___i_registry_get], + ["i/registry/keys-with-type", ep___i_registry_keysWithType], + ["i/registry/keys", ep___i_registry_keys], + ["i/registry/remove", ep___i_registry_remove], + ["i/registry/scopes", ep___i_registry_scopes], + ["i/registry/set", ep___i_registry_set], + ["i/revoke-token", ep___i_revokeToken], + ["i/signin-history", ep___i_signinHistory], + ["i/unpin", ep___i_unpin], + ["i/update-email", ep___i_updateEmail], + ["i/update", ep___i_update], + ["i/user-emojis/create", ep___i_userEmojis_create], + ["i/user-emojis/delete", ep___i_userEmojis_delete], + ["i/user-emojis/list", ep___i_userEmojis_list], + ["i/user-group-invites", ep___i_userGroupInvites], + ["i/webhooks/create", ep___i_webhooks_create], + ["i/webhooks/list", ep___i_webhooks_list], + ["i/webhooks/show", ep___i_webhooks_show], + ["i/webhooks/update", ep___i_webhooks_update], + ["i/webhooks/delete", ep___i_webhooks_delete], + ["messaging/history", ep___messaging_history], + ["messaging/messages", ep___messaging_messages], + ["messaging/messages/create", ep___messaging_messages_create], + ["messaging/messages/delete", ep___messaging_messages_delete], + ["messaging/messages/read", ep___messaging_messages_read], + ["meta", ep___meta], + ["miauth/gen-token", ep___miauth_genToken], + ["mute/create", ep___mute_create], + ["mute/delete", ep___mute_delete], + ["mute/list", ep___mute_list], + ["my/apps", ep___my_apps], + ["notes", ep___notes], + ["notes/children", ep___notes_children], + ["notes/clips", ep___notes_clips], + ["notes/conversation", ep___notes_conversation], + ["notes/create", ep___notes_create], + ["notes/delete", ep___notes_delete], + ["notes/edit", ep___notes_edit], + ["notes/favorites/create", ep___notes_favorites_create], + ["notes/favorites/delete", ep___notes_favorites_delete], + ["notes/featured", ep___notes_featured], + ["notes/global-timeline", ep___notes_globalTimeline], + ["notes/hybrid-timeline", ep___notes_hybridTimeline], + ["notes/local-timeline", ep___notes_localTimeline], + ["notes/recommended-timeline", ep___notes_recommendedTimeline], + ["notes/mentions", ep___notes_mentions], + ["notes/polls/recommendation", ep___notes_polls_recommendation], + ["notes/polls/vote", ep___notes_polls_vote], + ["notes/reactions", ep___notes_reactions], + ["notes/reactions/create", ep___notes_reactions_create], + ["notes/reactions/delete", ep___notes_reactions_delete], + ["notes/renotes", ep___notes_renotes], + ["notes/replies", ep___notes_replies], + ["notes/increment-service-view", ep___notes_incrementServiceView], + ["notes/lua4frozen-search", ep___notes_lua4frozenSearch], + ["notes/media-service-search", ep___notes_mediaServiceSearch], + ["notes/karaoke-service-search", ep___notes_karaokeServiceSearch], + ["memoriet/create", ep___memoriet_create], + ["memoriet/deleted-list", ep___memoriet_deletedList], + ["memoriet/list", ep___memoriet_list], + ["memoriet/repost", ep___memoriet_repost], + ["memoriet/viewers", ep___memoriet_viewers], + ["notes/search-by-tag", ep___notes_searchByTag], + ["notes/search", ep___notes_search], + ["notes/show", ep___notes_show], + ["notes/state", ep___notes_state], + ["notes/thread-muting/create", ep___notes_threadMuting_create], + ["notes/thread-muting/delete", ep___notes_threadMuting_delete], + ["notes/timeline", ep___notes_timeline], + ["notes/translate", ep___notes_translate], + ["notes/unrenote", ep___notes_unrenote], + ["notes/user-list-timeline", ep___notes_userListTimeline], + ["notes/watching/create", ep___notes_watching_create], + ["notes/watching/delete", ep___notes_watching_delete], + ["notifications/create", ep___notifications_create], + ["notifications/mark-all-as-read", ep___notifications_markAllAsRead], + ["notifications/read", ep___notifications_read], + ["page-push", ep___pagePush], + ["pages/create", ep___pages_create], + ["pages/delete", ep___pages_delete], + ["pages/featured", ep___pages_featured], + ["pages/like", ep___pages_like], + ["pages/show", ep___pages_show], + ["pages/unlike", ep___pages_unlike], + ["pages/update", ep___pages_update], + ["ping", ep___ping], + ["pinned-users", ep___pinnedUsers], + ["recommended-instances", ep___recommendedInstances], + ["renote-mute/create", ep___renote_mute_create], + ["renote-mute/delete", ep___renote_mute_delete], + ["renote-mute/list", ep___renote_mute_list], + ["custom-motd", ep___customMOTD], + ["custom-splash-icons", ep___customSplashIcons], + ["latest-version", ep___latestVersion], + ["release", ep___release], + ["reversi/cancel-match", ep___reversi_cancelMatch], + ["reversi/games", ep___reversi_games], + ["reversi/invitations", ep___reversi_invitations], + ["reversi/match", ep___reversi_match], + ["reversi/show-game", ep___reversi_showGame], + ["reversi/surrender", ep___reversi_surrender], + ["reversi/verify", ep___reversi_verify], + ["games/reversi/games", ep___reversi_games], + ["games/reversi/games/show", ep___reversi_showGame], + ["games/reversi/games/surrender", ep___reversi_surrender], + ["games/reversi/invitations", ep___reversi_invitations], + ["games/reversi/match", ep___reversi_match], + ["games/reversi/match/cancel", ep___reversi_cancelMatch], + ["shogi/cancel-match", ep___shogi_cancelMatch], + ["shogi/games", ep___shogi_games], + ["shogi/invitations", ep___shogi_invitations], + ["shogi/match", ep___shogi_match], + ["shogi/show-game", ep___shogi_showGame], + ["shogi/surrender", ep___shogi_surrender], + ["games/shogi/games", ep___shogi_games], + ["games/shogi/games/show", ep___shogi_showGame], + ["games/shogi/games/surrender", ep___shogi_surrender], + ["games/shogi/invitations", ep___shogi_invitations], + ["games/shogi/match", ep___shogi_match], + ["games/shogi/match/cancel", ep___shogi_cancelMatch], + ["promo/read", ep___promo_read], + ["promo/show", ep___promo_show], + ["request-reset-password", ep___requestResetPassword], + ["reset-db", ep___resetDb], + ["reset-password", ep___resetPassword], + ["server-info", ep___serverInfo], + ["stats", ep___stats], + ["sw/register", ep___sw_register], + ["sw/unregister", ep___sw_unregister], + ["sw/show-registration", ep___sw_show_registration], + ["sw/update-registration", ep___sw_update_registration], + ["test", ep___test], + ["username/available", ep___username_available], + ["users", ep___users], + ["users/clips", ep___users_clips], + ["users/followers", ep___users_followers], + ["users/following", ep___users_following], + ["users/gallery/posts", ep___users_gallery_posts], + ["users/get-frequently-replied-users", ep___users_getFrequentlyRepliedUsers], + ["users/groups/create", ep___users_groups_create], + ["users/groups/delete", ep___users_groups_delete], + ["users/groups/invitations/accept", ep___users_groups_invitations_accept], + ["users/groups/invitations/reject", ep___users_groups_invitations_reject], + ["users/groups/invite", ep___users_groups_invite], + ["users/groups/joined", ep___users_groups_joined], + ["users/groups/leave", ep___users_groups_leave], + ["users/groups/owned", ep___users_groups_owned], + ["users/groups/pull", ep___users_groups_pull], + ["users/groups/emojis/create", ep___users_groups_emojis_create], + ["users/groups/emojis/delete", ep___users_groups_emojis_delete], + ["users/groups/emojis/list", ep___users_groups_emojis_list], + ["users/groups/show", ep___users_groups_show], + ["users/groups/show-by-username", ep___users_groups_showByUsername], + ["users/groups/transfer", ep___users_groups_transfer], + ["users/groups/update", ep___users_groups_update], + ["users/lists/create", ep___users_lists_create], + ["users/lists/delete", ep___users_lists_delete], + ["users/lists/delete-all", ep___users_lists_delete_all], + ["users/lists/list", ep___users_lists_list], + ["users/lists/pull", ep___users_lists_pull], + ["users/lists/push", ep___users_lists_push], + ["users/lists/show", ep___users_lists_show], + ["users/lists/update", ep___users_lists_update], + ["users/notes", ep___users_notes], + ["users/pages", ep___users_pages], + ["users/reactions", ep___users_reactions], + ["users/recommendation", ep___users_recommendation], + ["users/relation", ep___users_relation], + ["users/report-abuse", ep___users_reportAbuse], + ["users/search-by-username-and-host", ep___users_searchByUsernameAndHost], + ["users/search", ep___users_search], + ["users/show", ep___users_show], + ["users/stats", ep___users_stats], + ["admin/drive-capacity-override", ep___admin_driveCapOverride], + ["fetch-rss", ep___fetchRss], + ["get-sounds", ep___sounds], + ["bites/create", ep___bites_create], + ["bites/show", ep___bites_show], +]; + +export interface IEndpointMeta { + readonly stability?: "deprecated" | "experimental" | "stable"; + + readonly tags?: ReadonlyArray; + + readonly errors?: { + readonly [key: string]: { + readonly message: string; + readonly code: string; + readonly id: string; + }; + }; + + readonly res?: Schema; + + /** + * このエンドポイントにリクエストするのにユーザー情報が必須か否か + * 省略した場合は false として解釈されます。 + */ + readonly requireCredential?: boolean; + + /** + * 管理者のみ使えるエンドポイントか否か + */ + readonly requireAdmin?: boolean; + + /** + * 管理者またはモデレーターのみ使えるエンドポイントか否か + */ + readonly requireModerator?: boolean; + + /** + * エンドポイントのリミテーションに関するやつ + * 省略した場合はリミテーションは無いものとして解釈されます。 + */ + readonly limit?: { + /** + * 複数のエンドポイントでリミットを共有したい場合に指定するキー + */ + readonly key?: string; + + /** + * リミットを適用する期間(ms) + * このプロパティを設定する場合、max プロパティも設定する必要があります。 + */ + readonly duration?: number; + + /** + * durationで指定した期間内にいくつまでリクエストできるのか + * このプロパティを設定する場合、duration プロパティも設定する必要があります。 + */ + readonly max?: number; + + /** + * 最低でもどれくらいの間隔を開けてリクエストしなければならないか(ms) + */ + readonly minInterval?: number; + }; + + /** + * ファイルの添付を必要とするか否か + * 省略した場合は false として解釈されます。 + */ + readonly requireFile?: boolean; + + /** + * サードパーティアプリからはリクエストすることができないか否か + * 省略した場合は false として解釈されます。 + */ + readonly secure?: boolean; + + /** + * プライベートモードでなら、このエンドポイントにリクエストするときにユーザー情報が必要か否か + * 省略した場合は false として解釈されます + */ + readonly requireCredentialPrivateMode?: boolean; + + /** + * エンドポイントの種類 + * パーミッションの実現に利用されます。 + */ + readonly kind?: string; + + readonly description?: string; + + /** + * GETでのリクエストを許容するか否か + */ + readonly allowGet?: boolean; + + /** + * 正常応答をキャッシュ (Cache-Control: public) する秒数 + */ + readonly cacheSec?: number; +} + +export interface IEndpoint { + name: string; + exec: any; // TODO: may be obosolete @ThatOneCalculator + meta: IEndpointMeta; + params: Schema; +} + +const endpoints: IEndpoint[] = (eps as [string, any]).map(([name, ep]) => { + return { + name: name, + exec: ep.default, + meta: ep.meta ?? {}, + params: ep.paramDef, + }; +}); + +export default endpoints; diff --git a/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts new file mode 100644 index 0000000..4861431 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/abuse-user-reports.ts @@ -0,0 +1,144 @@ +import define from "../../define.js"; +import { AbuseUserReports } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + nullable: false, + optional: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + nullable: false, + optional: false, + format: "date-time", + }, + comment: { + type: "string", + nullable: false, + optional: false, + }, + resolved: { + type: "boolean", + nullable: false, + optional: false, + example: false, + }, + reporterId: { + type: "string", + nullable: false, + optional: false, + format: "id", + }, + targetUserId: { + type: "string", + nullable: false, + optional: false, + format: "id", + }, + assigneeId: { + type: "string", + nullable: true, + optional: false, + format: "id", + }, + reporter: { + type: "object", + nullable: false, + optional: false, + ref: "User", + }, + targetUser: { + type: "object", + nullable: false, + optional: false, + ref: "User", + }, + assignee: { + type: "object", + nullable: true, + optional: true, + ref: "User", + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + state: { type: "string", nullable: true, default: null }, + reporterOrigin: { + type: "string", + enum: ["combined", "local", "remote"], + default: "combined", + }, + targetUserOrigin: { + type: "string", + enum: ["combined", "local", "remote"], + default: "combined", + }, + forwarded: { type: "boolean", default: false }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps) => { + const query = makePaginationQuery( + AbuseUserReports.createQueryBuilder("report"), + ps.sinceId, + ps.untilId, + ); + + switch (ps.state) { + case "resolved": + query.andWhere("report.resolved = TRUE"); + break; + case "unresolved": + query.andWhere("report.resolved = FALSE"); + break; + } + + switch (ps.reporterOrigin) { + case "local": + query.andWhere("report.reporterHost IS NULL"); + break; + case "remote": + query.andWhere("report.reporterHost IS NOT NULL"); + break; + } + + switch (ps.targetUserOrigin) { + case "local": + query.andWhere("report.targetUserHost IS NULL"); + break; + case "remote": + query.andWhere("report.targetUserHost IS NOT NULL"); + break; + } + + const reports = await query.take(ps.limit).getMany(); + + return await AbuseUserReports.packMany(reports); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/create.ts b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts new file mode 100644 index 0000000..2e035d1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/accounts/create.ts @@ -0,0 +1,55 @@ +import define from "../../../define.js"; +import { Users } from "@/models/index.js"; +import { signup } from "../../../common/signup.js"; +import { IsNull } from "typeorm"; + +export const meta = { + tags: ["admin"], + + res: { + type: "object", + optional: false, + nullable: false, + ref: "User", + properties: { + token: { + type: "string", + optional: false, + nullable: false, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + username: Users.localUsernameSchema, + password: Users.passwordSchema, + }, + required: ["username", "password"], +} as const; + +export default define(meta, paramDef, async (ps, _me) => { + const me = _me ? await Users.findOneByOrFail({ id: _me.id }) : null; + const noUsers = + (await Users.countBy({ + host: IsNull(), + isAdmin: true, + })) === 0; + if (!(noUsers || me?.isAdmin)) throw new Error("access denied"); + + const { account, secret } = await signup({ + username: ps.username, + password: ps.password, + }); + + const res = await Users.pack(account, account, { + detail: true, + includeSecrets: true, + }); + + (res as any).token = secret; + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts new file mode 100644 index 0000000..3f7243a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts @@ -0,0 +1,58 @@ +import define from "../../../define.js"; +import { Users } from "@/models/index.js"; +import { doPostSuspend } from "@/services/suspend-user.js"; +import { publishUserEvent } from "@/services/stream.js"; +import { createDeleteAccountJob } from "@/queue/index.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error("user not found"); + } + + if (user.isAdmin) { + throw new Error("cannot suspend admin"); + } + + if (user.isModerator) { + throw new Error("cannot suspend moderator"); + } + + if (Users.isLocalUser(user)) { + // 物理削除する前にDelete activityを送信する + await doPostSuspend(user).catch((e) => {}); + + createDeleteAccountJob(user, { + soft: false, + }); + } else { + createDeleteAccountJob(user, { + soft: true, // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する + }); + } + + await Users.update(user.id, { + isDeleted: true, + }); + + if (Users.isLocalUser(user)) { + // Terminate streaming + publishUserEvent(user.id, "terminate", {}); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/hosted.ts b/packages/backend/src/server/api/endpoints/admin/accounts/hosted.ts new file mode 100644 index 0000000..a5423e1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/accounts/hosted.ts @@ -0,0 +1,126 @@ +import config from "@/config/index.js"; +import { Meta } from "@/models/entities/meta.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; +import { db } from "@/db/postgre.js"; +import define from "../../../define.js"; +import { Metas } from "@/models/index.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const hostedConfig = config.isManagedHosting; + const hosted = hostedConfig != null && hostedConfig === true; + if (hosted) { + const set = {} as Partial; + if (config.deepl.managed != null && config.deepl.managed === true) { + if (typeof config.deepl.authKey === "boolean") { + set.deeplAuthKey = config.deepl.authKey; + } + if (typeof config.deepl.isPro === "boolean") { + set.deeplIsPro = config.deepl.isPro; + } + } + if ( + config.libreTranslate.managed != null && + config.libreTranslate.managed === true + ) { + if (typeof config.libreTranslate.apiUrl === "string") { + set.libreTranslateApiUrl = config.libreTranslate.apiUrl; + } + if (typeof config.libreTranslate.apiKey === "string") { + set.libreTranslateApiKey = config.libreTranslate.apiKey; + } + } + if (config.email.managed != null && config.email.managed === true) { + set.enableEmail = true; + if (typeof config.email.address === "string") { + set.email = config.email.address; + } + if (typeof config.email.host === "string") { + set.smtpHost = config.email.host; + } + if (typeof config.email.port === "number") { + set.smtpPort = config.email.port; + } + if (typeof config.email.user === "string") { + set.smtpUser = config.email.user; + } + if (typeof config.email.pass === "string") { + set.smtpPass = config.email.pass; + } + if (typeof config.email.useImplicitSslTls === "boolean") { + set.smtpSecure = config.email.useImplicitSslTls; + } + } + if ( + config.objectStorage.managed != null && + config.objectStorage.managed === true + ) { + set.useObjectStorage = true; + if (typeof config.objectStorage.baseUrl === "string") { + set.objectStorageBaseUrl = config.objectStorage.baseUrl; + } + if (typeof config.objectStorage.bucket === "string") { + set.objectStorageBucket = config.objectStorage.bucket; + } + if (typeof config.objectStorage.prefix === "string") { + set.objectStoragePrefix = config.objectStorage.prefix; + } + if (typeof config.objectStorage.endpoint === "string") { + set.objectStorageEndpoint = config.objectStorage.endpoint; + } + if (typeof config.objectStorage.region === "string") { + set.objectStorageRegion = config.objectStorage.region; + } + if (typeof config.objectStorage.accessKey === "string") { + set.objectStorageAccessKey = config.objectStorage.accessKey; + } + if (typeof config.objectStorage.secretKey === "string") { + set.objectStorageSecretKey = config.objectStorage.secretKey; + } + if (typeof config.objectStorage.useSsl === "boolean") { + set.objectStorageUseSSL = config.objectStorage.useSsl; + } + if (typeof config.objectStorage.connnectOverProxy === "boolean") { + set.objectStorageUseProxy = config.objectStorage.connnectOverProxy; + } + if (typeof config.objectStorage.setPublicReadOnUpload === "boolean") { + set.objectStorageSetPublicRead = + config.objectStorage.setPublicReadOnUpload; + } + if (typeof config.objectStorage.s3ForcePathStyle === "boolean") { + set.objectStorageS3ForcePathStyle = + config.objectStorage.s3ForcePathStyle; + } + } + if (config.summalyProxyUrl !== undefined) { + set.summalyProxy = config.summalyProxyUrl; + } + + const meta = await Metas.findOne({ + where: {}, + order: { + id: "DESC", + }, + }); + + if (meta) + await Metas.update(meta.id, set); + else + await Metas.save(set); + + insertModerationLog(me, "updateMeta"); + } + return hosted; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/create.ts b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts new file mode 100644 index 0000000..13e2f5d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/announcements/create.ts @@ -0,0 +1,95 @@ +import define from "../../../define.js"; +import { Announcements } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { publishBroadcastStream } from "@/services/stream.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + updatedAt: { + type: "string", + optional: false, + nullable: true, + format: "date-time", + }, + title: { + type: "string", + optional: false, + nullable: false, + }, + text: { + type: "string", + optional: false, + nullable: false, + }, + imageUrl: { + type: "string", + optional: false, + nullable: true, + }, + showPopup: { + type: "boolean", + optional: true, + nullable: false, + }, + isGoodNews: { + type: "boolean", + optional: true, + nullable: false, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + title: { type: "string", minLength: 1 }, + text: { type: "string", minLength: 1 }, + imageUrl: { type: "string", nullable: true, minLength: 1 }, + showPopup: { type: "boolean" }, + isGoodNews: { type: "boolean" }, + }, + required: ["title", "text", "imageUrl"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const announcement = await Announcements.insert({ + id: genId(), + createdAt: new Date(), + updatedAt: null, + title: ps.title, + text: ps.text, + imageUrl: ps.imageUrl, + showPopup: ps.showPopup ?? false, + isGoodNews: ps.isGoodNews ?? false, + }).then((x) => Announcements.findOneByOrFail(x.identifiers[0])); + + publishBroadcastStream("announcementAdded", announcement); + + return Object.assign({}, announcement, { + createdAt: announcement.createdAt.toISOString(), + updatedAt: null, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts b/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts new file mode 100644 index 0000000..e4f26dd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/announcements/delete.ts @@ -0,0 +1,36 @@ +import define from "../../../define.js"; +import { Announcements } from "@/models/index.js"; +import { ApiError } from "../../../error.js"; +import { publishBroadcastStream } from "@/services/stream.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + errors: { + noSuchAnnouncement: { + message: "No such announcement.", + code: "NO_SUCH_ANNOUNCEMENT", + id: "ecad8040-a276-4e85-bda9-015a708d291e", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + id: { type: "string", format: "misskey:id" }, + }, + required: ["id"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const announcement = await Announcements.findOneBy({ id: ps.id }); + + if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); + + publishBroadcastStream("announcementDeleted", announcement.id); + await Announcements.delete(announcement.id); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/list.ts b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts new file mode 100644 index 0000000..e96517c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/announcements/list.ts @@ -0,0 +1,116 @@ +import { Announcements, AnnouncementReads } from "@/models/index.js"; +import type { Announcement } from "@/models/entities/announcement.js"; +import define from "../../../define.js"; +import { makePaginationQuery } from "../../../common/make-pagination-query.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + updatedAt: { + type: "string", + optional: false, + nullable: true, + format: "date-time", + }, + text: { + type: "string", + optional: false, + nullable: false, + }, + title: { + type: "string", + optional: false, + nullable: false, + }, + imageUrl: { + type: "string", + optional: false, + nullable: true, + }, + reads: { + type: "number", + optional: false, + nullable: false, + }, + showPopup: { + type: "boolean", + optional: true, + nullable: false, + }, + isGoodNews: { + type: "boolean", + optional: true, + nullable: false, + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps) => { + const query = makePaginationQuery( + Announcements.createQueryBuilder("announcement"), + ps.sinceId, + ps.untilId, + ); + + const announcements = await query.take(ps.limit).getMany(); + + const reads = new Map(); + + for (const announcement of announcements) { + reads.set( + announcement, + await AnnouncementReads.countBy({ + announcementId: announcement.id, + }), + ); + } + + return announcements.map((announcement) => ({ + id: announcement.id, + createdAt: announcement.createdAt.toISOString(), + updatedAt: announcement.updatedAt?.toISOString() ?? null, + title: announcement.title, + text: announcement.text, + imageUrl: announcement.imageUrl, + reads: reads.get(announcement)!, + showPopup: announcement.showPopup, + isGoodNews: announcement.isGoodNews, + })); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/announcements/update.ts b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts new file mode 100644 index 0000000..616b94d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/announcements/update.ts @@ -0,0 +1,46 @@ +import define from "../../../define.js"; +import { Announcements } from "@/models/index.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + errors: { + noSuchAnnouncement: { + message: "No such announcement.", + code: "NO_SUCH_ANNOUNCEMENT", + id: "d3aae5a7-6372-4cb4-b61c-f511ffc2d7cc", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + id: { type: "string", format: "misskey:id" }, + title: { type: "string", minLength: 1 }, + text: { type: "string", minLength: 1 }, + imageUrl: { type: "string", nullable: true, minLength: 1 }, + showPopup: { type: "boolean" }, + isGoodNews: { type: "boolean" }, + }, + required: ["id", "title", "text", "imageUrl"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const announcement = await Announcements.findOneBy({ id: ps.id }); + + if (announcement == null) throw new ApiError(meta.errors.noSuchAnnouncement); + + await Announcements.update(announcement.id, { + updatedAt: new Date(), + title: ps.title, + text: ps.text, + imageUrl: ps.imageUrl, + showPopup: ps.showPopup ?? false, + isGoodNews: ps.isGoodNews ?? false, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/create-backup.ts b/packages/backend/src/server/api/endpoints/admin/create-backup.ts new file mode 100644 index 0000000..e4dc61c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/create-backup.ts @@ -0,0 +1,235 @@ +import * as fs from "node:fs"; +import { mkdir, rm, stat, writeFile } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import archiver from "archiver"; +import config from "@/config/index.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; +import { createTempDir } from "@/misc/create-temp.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireAdmin: true, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + path: { + type: "string", + optional: false, + nullable: false, + }, + fileName: { + type: "string", + optional: false, + nullable: false, + }, + size: { + type: "number", + optional: false, + nullable: false, + }, + includedMedia: { + type: "boolean", + optional: false, + nullable: false, + }, + }, + }, + + errors: { + backupFailed: { + message: "Failed to create backup.", + code: "BACKUP_FAILED", + id: "7498ab9f-4e1d-40d0-96d8-d8d1d82bd621", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +const rootDir = resolve( + dirname(fileURLToPath(import.meta.url)), + "../../../../../../..", +); + +function timestamp(): string { + const now = new Date(); + const pad = (value: number) => value.toString().padStart(2, "0"); + return `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad( + now.getDate(), + )}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; +} + +function run(command: string, args: string[], env: NodeJS.ProcessEnv) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { + stdio: ["ignore", "ignore", "pipe"], + env, + }); + let stderr = ""; + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("exit", (code) => { + if (code === 0) { + resolvePromise(); + } else { + reject( + new Error( + `${command} exited with code ${code}: ${stderr.trim()}`, + ), + ); + } + }); + }); +} + +function archiveDirectory(sourceDir: string, outFile: string) { + return new Promise((resolvePromise, reject) => { + const output = fs.createWriteStream(outFile); + const archive = archiver("tar", { + gzip: true, + gzipOptions: { level: 6 }, + }); + + output.on("close", () => resolvePromise()); + archive.on("error", reject); + archive.pipe(output); + archive.directory(sourceDir, false); + archive.finalize(); + }); +} + +async function pathExists(path: string): Promise { + return stat(path) + .then(() => true) + .catch(() => false); +} + +export default define(meta, paramDef, async (_ps, me) => { + const [workDir, cleanup] = await createTempDir(); + const fileName = `iceshrimp-full-${timestamp()}.tar.gz`; + const outDir = resolve( + process.env.ICESHRIMP_BACKUP_DIR ?? `${rootDir}/backups`, + ); + const outFile = resolve(outDir, fileName); + const dbDumpPath = resolve(workDir, "database.dump"); + const mediaDir = resolve(config.mediaDir); + let includedMedia = false; + + try { + await mkdir(outDir, { recursive: true }); + + const env = { + ...process.env, + PGHOST: config.db.host, + PGPORT: String(config.db.port), + PGDATABASE: config.db.db, + PGUSER: config.db.user, + PGPASSWORD: config.db.pass, + }; + + await run( + "pg_dump", + [ + "--format=custom", + "--blobs", + "--no-owner", + "--file", + dbDumpPath, + config.db.db, + ], + env, + ); + + const configDir = resolve(workDir, "config"); + await mkdir(configDir, { recursive: true }); + const configFiles = [ + process.env.ICESHRIMP_CONFIG + ? resolve(process.env.ICESHRIMP_CONFIG) + : resolve(rootDir, ".config/default.yml"), + ...(process.env.ICESHRIMP_SECRETS + ? [resolve(process.env.ICESHRIMP_SECRETS)] + : []), + ]; + for (const configFile of configFiles) { + if (await pathExists(configFile)) { + fs.copyFileSync( + configFile, + resolve(configDir, configFile.split("/").pop()!), + ); + } + } + + if ( + (await pathExists(mediaDir)) && + !outDir.startsWith(mediaDir + "/") && + outDir !== mediaDir + ) { + fs.cpSync(mediaDir, resolve(workDir, "files"), { + recursive: true, + dereference: false, + errorOnExist: false, + }); + includedMedia = true; + } + + await writeFile( + resolve(workDir, "manifest.json"), + `${JSON.stringify( + { + type: "iceshrimp-full-backup", + version: config.version, + createdAt: new Date().toISOString(), + host: config.host, + database: config.db.db, + included: { + database: true, + config: true, + media: includedMedia, + }, + restore: "yarn full:restore ", + }, + null, + 2, + )}\n`, + "utf8", + ); + + await archiveDirectory(workDir, outFile); + const outStat = await stat(outFile); + + await insertModerationLog(me, "createBackup", { + path: outFile, + size: outStat.size, + includedMedia, + }); + + return { + path: outFile, + fileName, + size: outStat.size, + includedMedia, + }; + } catch (e) { + await rm(outFile, { force: true }).catch(() => {}); + throw new ApiError(meta.errors.backupFailed, { + message: e instanceof Error ? e.message : String(e), + }); + } finally { + cleanup(); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/delete-account.ts b/packages/backend/src/server/api/endpoints/admin/delete-account.ts new file mode 100644 index 0000000..9fd1968 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/delete-account.ts @@ -0,0 +1,29 @@ +import { Users } from "@/models/index.js"; +import { deleteAccount } from "@/services/delete-account.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireAdmin: true, + + res: {}, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const user = await Users.findOneByOrFail({ id: ps.userId }); + if (user.isDeleted) { + return; + } + + await deleteAccount(user); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts new file mode 100644 index 0000000..7969008 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/delete-all-files-of-a-user.ts @@ -0,0 +1,28 @@ +import define from "../../define.js"; +import { deleteFile } from "@/services/drive/delete-file.js"; +import { DriveFiles } from "@/models/index.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const files = await DriveFiles.findBy({ + userId: ps.userId, + }); + + for (const file of files) { + deleteFile(file); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts b/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts new file mode 100644 index 0000000..c8be344 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts @@ -0,0 +1,43 @@ +import define from "../../define.js"; +import { Users } from "@/models/index.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; +import { publishInternalEvent } from "@/services/stream.js"; +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + overrideMb: { type: "number", nullable: true }, + }, + required: ["userId", "overrideMb"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error("user not found"); + } + + if (!Users.isLocalUser(user)) { + throw new Error("user is not local user"); + } + + await Users.update(user.id, { + driveCapacityOverrideMb: ps.overrideMb, + }); + + publishInternalEvent("localUserUpdated", { + id: user.id, + }); + + insertModerationLog(me, "change-drive-capacity-override", { + targetId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts new file mode 100644 index 0000000..1b0c126 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive/clean-remote-files.ts @@ -0,0 +1,19 @@ +import define from "../../../define.js"; +import { createCleanRemoteFilesJob } from "@/queue/index.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + createCleanRemoteFilesJob(); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts b/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts new file mode 100644 index 0000000..04208f6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive/cleanup.ts @@ -0,0 +1,27 @@ +import { IsNull } from "typeorm"; +import define from "../../../define.js"; +import { deleteFile } from "@/services/drive/delete-file.js"; +import { DriveFiles } from "@/models/index.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const files = await DriveFiles.findBy({ + userId: IsNull(), + }); + + for (const file of files) { + deleteFile(file); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/files.ts b/packages/backend/src/server/api/endpoints/admin/drive/files.ts new file mode 100644 index 0000000..5cb0aec --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive/files.ts @@ -0,0 +1,89 @@ +import { DriveFiles } from "@/models/index.js"; +import define from "../../../define.js"; +import { makePaginationQuery } from "../../../common/make-pagination-query.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: false, + requireModerator: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "DriveFile", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + userId: { type: "string", format: "misskey:id", nullable: true }, + type: { + type: "string", + nullable: true, + pattern: /^[a-zA-Z0-9\/\-*]+$/.toString().slice(1, -1), + }, + origin: { + type: "string", + enum: ["combined", "local", "remote"], + default: "local", + }, + hostname: { + type: "string", + nullable: true, + default: null, + description: "The local host is represented with `null`.", + }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery( + DriveFiles.createQueryBuilder("file"), + ps.sinceId, + ps.untilId, + ); + + if (ps.userId) { + query.andWhere("file.userId = :userId", { userId: ps.userId }); + } else { + if (ps.origin === "local") { + query.andWhere("file.userHost IS NULL"); + } else if (ps.origin === "remote") { + query.andWhere("file.userHost IS NOT NULL"); + } + + if (ps.hostname) { + query.andWhere("file.userHost = :hostname", { hostname: ps.hostname }); + } + } + + if (ps.type) { + if (ps.type.endsWith("/*")) { + query.andWhere("file.type like :type", { + type: `${ps.type.replace("/*", "/")}%`, + }); + } else { + query.andWhere("file.type = :type", { type: ps.type }); + } + } + + const files = await query.take(ps.limit).getMany(); + + return await DriveFiles.packMany(files, { + detail: true, + withUser: true, + self: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts new file mode 100644 index 0000000..d65ec09 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts @@ -0,0 +1,225 @@ +import { DriveFiles } from "@/models/index.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + errors: { + noSuchFile: { + message: "No such file.", + code: "NO_SUCH_FILE", + id: "caf3ca38-c6e5-472e-a30c-b05377dcc240", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + userId: { + type: "string", + optional: false, + nullable: true, + format: "id", + example: "xxxxxxxxxx", + }, + userHost: { + type: "string", + optional: false, + nullable: true, + description: "The local host is represented with `null`.", + }, + md5: { + type: "string", + optional: false, + nullable: false, + format: "md5", + example: "15eca7fba0480996e2245f5185bf39f2", + }, + name: { + type: "string", + optional: false, + nullable: false, + example: "lenna.jpg", + }, + type: { + type: "string", + optional: false, + nullable: false, + example: "image/jpeg", + }, + size: { + type: "number", + optional: false, + nullable: false, + example: 51469, + }, + comment: { + type: "string", + optional: false, + nullable: true, + }, + blurhash: { + type: "string", + optional: false, + nullable: true, + }, + properties: { + type: "object", + optional: false, + nullable: false, + properties: { + width: { + type: "number", + optional: false, + nullable: false, + example: 1280, + }, + height: { + type: "number", + optional: false, + nullable: false, + example: 720, + }, + avgColor: { + type: "string", + optional: true, + nullable: false, + example: "rgb(40,65,87)", + }, + }, + }, + storedInternal: { + type: "boolean", + optional: false, + nullable: true, + example: true, + }, + url: { + type: "string", + optional: false, + nullable: true, + format: "url", + }, + thumbnailUrl: { + type: "string", + optional: false, + nullable: true, + format: "url", + }, + webpublicUrl: { + type: "string", + optional: false, + nullable: true, + format: "url", + }, + accessKey: { + type: "string", + optional: false, + nullable: false, + }, + thumbnailAccessKey: { + type: "string", + optional: false, + nullable: false, + }, + webpublicAccessKey: { + type: "string", + optional: false, + nullable: false, + }, + uri: { + type: "string", + optional: false, + nullable: true, + }, + src: { + type: "string", + optional: false, + nullable: true, + }, + folderId: { + type: "string", + optional: false, + nullable: true, + format: "id", + example: "xxxxxxxxxx", + }, + isSensitive: { + type: "boolean", + optional: false, + nullable: false, + }, + isLink: { + type: "boolean", + optional: false, + nullable: false, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + anyOf: [ + { + properties: { + fileId: { type: "string", format: "misskey:id" }, + }, + required: ["fileId"], + }, + { + properties: { + url: { type: "string" }, + }, + required: ["url"], + }, + ], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const file = ps.fileId + ? await DriveFiles.findOneBy({ id: ps.fileId }) + : await DriveFiles.findOne({ + where: [ + { + url: ps.url, + }, + { + thumbnailUrl: ps.url, + }, + { + webpublicUrl: ps.url, + }, + ], + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + + if (!me.isAdmin) { + file.requestIp = undefined; + file.requestHeaders = undefined; + } + + return file; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts new file mode 100644 index 0000000..1ea457a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add-aliases-bulk.ts @@ -0,0 +1,47 @@ +import define from "../../../define.js"; +import { Emojis } from "@/models/index.js"; +import { In } from "typeorm"; +import { ApiError } from "../../../error.js"; +import { db } from "@/db/postgre.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + ids: { + type: "array", + items: { + type: "string", + format: "misskey:id", + }, + }, + aliases: { + type: "array", + items: { + type: "string", + }, + }, + }, + required: ["ids", "aliases"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const emojis = await Emojis.findBy({ + id: In(ps.ids), + }); + + for (const emoji of emojis) { + await Emojis.update(emoji.id, { + updatedAt: new Date(), + aliases: [...new Set(emoji.aliases.concat(ps.aliases))], + }); + } + + await db.queryResultCache!.remove(["meta_emojis"]); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/add.ts b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts new file mode 100644 index 0000000..cf54dfe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/add.ts @@ -0,0 +1,74 @@ +import define from "../../../define.js"; +import { Emojis, DriveFiles } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; +import { ApiError } from "../../../error.js"; +import rndstr from "rndstr"; +import { publishBroadcastStream } from "@/services/stream.js"; +import { db } from "@/db/postgre.js"; +import { getEmojiSize } from "@/misc/emoji-meta.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + errors: { + noSuchFile: { + message: "No such file.", + code: "MO_SUCH_FILE", + id: "fc46b5a4-6b92-4c33-ac66-b806659bb5cf", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + fileId: { type: "string", format: "misskey:id" }, + }, + required: ["fileId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const file = await DriveFiles.findOneBy({ id: ps.fileId }); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + + const name = file.name.split(".")[0].match(/^[a-z0-9_]+$/) + ? file.name.split(".")[0] + : `_${rndstr("a-z0-9", 8)}_`; + + const size = await getEmojiSize(file.url); + + const emoji = await Emojis.insert({ + id: genId(), + updatedAt: new Date(), + name: name, + category: null, + host: null, + aliases: [], + originalUrl: file.url, + publicUrl: file.webpublicUrl ?? file.url, + type: file.webpublicType ?? file.type, + license: null, + glyph: file.type === "image/svg+xml", + width: size.width || null, + height: size.height || null, + }).then((x) => Emojis.findOneByOrFail(x.identifiers[0])); + + await db.queryResultCache!.remove(["meta_emojis"]); + + publishBroadcastStream("emojiAdded", { + emoji: await Emojis.pack(emoji.id), + }); + + insertModerationLog(me, "addEmoji", { + emojiId: emoji.id, + }); + + return { + id: emoji.id, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts new file mode 100644 index 0000000..d0f8275 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/copy.ts @@ -0,0 +1,94 @@ +import define from "../../../define.js"; +import { Emojis } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { ApiError } from "../../../error.js"; +import type { DriveFile } from "@/models/entities/drive-file.js"; +import { uploadFromUrl } from "@/services/drive/upload-from-url.js"; +import { publishBroadcastStream } from "@/services/stream.js"; +import { db } from "@/db/postgre.js"; +import { getEmojiSize } from "@/misc/emoji-meta.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + errors: { + noSuchEmoji: { + message: "No such emoji.", + code: "NO_SUCH_EMOJI", + id: "e2785b66-dca3-4087-9cac-b93c541cc425", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + emojiId: { type: "string", format: "misskey:id" }, + }, + required: ["emojiId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const emoji = await Emojis.findOneBy({ id: ps.emojiId }); + + if (emoji == null) { + throw new ApiError(meta.errors.noSuchEmoji); + } + + let driveFile: DriveFile; + + try { + // Create file + driveFile = await uploadFromUrl({ + url: emoji.originalUrl, + user: null, + force: true, + }); + } catch (e) { + throw new ApiError(); + } + + const size = await getEmojiSize(driveFile.url); + + const copied = await Emojis.insert({ + id: genId(), + updatedAt: new Date(), + name: emoji.name, + host: null, + aliases: [], + originalUrl: driveFile.url, + publicUrl: driveFile.webpublicUrl ?? driveFile.url, + type: driveFile.webpublicType ?? driveFile.type, + license: emoji.license, + glyph: emoji.glyph, + width: size.width || null, + height: size.height || null, + }).then((x) => Emojis.findOneByOrFail(x.identifiers[0])); + + await db.queryResultCache!.remove(["meta_emojis"]); + + publishBroadcastStream("emojiAdded", { + emoji: await Emojis.pack(copied.id), + }); + + return { + id: copied.id, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts new file mode 100644 index 0000000..585af23 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete-bulk.ts @@ -0,0 +1,43 @@ +import define from "../../../define.js"; +import { Emojis } from "@/models/index.js"; +import { In } from "typeorm"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; +import { ApiError } from "../../../error.js"; +import { db } from "@/db/postgre.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + ids: { + type: "array", + items: { + type: "string", + format: "misskey:id", + }, + }, + }, + required: ["ids"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const emojis = await Emojis.findBy({ + id: In(ps.ids), + }); + + for (const emoji of emojis) { + await Emojis.delete(emoji.id); + + await db.queryResultCache!.remove(["meta_emojis"]); + + insertModerationLog(me, "deleteEmoji", { + emoji: emoji, + }); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts new file mode 100644 index 0000000..761c7c3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/delete.ts @@ -0,0 +1,42 @@ +import define from "../../../define.js"; +import { Emojis } from "@/models/index.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; +import { ApiError } from "../../../error.js"; +import { db } from "@/db/postgre.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + errors: { + noSuchEmoji: { + message: "No such emoji.", + code: "NO_SUCH_EMOJI", + id: "be83669b-773a-44b7-b1f8-e5e5170ac3c2", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + id: { type: "string", format: "misskey:id" }, + }, + required: ["id"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const emoji = await Emojis.findOneBy({ id: ps.id }); + + if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); + + await Emojis.delete(emoji.id); + + await db.queryResultCache!.remove(["meta_emojis"]); + + insertModerationLog(me, "deleteEmoji", { + emoji: emoji, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts new file mode 100644 index 0000000..6f49d6d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/import-zip.ts @@ -0,0 +1,21 @@ +import define from "../../../define.js"; +import { createImportCustomEmojisJob } from "@/queue/index.js"; +import ms from "ms"; + +export const meta = { + secure: true, + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + fileId: { type: "string", format: "misskey:id" }, + }, + required: ["fileId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + createImportCustomEmojisJob(user, ps.fileId); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts new file mode 100644 index 0000000..00cd7b1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list-remote.ts @@ -0,0 +1,128 @@ +import define from "../../../define.js"; +import { Emojis } from "@/models/index.js"; +import { toPuny } from "@/misc/convert-host.js"; +import { makePaginationQuery } from "../../../common/make-pagination-query.js"; +import { sqlLikeEscape } from "@/misc/sql-like-escape.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + aliases: { + type: "array", + optional: false, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + name: { + type: "string", + optional: false, + nullable: false, + }, + category: { + type: "string", + optional: false, + nullable: true, + }, + host: { + type: "string", + optional: false, + nullable: true, + description: "The local host is represented with `null`.", + }, + url: { + type: "string", + optional: false, + nullable: false, + }, + license: { + type: "string", + optional: false, + nullable: true, + }, + glyph: { + type: "boolean", + optional: false, + nullable: false, + }, + glyphUrl: { + type: "string", + optional: false, + nullable: true, + }, + width: { + type: "number", + optional: false, + nullable: true, + }, + height: { + type: "number", + optional: false, + nullable: true, + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + query: { type: "string", nullable: true, default: null }, + host: { + type: "string", + nullable: true, + default: null, + description: "Use `null` to represent the local host.", + }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps) => { + const q = makePaginationQuery( + Emojis.createQueryBuilder("emoji"), + ps.sinceId, + ps.untilId, + ); + + if (ps.host == null) { + q.andWhere("emoji.host IS NOT NULL"); + } else { + q.andWhere("emoji.host = :host", { host: toPuny(ps.host) }); + } + + if (ps.query) { + q.andWhere("emoji.name like :query", { + query: `%${sqlLikeEscape(ps.query)}%`, + }); + } + + const emojis = await q.orderBy("emoji.id", "DESC").take(ps.limit).getMany(); + + return Emojis.packMany(emojis); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/list.ts b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts new file mode 100644 index 0000000..26c5d13 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/list.ts @@ -0,0 +1,128 @@ +import define from "../../../define.js"; +import { Emojis } from "@/models/index.js"; +import { makePaginationQuery } from "../../../common/make-pagination-query.js"; +import type { Emoji } from "@/models/entities/emoji.js"; +//import { sqlLikeEscape } from "@/misc/sql-like-escape.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + aliases: { + type: "array", + optional: false, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + name: { + type: "string", + optional: false, + nullable: false, + }, + category: { + type: "string", + optional: false, + nullable: true, + }, + host: { + type: "null", + optional: false, + description: + "The local host is represented with `null`. The field exists for compatibility with other API endpoints that return files.", + }, + url: { + type: "string", + optional: false, + nullable: false, + }, + license: { + type: "string", + optional: false, + nullable: true, + }, + glyph: { + type: "boolean", + optional: false, + nullable: false, + }, + glyphUrl: { + type: "string", + optional: false, + nullable: true, + }, + width: { + type: "number", + optional: false, + nullable: true, + }, + height: { + type: "number", + optional: false, + nullable: true, + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + query: { type: "string", nullable: true, default: null }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps) => { + const q = makePaginationQuery( + Emojis.createQueryBuilder("emoji"), + ps.sinceId, + ps.untilId, + ).andWhere("emoji.host IS NULL"); + + let emojis: Emoji[]; + + if (ps.query) { + //q.andWhere('emoji.name ILIKE :q', { q: `%${sqlLikeEscape(ps.query)}%` }); + //const emojis = await q.take(ps.limit).getMany(); + + emojis = await q.getMany(); + + emojis = emojis.filter( + (emoji) => + emoji.name.includes(ps.query!) || + emoji.aliases.some((a) => a.includes(ps.query!)) || + emoji.category?.includes(ps.query!), + ); + + emojis.splice(ps.limit + 1); + } else { + emojis = await q.take(ps.limit).getMany(); + } + + return Emojis.packMany(emojis); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts new file mode 100644 index 0000000..4e57fa3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/remove-aliases-bulk.ts @@ -0,0 +1,47 @@ +import define from "../../../define.js"; +import { Emojis } from "@/models/index.js"; +import { In } from "typeorm"; +import { ApiError } from "../../../error.js"; +import { db } from "@/db/postgre.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + ids: { + type: "array", + items: { + type: "string", + format: "misskey:id", + }, + }, + aliases: { + type: "array", + items: { + type: "string", + }, + }, + }, + required: ["ids", "aliases"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const emojis = await Emojis.findBy({ + id: In(ps.ids), + }); + + for (const emoji of emojis) { + await Emojis.update(emoji.id, { + updatedAt: new Date(), + aliases: emoji.aliases.filter((x) => !ps.aliases.includes(x)), + }); + } + + await db.queryResultCache!.remove(["meta_emojis"]); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts new file mode 100644 index 0000000..1197f60 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-aliases-bulk.ts @@ -0,0 +1,46 @@ +import define from "../../../define.js"; +import { Emojis } from "@/models/index.js"; +import { In } from "typeorm"; +import { ApiError } from "../../../error.js"; +import { db } from "@/db/postgre.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + ids: { + type: "array", + items: { + type: "string", + format: "misskey:id", + }, + }, + aliases: { + type: "array", + items: { + type: "string", + }, + }, + }, + required: ["ids", "aliases"], +} as const; + +export default define(meta, paramDef, async (ps) => { + await Emojis.update( + { + id: In(ps.ids), + }, + { + updatedAt: new Date(), + aliases: ps.aliases, + }, + ); + + await db.queryResultCache!.remove(["meta_emojis"]); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts new file mode 100644 index 0000000..17881a4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-category-bulk.ts @@ -0,0 +1,45 @@ +import define from "../../../define.js"; +import { Emojis } from "@/models/index.js"; +import { In } from "typeorm"; +import { ApiError } from "../../../error.js"; +import { db } from "@/db/postgre.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + ids: { + type: "array", + items: { + type: "string", + format: "misskey:id", + }, + }, + category: { + type: "string", + nullable: true, + description: "Use `null` to reset the category.", + }, + }, + required: ["ids"], +} as const; + +export default define(meta, paramDef, async (ps) => { + await Emojis.update( + { + id: In(ps.ids), + }, + { + updatedAt: new Date(), + category: ps.category, + }, + ); + + await db.queryResultCache!.remove(["meta_emojis"]); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts new file mode 100644 index 0000000..c98ca03 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/set-license-bulk.ts @@ -0,0 +1,45 @@ +import define from "../../../define.js"; +import { Emojis } from "@/models/index.js"; +import { In } from "typeorm"; +import { ApiError } from "../../../error.js"; +import { db } from "@/db/postgre.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + ids: { + type: "array", + items: { + type: "string", + format: "misskey:id", + }, + }, + license: { + type: "string", + nullable: true, + description: "Use `null` to reset the license.", + }, + }, + required: ["ids"], +} as const; + +export default define(meta, paramDef, async (ps) => { + await Emojis.update( + { + id: In(ps.ids), + }, + { + updatedAt: new Date(), + license: ps.license, + }, + ); + + await db.queryResultCache!.remove(["meta_emojis"]); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/emoji/update.ts b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts new file mode 100644 index 0000000..94fc6bf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/emoji/update.ts @@ -0,0 +1,61 @@ +import define from "../../../define.js"; +import { Emojis } from "@/models/index.js"; +import { ApiError } from "../../../error.js"; +import { db } from "@/db/postgre.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + errors: { + noSuchEmoji: { + message: "No such emoji.", + code: "NO_SUCH_EMOJI", + id: "684dec9d-a8c2-4364-9aa8-456c49cb1dc8", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + id: { type: "string", format: "misskey:id" }, + name: { type: "string" }, + category: { + type: "string", + nullable: true, + description: "Use `null` to reset the category.", + }, + aliases: { + type: "array", + items: { + type: "string", + }, + }, + license: { + type: "string", + nullable: true, + }, + glyph: { type: "boolean" }, + }, + required: ["id", "name", "aliases"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const emoji = await Emojis.findOneBy({ id: ps.id }); + + if (emoji == null) throw new ApiError(meta.errors.noSuchEmoji); + + await Emojis.update(emoji.id, { + updatedAt: new Date(), + name: ps.name, + category: ps.category, + aliases: ps.aliases, + license: ps.license, + ...(typeof ps.glyph === "boolean" ? { glyph: ps.glyph } : {}), + }); + + await db.queryResultCache!.remove(["meta_emojis"]); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts b/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts new file mode 100644 index 0000000..534f226 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/federation/delete-all-files.ts @@ -0,0 +1,28 @@ +import define from "../../../define.js"; +import { deleteFile } from "@/services/drive/delete-file.js"; +import { DriveFiles } from "@/models/index.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + host: { type: "string" }, + }, + required: ["host"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const files = await DriveFiles.findBy({ + userHost: ps.host, + }); + + for (const file of files) { + deleteFile(file); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts b/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts new file mode 100644 index 0000000..9c71655 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/federation/refresh-remote-instance-metadata.ts @@ -0,0 +1,29 @@ +import define from "../../../define.js"; +import { Instances } from "@/models/index.js"; +import { toPuny } from "@/misc/convert-host.js"; +import { fetchInstanceMetadata } from "@/services/fetch-instance-metadata.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + host: { type: "string" }, + }, + required: ["host"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const instance = await Instances.findOneBy({ host: toPuny(ps.host) }); + + if (instance == null) { + throw new Error("instance not found"); + } + + fetchInstanceMetadata(instance, true); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts new file mode 100644 index 0000000..a1ccf11 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/federation/remove-all-following.ts @@ -0,0 +1,37 @@ +import define from "../../../define.js"; +import deleteFollowing from "@/services/following/delete.js"; +import { Followings, Users } from "@/models/index.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + host: { type: "string" }, + }, + required: ["host"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const followings = await Followings.findBy({ + followerHost: ps.host, + }); + + const pairs = await Promise.all( + followings.map((f) => + Promise.all([ + Users.findOneByOrFail({ id: f.followerId }), + Users.findOneByOrFail({ id: f.followeeId }), + ]), + ), + ); + + for (const pair of pairs) { + deleteFollowing(pair[0], pair[1]); + } +}); diff --git a/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts new file mode 100644 index 0000000..016989b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/federation/update-instance.ts @@ -0,0 +1,34 @@ +import define from "../../../define.js"; +import { Instances } from "@/models/index.js"; +import { toPuny } from "@/misc/convert-host.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + host: { type: "string" }, + isSuspended: { type: "boolean" }, + }, + required: ["host", "isSuspended"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const instance = await Instances.findOneBy({ host: toPuny(ps.host) }); + + if (instance == null) { + throw new Error("instance not found"); + } + + Instances.update( + { host: toPuny(ps.host) }, + { + isSuspended: ps.isSuspended, + }, + ); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts b/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts new file mode 100644 index 0000000..f39a369 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/get-index-stats.ts @@ -0,0 +1,27 @@ +import define from "../../define.js"; +import { db } from "@/db/postgre.js"; + +export const meta = { + requireCredential: true, + requireModerator: true, + + tags: ["admin"], +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async () => { + const stats = await db.query("SELECT * FROM pg_indexes;").then((recs) => { + const res = [] as { tablename: string; indexname: string }[]; + for (const rec of recs) { + res.push(rec); + } + return res; + }); + + return stats; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts b/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts new file mode 100644 index 0000000..25d07f3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/get-table-stats.ts @@ -0,0 +1,49 @@ +import { db } from "@/db/postgre.js"; +import define from "../../define.js"; + +export const meta = { + requireCredential: true, + requireModerator: true, + + tags: ["admin"], + + res: { + type: "object", + optional: false, + nullable: false, + example: { + migrations: { + count: 66, + size: 32768, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async () => { + const sizes = await db + .query(` + SELECT relname AS "table", reltuples as "count", pg_total_relation_size(C.oid) AS "size" + FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) + WHERE nspname NOT IN ('pg_catalog', 'information_schema') + AND C.relkind <> 'i' + AND nspname !~ '^pg_toast';`) + .then((recs) => { + const res = {} as Record; + for (const rec of recs) { + res[rec.table] = { + count: parseInt(rec.count, 10), + size: parseInt(rec.size, 10), + }; + } + return res; + }); + + return sizes; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts b/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts new file mode 100644 index 0000000..da76ae6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts @@ -0,0 +1,30 @@ +import { UserIps } from "@/models/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const ips = await UserIps.find({ + where: { userId: ps.userId }, + order: { createdAt: "DESC" }, + take: 30, + }); + + return ips.map((x) => ({ + ip: x.ip, + createdAt: x.createdAt.toISOString(), + })); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/invite.ts b/packages/backend/src/server/api/endpoints/admin/invite.ts new file mode 100644 index 0000000..b8bdb38 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/invite.ts @@ -0,0 +1,50 @@ +import rndstr from "rndstr"; +import define from "../../define.js"; +import { RegistrationTickets } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + code: { + type: "string", + optional: false, + nullable: false, + example: "2ERUA5VR", + maxLength: 8, + minLength: 8, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async () => { + const code = rndstr({ + length: 8, + chars: "2-9A-HJ-NP-Z", // [0-9A-Z] w/o [01IO] (32 patterns) + }); + + await RegistrationTickets.insert({ + id: genId(), + createdAt: new Date(), + code, + }); + + return { + code, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts new file mode 100644 index 0000000..4b6559f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -0,0 +1,508 @@ +import config from "@/config/index.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { MAX_NOTE_TEXT_LENGTH, MAX_CAPTION_TEXT_LENGTH } from "@/const.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["meta"], + + requireCredential: true, + requireAdmin: true, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + driveCapacityPerLocalUserMb: { + type: "number", + optional: false, + nullable: false, + }, + driveCapacityPerRemoteUserMb: { + type: "number", + optional: false, + nullable: false, + }, + lua4frozenDatabaseCapacityMb: { + type: "number", + optional: false, + nullable: false, + }, + cacheRemoteFiles: { + type: "boolean", + optional: false, + nullable: false, + }, + emailRequiredForSignup: { + type: "boolean", + optional: false, + nullable: false, + }, + enableHcaptcha: { + type: "boolean", + optional: false, + nullable: false, + }, + hcaptchaSiteKey: { + type: "string", + optional: false, + nullable: true, + }, + enableRecaptcha: { + type: "boolean", + optional: false, + nullable: false, + }, + recaptchaSiteKey: { + type: "string", + optional: false, + nullable: true, + }, + swPublickey: { + type: "string", + optional: false, + nullable: true, + }, + mascotImageUrl: { + type: "string", + optional: false, + nullable: false, + default: "/twemoji/1f440.svg", + }, + bannerUrl: { + type: "string", + optional: false, + nullable: false, + }, + errorImageUrl: { + type: "string", + optional: false, + nullable: false, + default: "/twemoji/1f480.svg", + }, + iconUrl: { + type: "string", + optional: false, + nullable: true, + }, + maxNoteTextLength: { + type: "number", + optional: false, + nullable: false, + }, + maxCaptionTextLength: { + type: "number", + optional: false, + nullable: false, + }, + emojis: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + aliases: { + type: "array", + optional: false, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + category: { + type: "string", + optional: false, + nullable: true, + }, + host: { + type: "string", + optional: false, + nullable: true, + }, + url: { + type: "string", + optional: false, + nullable: false, + format: "url", + }, + }, + }, + }, + enableEmail: { + type: "boolean", + optional: false, + nullable: false, + }, + enableGithubIntegration: { + type: "boolean", + optional: false, + nullable: false, + }, + enableDiscordIntegration: { + type: "boolean", + optional: false, + nullable: false, + }, + translatorAvailable: { + type: "boolean", + optional: false, + nullable: false, + }, + recommendedInstances: { + type: "array", + optional: true, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + pinnedUsers: { + type: "array", + optional: true, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + customMOTD: { + type: "array", + optional: true, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + customSplashIcons: { + type: "array", + optional: true, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + hiddenTags: { + type: "array", + optional: true, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + blockedHosts: { + type: "array", + optional: true, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + silencedHosts: { + type: "array", + optional: true, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + allowedHosts: { + type: "array", + optional: true, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + privateMode: { + type: "boolean", + optional: false, + nullable: false, + }, + secureMode: { + type: "boolean", + optional: false, + nullable: false, + }, + hcaptchaSecretKey: { + type: "string", + optional: true, + nullable: true, + }, + recaptchaSecretKey: { + type: "string", + optional: true, + nullable: true, + }, + summaryProxy: { + type: "string", + optional: true, + nullable: true, + }, + email: { + type: "string", + optional: true, + nullable: true, + }, + smtpSecure: { + type: "boolean", + optional: true, + nullable: false, + }, + smtpHost: { + type: "string", + optional: true, + nullable: true, + }, + smtpPort: { + type: "string", + optional: true, + nullable: true, + }, + smtpUser: { + type: "string", + optional: true, + nullable: true, + }, + smtpPass: { + type: "string", + optional: true, + nullable: true, + }, + swPrivateKey: { + type: "string", + optional: true, + nullable: true, + }, + useObjectStorage: { + type: "boolean", + optional: true, + nullable: false, + }, + objectStorageBaseUrl: { + type: "string", + optional: true, + nullable: true, + }, + objectStorageBucket: { + type: "string", + optional: true, + nullable: true, + }, + objectStoragePrefix: { + type: "string", + optional: true, + nullable: true, + }, + objectStorageEndpoint: { + type: "string", + optional: true, + nullable: true, + }, + objectStorageRegion: { + type: "string", + optional: true, + nullable: true, + }, + objectStoragePort: { + type: "number", + optional: true, + nullable: true, + }, + objectStorageAccessKey: { + type: "string", + optional: true, + nullable: true, + }, + objectStorageSecretKey: { + type: "string", + optional: true, + nullable: true, + }, + objectStorageUseSSL: { + type: "boolean", + optional: true, + nullable: false, + }, + objectStorageUseProxy: { + type: "boolean", + optional: true, + nullable: false, + }, + objectStorageSetPublicRead: { + type: "boolean", + optional: true, + nullable: false, + }, + enableIpLogging: { + type: "boolean", + optional: true, + nullable: false, + }, + enableActiveEmailValidation: { + type: "boolean", + optional: true, + nullable: false, + }, + defaultReaction: { + type: "string", + optional: false, + nullable: false, + }, + experimentalFeatures: { + type: "object", + optional: true, + nullable: true, + properties: { + postImports: { + type: "boolean", + }, + }, + }, + enableServerMachineStats: { + type: "boolean", + optional: false, + nullable: false, + }, + enableIdenticonGeneration: { + type: "boolean", + optional: false, + nullable: false, + }, + donationLink: { + type: "string", + optional: true, + nullable: true, + }, + autofollowedAccount: { + type: "string", + optional: true, + nullable: true, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const instance = await fetchMeta(true); + + return { + maintainerName: instance.maintainerName, + maintainerEmail: instance.maintainerEmail, + version: config.version, + name: instance.name, + uri: config.url, + description: instance.description, + langs: instance.langs, + tosUrl: instance.ToSUrl, + repositoryUrl: instance.repositoryUrl, + feedbackUrl: instance.feedbackUrl, + disableRegistration: instance.disableRegistration, + disableLocalTimeline: instance.disableLocalTimeline, + disableRecommendedTimeline: instance.disableRecommendedTimeline, + disableGlobalTimeline: instance.disableGlobalTimeline, + driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, + driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, + lua4frozenDatabaseCapacityMb: instance.lua4frozenDatabaseCapacityMb, + emailRequiredForSignup: instance.emailRequiredForSignup, + enableHcaptcha: instance.enableHcaptcha, + hcaptchaSiteKey: instance.hcaptchaSiteKey, + enableRecaptcha: instance.enableRecaptcha, + recaptchaSiteKey: instance.recaptchaSiteKey, + swPublickey: instance.swPublicKey, + themeColor: instance.themeColor, + mascotImageUrl: instance.mascotImageUrl, + bannerUrl: instance.bannerUrl, + errorImageUrl: instance.errorImageUrl, + iconUrl: instance.iconUrl, + backgroundImageUrl: instance.backgroundImageUrl, + logoImageUrl: instance.logoImageUrl, + maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため + maxCaptionTextLength: MAX_CAPTION_TEXT_LENGTH, + defaultLightTheme: instance.defaultLightTheme, + defaultDarkTheme: instance.defaultDarkTheme, + enableEmail: instance.enableEmail, + translatorAvailable: + instance.deeplAuthKey != null || instance.libreTranslateApiUrl != null, + pinnedPages: instance.pinnedPages, + pinnedClipId: instance.pinnedClipId, + cacheRemoteFiles: instance.cacheRemoteFiles, + defaultReaction: instance.defaultReaction, + recommendedInstances: instance.recommendedInstances, + pinnedUsers: instance.pinnedUsers, + customMOTD: instance.customMOTD, + customSplashIcons: instance.customSplashIcons, + hiddenTags: instance.hiddenTags, + blockedHosts: instance.blockedHosts, + silencedHosts: instance.silencedHosts, + allowedHosts: instance.allowedHosts, + privateMode: instance.privateMode, + secureMode: instance.secureMode, + hcaptchaSecretKey: instance.hcaptchaSecretKey, + recaptchaSecretKey: instance.recaptchaSecretKey, + summalyProxy: instance.summalyProxy, + email: instance.email, + smtpSecure: instance.smtpSecure, + smtpHost: instance.smtpHost, + smtpPort: instance.smtpPort, + smtpUser: instance.smtpUser, + smtpPass: instance.smtpPass, + swPrivateKey: instance.swPrivateKey, + useObjectStorage: instance.useObjectStorage, + objectStorageBaseUrl: instance.objectStorageBaseUrl, + objectStorageBucket: instance.objectStorageBucket, + objectStoragePrefix: instance.objectStoragePrefix, + objectStorageEndpoint: instance.objectStorageEndpoint, + objectStorageRegion: instance.objectStorageRegion, + objectStoragePort: instance.objectStoragePort, + objectStorageAccessKey: instance.objectStorageAccessKey, + objectStorageSecretKey: instance.objectStorageSecretKey, + objectStorageUseSSL: instance.objectStorageUseSSL, + objectStorageUseProxy: instance.objectStorageUseProxy, + objectStorageSetPublicRead: instance.objectStorageSetPublicRead, + objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle, + deeplAuthKey: instance.deeplAuthKey, + deeplIsPro: instance.deeplIsPro, + libreTranslateApiUrl: instance.libreTranslateApiUrl, + libreTranslateApiKey: instance.libreTranslateApiKey, + enableIpLogging: instance.enableIpLogging, + enableActiveEmailValidation: instance.enableActiveEmailValidation, + experimentalFeatures: instance.experimentalFeatures, + enableServerMachineStats: instance.enableServerMachineStats, + enableIdenticonGeneration: instance.enableIdenticonGeneration, + donationLink: instance.donationLink, + autofollowedAccount: instance.autofollowedAccount, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/moderators/add.ts b/packages/backend/src/server/api/endpoints/admin/moderators/add.ts new file mode 100644 index 0000000..478f266 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/moderators/add.ts @@ -0,0 +1,39 @@ +import define from "../../../define.js"; +import { Users } from "@/models/index.js"; +import { publishInternalEvent } from "@/services/stream.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const user = await Users.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error("user not found"); + } + + if (user.isAdmin) { + throw new Error("cannot mark as moderator if admin user"); + } + + await Users.update(user.id, { + isModerator: true, + }); + + publishInternalEvent("userChangeModeratorState", { + id: user.id, + isModerator: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts b/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts new file mode 100644 index 0000000..a43cc0c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/moderators/remove.ts @@ -0,0 +1,35 @@ +import define from "../../../define.js"; +import { Users } from "@/models/index.js"; +import { publishInternalEvent } from "@/services/stream.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const user = await Users.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error("user not found"); + } + + await Users.update(user.id, { + isModerator: false, + }); + + publishInternalEvent("userChangeModeratorState", { + id: user.id, + isModerator: false, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/plans/create.ts b/packages/backend/src/server/api/endpoints/admin/plans/create.ts new file mode 100644 index 0000000..3770601 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/plans/create.ts @@ -0,0 +1,49 @@ +import define from "../../../define.js"; +import { Plans } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 128 }, + icon: { type: "string", minLength: 1, maxLength: 64 }, + description: { type: "string", maxLength: 512, default: "" }, + }, + required: ["name", "icon"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const name = ps.name.trim(); + const icon = ps.icon.trim(); + const description = ps.description.trim(); + + if (name === "") throw new Error("name is empty"); + if (icon === "") throw new Error("icon is empty"); + + const exists = await Plans.findOneBy({ name }); + if (exists) throw new Error("plan name already exists"); + + const plan = await Plans.insert({ + id: genId(), + createdAt: new Date(), + updatedAt: null, + name, + icon, + description, + }).then((x) => Plans.findOneByOrFail(x.identifiers[0])); + + insertModerationLog(me, "createPlan", { + planId: plan.id, + name, + }); + + return await Plans.pack(plan); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/plans/delete.ts b/packages/backend/src/server/api/endpoints/admin/plans/delete.ts new file mode 100644 index 0000000..778c40e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/plans/delete.ts @@ -0,0 +1,29 @@ +import define from "../../../define.js"; +import { Plans } from "@/models/index.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + planId: { type: "string", format: "misskey:id" }, + }, + required: ["planId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const plan = await Plans.findOneByOrFail({ id: ps.planId }); + + await Plans.delete(plan.id); + + insertModerationLog(me, "deletePlan", { + planId: plan.id, + name: plan.name, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/plans/list.ts b/packages/backend/src/server/api/endpoints/admin/plans/list.ts new file mode 100644 index 0000000..5b1e82f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/plans/list.ts @@ -0,0 +1,25 @@ +import define from "../../../define.js"; +import { Plans } from "@/models/index.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async () => { + const plans = await Plans.find({ + order: { + createdAt: "ASC", + }, + }); + + return await Plans.packMany(plans); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/plans/update.ts b/packages/backend/src/server/api/endpoints/admin/plans/update.ts new file mode 100644 index 0000000..64fa5c8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/plans/update.ts @@ -0,0 +1,48 @@ +import define from "../../../define.js"; +import { Plans } from "@/models/index.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + planId: { type: "string", format: "misskey:id" }, + name: { type: "string", minLength: 1, maxLength: 128 }, + icon: { type: "string", minLength: 1, maxLength: 64 }, + description: { type: "string", maxLength: 512, default: "" }, + }, + required: ["planId", "name", "icon"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const plan = await Plans.findOneByOrFail({ id: ps.planId }); + const name = ps.name.trim(); + const icon = ps.icon.trim(); + const description = ps.description.trim(); + + if (name === "") throw new Error("name is empty"); + if (icon === "") throw new Error("icon is empty"); + + const exists = await Plans.findOneBy({ name }); + if (exists && exists.id !== plan.id) throw new Error("plan name already exists"); + + await Plans.update(plan.id, { + updatedAt: new Date(), + name, + icon, + description, + }); + + insertModerationLog(me, "updatePlan", { + planId: plan.id, + name, + }); + + return await Plans.pack(plan.id); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/promo/create.ts b/packages/backend/src/server/api/endpoints/admin/promo/create.ts new file mode 100644 index 0000000..37716af --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/promo/create.ts @@ -0,0 +1,72 @@ +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { Notes, PromoNotes } from "@/models/index.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "ee449fbe-af2a-453b-9cae-cf2fe7c895fc", + }, + + alreadyPromoted: { + message: "The note has already promoted.", + code: "ALREADY_PROMOTED", + id: "ae427aa2-7a41-484f-a18c-2c1104051604", + }, + notAdService: { + message: "The note must have #AdService.", + code: "NOT_AD_SERVICE", + id: "970a4d57-7b67-4540-80ea-f210041724ef", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + expiresAt: { type: "integer" }, + credits: { type: "integer", minimum: 1, maximum: 100000, default: 1 }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const note = await Notes.findOneBy({ id: ps.noteId }); + + if (note == null) { + throw new ApiError(meta.errors.noSuchNote); + } + + if (!note.tags.includes("adservice")) { + throw new ApiError(meta.errors.notAdService); + } + + const expiresAt = ps.expiresAt ? new Date(ps.expiresAt) : new Date(Date.now() + 30 * 86400000); + const credits = ps.credits ?? 1; + const exist = await PromoNotes.findOneBy({ noteId: note.id }); + + if (exist) { + await PromoNotes.update(note.id, { + expiresAt: exist.expiresAt.getTime() > expiresAt.getTime() ? exist.expiresAt : expiresAt, + totalCredits: exist.totalCredits + credits, + remainingCredits: exist.remainingCredits + credits, + }); + return; + } + + await PromoNotes.insert({ + noteId: note.id, + expiresAt, + totalCredits: credits, + remainingCredits: credits, + userId: note.userId, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/promo/list.ts b/packages/backend/src/server/api/endpoints/admin/promo/list.ts new file mode 100644 index 0000000..c965232 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/promo/list.ts @@ -0,0 +1,57 @@ +import rndstr from "rndstr"; +import { Notes, PromoNotes } from "@/models/index.js"; +import define from "../../../define.js"; +import { makePaginationQuery } from "../../../common/make-pagination-query.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ) + .andWhere(`'adservice' = ANY(note.tags)`) + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser"); + + const notes = await query.take(ps.limit).getMany(); + if (notes.length === 0) return []; + + const promos = await PromoNotes.findBy(notes.map((note) => ({ noteId: note.id }))); + + return Promise.all(notes.map(async (note) => { + const promo = promos.find((item) => item.noteId === note.id); + const expiredCredits = promo && promo.expiresAt.getTime() <= Date.now() + ? promo.remainingCredits + : 0; + (note as any)._prId_ = rndstr("a-z0-9", 8); + + return { + id: note.id, + note: await Notes.pack(note, user), + expiresAt: promo?.expiresAt.toISOString() ?? null, + totalCredits: promo?.totalCredits ?? 0, + remainingCredits: promo?.remainingCredits ?? 0, + expiredCredits, + }; + })); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/clear.ts b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts new file mode 100644 index 0000000..9b828bb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/clear.ts @@ -0,0 +1,22 @@ +import define from "../../../define.js"; +import { destroy } from "@/queue/index.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + destroy(); + + insertModerationLog(me, "clearQueue"); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts new file mode 100644 index 0000000..15fdfb0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/deliver-delayed.ts @@ -0,0 +1,57 @@ +import { deliverQueue } from "@/queue/queues.js"; +import { URL } from "node:url"; +import define from "../../../define.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "array", + optional: false, + nullable: false, + items: { + anyOf: [ + { + type: "string", + }, + { + type: "number", + }, + ], + }, + }, + example: [["example.com", 12]], + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps) => { + const jobs = await deliverQueue.getJobs(["delayed"]); + + const res = [] as [string, number][]; + + for (const job of jobs) { + const host = new URL(job.data.to).host; + if (res.find((x) => x[0] === host)) { + res.find((x) => x[0] === host)![1]++; + } else { + res.push([host, 1]); + } + } + + res.sort((a, b) => b[1] - a[1]); + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts new file mode 100644 index 0000000..1890bd4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/inbox-delayed.ts @@ -0,0 +1,57 @@ +import { URL } from "node:url"; +import define from "../../../define.js"; +import { inboxQueue } from "@/queue/queues.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "array", + optional: false, + nullable: false, + items: { + anyOf: [ + { + type: "string", + }, + { + type: "number", + }, + ], + }, + }, + example: [["example.com", 12]], + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps) => { + const jobs = await inboxQueue.getJobs(["delayed"]); + + const res = [] as [string, number][]; + + for (const job of jobs) { + const host = new URL(job.data.signature.keyId).host; + if (res.find((x) => x[0] === host)) { + res.find((x) => x[0] === host)![1]++; + } else { + res.push([host, 1]); + } + } + + res.sort((a, b) => b[1] - a[1]); + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/queue/stats.ts b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts new file mode 100644 index 0000000..ecd67d8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/queue/stats.ts @@ -0,0 +1,62 @@ +import { + deliverQueue, + inboxQueue, + dbQueue, + objectStorageQueue, +} from "@/queue/queues.js"; +import define from "../../../define.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + deliver: { + optional: false, + nullable: false, + ref: "QueueCount", + }, + inbox: { + optional: false, + nullable: false, + ref: "QueueCount", + }, + db: { + optional: false, + nullable: false, + ref: "QueueCount", + }, + objectStorage: { + optional: false, + nullable: false, + ref: "QueueCount", + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps) => { + const deliverJobCounts = await deliverQueue.getJobCounts(); + const inboxJobCounts = await inboxQueue.getJobCounts(); + const dbJobCounts = await dbQueue.getJobCounts(); + const objectStorageJobCounts = await objectStorageQueue.getJobCounts(); + + return { + deliver: deliverJobCounts, + inbox: inboxJobCounts, + db: dbJobCounts, + objectStorage: objectStorageJobCounts, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/relays/add.ts b/packages/backend/src/server/api/endpoints/admin/relays/add.ts new file mode 100644 index 0000000..bb56216 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/relays/add.ts @@ -0,0 +1,65 @@ +import { URL } from "node:url"; +import define from "../../../define.js"; +import { addRelay } from "@/services/relay.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + errors: { + invalidUrl: { + message: "Invalid URL", + code: "INVALID_URL", + id: "fb8c92d3-d4e5-44e7-b3d4-800d5cef8b2c", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + inbox: { + description: "URL of the inbox, must be a https scheme URL", + type: "string", + optional: false, + nullable: false, + format: "url", + }, + status: { + type: "string", + optional: false, + nullable: false, + default: "requesting", + enum: ["requesting", "accepted", "rejected"], + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + inbox: { type: "string" }, + }, + required: ["inbox"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + try { + if (new URL(ps.inbox).protocol !== "https:") throw new Error("https only"); + } catch { + throw new ApiError(meta.errors.invalidUrl); + } + + return await addRelay(ps.inbox); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/relays/list.ts b/packages/backend/src/server/api/endpoints/admin/relays/list.ts new file mode 100644 index 0000000..4c294ba --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/relays/list.ts @@ -0,0 +1,51 @@ +import define from "../../../define.js"; +import { listRelay } from "@/services/relay.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + inbox: { + type: "string", + optional: false, + nullable: false, + format: "url", + }, + status: { + type: "string", + optional: false, + nullable: false, + default: "requesting", + enum: ["requesting", "accepted", "rejected"], + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + return await listRelay(); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/relays/remove.ts b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts new file mode 100644 index 0000000..1b3d906 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/relays/remove.ts @@ -0,0 +1,21 @@ +import define from "../../../define.js"; +import { removeRelay } from "@/services/relay.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + inbox: { type: "string" }, + }, + required: ["inbox"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + return await removeRelay(ps.inbox); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/reset-password.ts b/packages/backend/src/server/api/endpoints/admin/reset-password.ts new file mode 100644 index 0000000..cbe6735 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/reset-password.ts @@ -0,0 +1,66 @@ +import define from "../../define.js"; +// import bcrypt from "bcryptjs"; +import rndstr from "rndstr"; +import { Users, UserProfiles } from "@/models/index.js"; +import { hashPassword } from "@/misc/password.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + password: { + type: "string", + optional: false, + nullable: false, + minLength: 8, + maxLength: 8, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const user = await Users.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error("user not found"); + } + + if (user.isAdmin) { + throw new Error("cannot reset password of admin"); + } + + const passwd = rndstr("a-zA-Z0-9", 8); + + // Generate hash of password + // const hash = bcrypt.hashSync(passwd); + const hash = await hashPassword(passwd); + + await UserProfiles.update( + { + userId: user.id, + }, + { + password: hash, + }, + ); + + return { + password: passwd, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts new file mode 100644 index 0000000..c876a21 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/resolve-abuse-user-report.ts @@ -0,0 +1,47 @@ +import define from "../../define.js"; +import { AbuseUserReports, Users } from "@/models/index.js"; +import { getInstanceActor } from "@/services/instance-actor.js"; +import { deliver } from "@/queue/index.js"; +import { renderActivity } from "@/remote/activitypub/renderer/index.js"; +import { renderFlag } from "@/remote/activitypub/renderer/flag.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + reportId: { type: "string", format: "misskey:id" }, + forward: { type: "boolean", default: false }, + }, + required: ["reportId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const report = await AbuseUserReports.findOneByOrFail({ id: ps.reportId }); + + if (report == null) { + throw new Error("report not found"); + } + + if (ps.forward && report.targetUserHost != null) { + const actor = await getInstanceActor(); + const targetUser = await Users.findOneByOrFail({ id: report.targetUserId }); + + deliver( + actor, + renderActivity(renderFlag(actor, [targetUser.uri!], report.comment)), + targetUser.inbox, + ); + } + + await AbuseUserReports.update(report.id, { + resolved: true, + assigneeId: me.id, + forwarded: ps.forward && report.targetUserHost != null, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/resolve-verified-badge-request.ts b/packages/backend/src/server/api/endpoints/admin/resolve-verified-badge-request.ts new file mode 100644 index 0000000..d1bae09 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/resolve-verified-badge-request.ts @@ -0,0 +1,46 @@ +import define from "../../define.js"; +import { Users, VerifiedBadgeRequests } from "@/models/index.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + requestId: { type: "string", format: "misskey:id" }, + approve: { type: "boolean" }, + }, + required: ["requestId", "approve"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const request = await VerifiedBadgeRequests.findOneByOrFail({ + id: ps.requestId, + }); + + if (request.status !== "pending") { + throw new Error("request already resolved"); + } + + if (ps.approve) { + await Users.update(request.userId, { + isVerified: true, + }); + } + + await VerifiedBadgeRequests.update(request.id, { + status: ps.approve ? "approved" : "rejected", + resolvedAt: new Date(), + resolverId: me.id, + }); + + insertModerationLog(me, ps.approve ? "approveVerifiedBadgeRequest" : "rejectVerifiedBadgeRequest", { + targetId: request.userId, + requestId: request.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/send-email.ts b/packages/backend/src/server/api/endpoints/admin/send-email.ts new file mode 100644 index 0000000..1676f68 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/send-email.ts @@ -0,0 +1,23 @@ +import define from "../../define.js"; +import { sendEmail } from "@/services/send-email.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + to: { type: "string" }, + subject: { type: "string" }, + text: { type: "string" }, + }, + required: ["to", "subject", "text"], +} as const; + +export default define(meta, paramDef, async (ps) => { + await sendEmail(ps.to, ps.subject, ps.text, ps.text); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/send-mod-mail.ts b/packages/backend/src/server/api/endpoints/admin/send-mod-mail.ts new file mode 100644 index 0000000..82a1dc2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/send-mod-mail.ts @@ -0,0 +1,69 @@ +import * as sanitizeHtml from "sanitize-html"; +import define from "../../define.js"; +import { Users, UserProfiles } from "@/models/index.js"; +import { ApiError } from "../../error.js"; +import { sendEmail } from "@/services/send-email.js"; +import { createNotification } from "@/services/create-notification.js"; +import config from "@/config/index.js"; + +export const meta = { + tags: ["users"], + + requireCredential: true, + requireModerator: true, + + description: "Send a moderation notice.", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "1acefcb5-0959-43fd-9685-b48305736cb5", + }, + noEmail: { + message: "No email for user.", + code: "NO_EMAIL", + id: "ac9d2d22-ef73-11ed-a05b-0242ac120003", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + comment: { type: "string", minLength: 1, maxLength: 2048 }, + }, + required: ["userId", "comment"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const [user, profile] = await Promise.all([ + Users.findOneBy({ id: ps.userId }), + UserProfiles.findOneBy({ userId: ps.userId }), + ]); + + if (user == null || profile == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + createNotification(user.id, "app", { + customBody: ps.comment, + customHeader: "Moderation Notice", + customIcon: config?.images?.info, + }); + + setImmediate(async () => { + const email = profile.email; + if (email == null) { + throw new ApiError(meta.errors.noEmail); + } + + sendEmail( + email, + "Moderation notice", + sanitizeHtml(ps.comment), + sanitizeHtml(ps.comment), + ); + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/server-info.ts b/packages/backend/src/server/api/endpoints/admin/server-info.ts new file mode 100644 index 0000000..8998032 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/server-info.ts @@ -0,0 +1,143 @@ +import * as os from "node:os"; +import si from "systeminformation"; +import define from "../../define.js"; +import { redisClient } from "../../../../db/redis.js"; +import { db } from "@/db/postgre.js"; + +export const meta = { + requireCredential: true, + requireModerator: true, + + tags: ["admin", "meta"], + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + machine: { + type: "string", + optional: false, + nullable: false, + }, + os: { + type: "string", + optional: false, + nullable: false, + example: "linux", + }, + node: { + type: "string", + optional: false, + nullable: false, + }, + psql: { + type: "string", + optional: false, + nullable: false, + }, + cpu: { + type: "object", + optional: false, + nullable: false, + properties: { + model: { + type: "string", + optional: false, + nullable: false, + }, + cores: { + type: "number", + optional: false, + nullable: false, + }, + }, + }, + mem: { + type: "object", + optional: false, + nullable: false, + properties: { + total: { + type: "number", + optional: false, + nullable: false, + format: "bytes", + }, + }, + }, + fs: { + type: "object", + optional: false, + nullable: false, + properties: { + total: { + type: "number", + optional: false, + nullable: false, + format: "bytes", + }, + used: { + type: "number", + optional: false, + nullable: false, + format: "bytes", + }, + }, + }, + net: { + type: "object", + optional: false, + nullable: false, + properties: { + interface: { + type: "string", + optional: false, + nullable: false, + example: "eth0", + }, + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async () => { + const memStats = await si.mem(); + const fsStats = await si.fsSize(); + const netInterface = await si.networkInterfaceDefault(); + + const redisServerInfo = await redisClient.info("Server"); + const m = redisServerInfo.match(new RegExp("^redis_version:(.*)", "m")); + const redis_version = m?.[1]; + + return { + machine: os.hostname(), + os: os.platform(), + node: process.version, + psql: await db + .query("SHOW server_version") + .then((x) => x[0].server_version), + redis: redis_version, + cpu: { + model: os.cpus()[0].model, + cores: os.cpus().length, + }, + mem: { + total: memStats.total, + }, + fs: { + total: fsStats[0].size, + used: fsStats[0].used, + }, + net: { + interface: netInterface, + }, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/set-user-verified.ts b/packages/backend/src/server/api/endpoints/admin/set-user-verified.ts new file mode 100644 index 0000000..e10a660 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/set-user-verified.ts @@ -0,0 +1,35 @@ +import define from "../../define.js"; +import { Users } from "@/models/index.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + isVerified: { type: "boolean" }, + }, + required: ["userId", "isVerified"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error("user not found"); + } + + await Users.update(user.id, { + isVerified: ps.isVerified, + }); + + insertModerationLog(me, ps.isVerified ? "markAsVerified" : "unmarkAsVerified", { + targetId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts b/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts new file mode 100644 index 0000000..df7e897 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/show-moderation-logs.ts @@ -0,0 +1,79 @@ +import define from "../../define.js"; +import { ModerationLogs } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + type: { + type: "string", + optional: false, + nullable: false, + }, + info: { + type: "object", + optional: false, + nullable: false, + }, + userId: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + user: { + type: "object", + optional: false, + nullable: false, + ref: "UserDetailed", + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps) => { + const query = makePaginationQuery( + ModerationLogs.createQueryBuilder("report"), + ps.sinceId, + ps.untilId, + ); + + const reports = await query.take(ps.limit).getMany(); + + return await ModerationLogs.packMany(reports); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts new file mode 100644 index 0000000..9daa4ba --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -0,0 +1,80 @@ +import { Signins, UserProfiles, Users } from "@/models/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + res: { + type: "object", + nullable: false, + optional: false, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const [user, profile] = await Promise.all([ + Users.findOneBy({ id: ps.userId }), + UserProfiles.findOneBy({ userId: ps.userId }), + ]); + + if (user == null || profile == null) { + throw new Error("user not found"); + } + + const _me = await Users.findOneByOrFail({ id: me.id }); + if (_me.isModerator && !_me.isAdmin && user.isAdmin) { + throw new Error("cannot show info of admin"); + } + + if (!_me.isAdmin) { + return { + isModerator: user.isModerator, + isSilenced: user.isSilenced, + isSuspended: user.isSuspended, + moderationNote: profile.moderationNote, + }; + } + + const maskedKeys = ["accessToken", "accessTokenSecret", "refreshToken"]; + Object.keys(profile.integrations).forEach((integration) => { + maskedKeys.forEach( + (key) => (profile.integrations[integration][key] = ""), + ); + }); + + const signins = await Signins.findBy({ userId: user.id }); + + return { + email: profile.email, + emailVerified: profile.emailVerified, + autoAcceptFollowed: profile.autoAcceptFollowed, + noCrawle: profile.noCrawle, + preventAiLearning: profile.preventAiLearning, + alwaysMarkNsfw: profile.alwaysMarkNsfw, + carefulBot: profile.carefulBot, + injectFeaturedNote: profile.injectFeaturedNote, + receiveAnnouncementEmail: profile.receiveAnnouncementEmail, + integrations: profile.integrations, + mutedWords: profile.mutedWords, + mutedInstances: profile.mutedInstances, + mutingNotificationTypes: profile.mutingNotificationTypes, + isModerator: user.isModerator, + isVerified: user.isVerified, + isSilenced: user.isSilenced, + isSuspended: user.isSuspended, + lastActiveDate: user.lastActiveDate, + moderationNote: profile.moderationNote, + signins, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/admin/show-users.ts b/packages/backend/src/server/api/endpoints/admin/show-users.ts new file mode 100644 index 0000000..302db8b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/show-users.ts @@ -0,0 +1,154 @@ +import { Users } from "@/models/index.js"; +import define from "../../define.js"; +import { sqlLikeEscape } from "@/misc/sql-like-escape.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, + + res: { + type: "array", + nullable: false, + optional: false, + items: { + type: "object", + nullable: false, + optional: false, + ref: "UserDetailed", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + offset: { type: "integer", default: 0 }, + sort: { + type: "string", + enum: [ + "+follower", + "-follower", + "+createdAt", + "-createdAt", + "+updatedAt", + "-updatedAt", + ], + }, + state: { + type: "string", + enum: [ + "all", + "alive", + "available", + "admin", + "moderator", + "adminOrModerator", + "silenced", + "suspended", + "verified", + ], + default: "all", + }, + origin: { + type: "string", + enum: ["combined", "local", "remote"], + default: "combined", + }, + username: { type: "string", nullable: true, default: null }, + hostname: { + type: "string", + nullable: true, + default: null, + description: "The local host is represented with `null`.", + }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = Users.createQueryBuilder("user"); + + switch (ps.state) { + case "available": + query.where("user.isSuspended = FALSE"); + break; + case "admin": + query.where("user.isAdmin = TRUE"); + break; + case "moderator": + query.where("user.isModerator = TRUE"); + break; + case "adminOrModerator": + query.where("user.isAdmin = TRUE OR user.isModerator = TRUE"); + break; + case "alive": + query.where("user.updatedAt > :date", { + date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5), + }); + break; + case "silenced": + query.where("user.isSilenced = TRUE"); + break; + case "suspended": + query.where("user.isSuspended = TRUE"); + break; + case "verified": + query.where("user.isVerified = TRUE"); + break; + } + + switch (ps.origin) { + case "local": + query.andWhere("user.host IS NULL"); + break; + case "remote": + query.andWhere("user.host IS NOT NULL"); + break; + } + + if (ps.username) { + query.andWhere("user.usernameLower like :username", { + username: `${sqlLikeEscape(ps.username.toLowerCase())}%`, + }); + } + + if (ps.hostname) { + query.andWhere("user.host = :hostname", { + hostname: ps.hostname.toLowerCase(), + }); + } + + switch (ps.sort) { + case "+follower": + query.orderBy("user.followersCount", "DESC"); + break; + case "-follower": + query.orderBy("user.followersCount", "ASC"); + break; + case "+createdAt": + query.orderBy("user.createdAt", "DESC"); + break; + case "-createdAt": + query.orderBy("user.createdAt", "ASC"); + break; + case "+updatedAt": + query.orderBy("user.updatedAt", "DESC", "NULLS LAST"); + break; + case "-updatedAt": + query.orderBy("user.updatedAt", "ASC", "NULLS FIRST"); + break; + default: + query.orderBy("user.id", "ASC"); + break; + } + + query.take(ps.limit); + query.skip(ps.offset); + + const users = await query.getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/silence-user.ts b/packages/backend/src/server/api/endpoints/admin/silence-user.ts new file mode 100644 index 0000000..a618232 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/silence-user.ts @@ -0,0 +1,44 @@ +import define from "../../define.js"; +import { Users } from "@/models/index.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; +import { publishInternalEvent } from "@/services/stream.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error("user not found"); + } + + if (user.isAdmin) { + throw new Error("cannot silence admin"); + } + + await Users.update(user.id, { + isSilenced: true, + }); + + publishInternalEvent("userChangeSilencedState", { + id: user.id, + isSilenced: true, + }); + + insertModerationLog(me, "silence", { + targetId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts new file mode 100644 index 0000000..984bc07 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts @@ -0,0 +1,87 @@ +import define from "../../define.js"; +import deleteFollowing from "@/services/following/delete.js"; +import { Users, Followings, Notifications } from "@/models/index.js"; +import type { User } from "@/models/entities/user.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; +import { doPostSuspend } from "@/services/suspend-user.js"; +import { publishUserEvent } from "@/services/stream.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error("user not found"); + } + + if (user.isAdmin) { + throw new Error("cannot suspend admin"); + } + + if (user.isModerator) { + throw new Error("cannot suspend moderator"); + } + + await Users.update(user.id, { + isSuspended: true, + }); + + insertModerationLog(me, "suspend", { + targetId: user.id, + }); + + // Terminate streaming + if (Users.isLocalUser(user)) { + publishUserEvent(user.id, "terminate", {}); + } + + (async () => { + await doPostSuspend(user).catch((e) => {}); + await unFollowAll(user).catch((e) => {}); + await readAllNotify(user).catch((e) => {}); + })(); +}); + +async function unFollowAll(follower: User) { + const followings = await Followings.findBy({ + followerId: follower.id, + }); + + for (const following of followings) { + const followee = await Users.findOneBy({ + id: following.followeeId, + }); + + if (followee == null) { + throw new Error(`Cant find followee ${following.followeeId}`); + } + + await deleteFollowing(follower, followee, true); + } +} + +async function readAllNotify(notifier: User) { + await Notifications.update( + { + notifierId: notifier.id, + isRead: false, + }, + { + isRead: true, + }, + ); +} diff --git a/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts new file mode 100644 index 0000000..6a01b8e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/unsilence-user.ts @@ -0,0 +1,40 @@ +import define from "../../define.js"; +import { Users } from "@/models/index.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; +import { publishInternalEvent } from "@/services/stream.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error("user not found"); + } + + await Users.update(user.id, { + isSilenced: false, + }); + + publishInternalEvent("userChangeSilencedState", { + id: user.id, + isSilenced: false, + }); + + insertModerationLog(me, "unsilence", { + targetId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts new file mode 100644 index 0000000..e51d585 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts @@ -0,0 +1,37 @@ +import define from "../../define.js"; +import { Users } from "@/models/index.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; +import { doPostUnsuspend } from "@/services/unsuspend-user.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error("user not found"); + } + + await Users.update(user.id, { + isSuspended: false, + }); + + insertModerationLog(me, "unsuspend", { + targetId: user.id, + }); + + doPostUnsuspend(user); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts new file mode 100644 index 0000000..a3d1650 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -0,0 +1,574 @@ +import { Meta } from "@/models/entities/meta.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; +import { db } from "@/db/postgre.js"; +import define from "../../define.js"; +import { Metas } from "@/models/index.js"; +import { Users } from "@/models/index.js"; +import { IsNull } from "typeorm"; + + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + disableRegistration: { type: "boolean", nullable: true }, + disableLocalTimeline: { type: "boolean", nullable: true }, + disableRecommendedTimeline: { type: "boolean", nullable: true }, + disableGlobalTimeline: { type: "boolean", nullable: true }, + defaultReaction: { type: "string", nullable: true }, + recommendedInstances: { + type: "array", + nullable: true, + items: { + type: "string", + }, + }, + pinnedUsers: { + type: "array", + nullable: true, + items: { + type: "string", + }, + }, + customMOTD: { + type: "array", + nullable: true, + items: { + type: "string", + }, + }, + customSplashIcons: { + type: "array", + nullable: true, + items: { + type: "string", + }, + }, + hiddenTags: { + type: "array", + nullable: true, + items: { + type: "string", + }, + }, + blockedHosts: { + type: "array", + nullable: true, + items: { + type: "string", + }, + }, + silencedHosts: { + type: "array", + nullable: true, + items: { + type: "string", + }, + }, + allowedHosts: { + type: "array", + nullable: true, + items: { + type: "string", + }, + }, + secureMode: { type: "boolean", nullable: true }, + privateMode: { type: "boolean", nullable: true }, + themeColor: { + type: "string", + nullable: true, + pattern: "^#[0-9a-fA-F]{6}$", + }, + mascotImageUrl: { type: "string", nullable: true }, + bannerUrl: { type: "string", nullable: true }, + logoImageUrl: { type: "string", nullable: true }, + errorImageUrl: { type: "string", nullable: true }, + iconUrl: { type: "string", nullable: true }, + backgroundImageUrl: { type: "string", nullable: true }, + name: { type: "string", nullable: true }, + description: { type: "string", nullable: true }, + defaultLightTheme: { type: "string", nullable: true }, + defaultDarkTheme: { type: "string", nullable: true }, + localDriveCapacityMb: { type: "integer" }, + remoteDriveCapacityMb: { type: "integer" }, + lua4frozenDatabaseCapacityMb: { type: "integer" }, + cacheRemoteFiles: { type: "boolean" }, + emailRequiredForSignup: { type: "boolean" }, + enableHcaptcha: { type: "boolean" }, + hcaptchaSiteKey: { type: "string", nullable: true }, + hcaptchaSecretKey: { type: "string", nullable: true }, + enableRecaptcha: { type: "boolean" }, + recaptchaSiteKey: { type: "string", nullable: true }, + recaptchaSecretKey: { type: "string", nullable: true }, + maintainerName: { type: "string", nullable: true }, + maintainerEmail: { type: "string", nullable: true }, + pinnedPages: { + type: "array", + items: { + type: "string", + }, + }, + pinnedClipId: { type: "string", format: "misskey:id", nullable: true }, + langs: { + type: "array", + items: { + type: "string", + }, + }, + summalyProxy: { type: "string", nullable: true }, + deeplAuthKey: { type: "string", nullable: true }, + deeplIsPro: { type: "boolean" }, + libreTranslateApiUrl: { type: "string", nullable: true }, + libreTranslateApiKey: { type: "string", nullable: true }, + enableGithubIntegration: { type: "boolean" }, + githubClientId: { type: "string", nullable: true }, + githubClientSecret: { type: "string", nullable: true }, + enableDiscordIntegration: { type: "boolean" }, + discordClientId: { type: "string", nullable: true }, + discordClientSecret: { type: "string", nullable: true }, + enableEmail: { type: "boolean" }, + email: { type: "string", nullable: true }, + smtpSecure: { type: "boolean" }, + smtpHost: { type: "string", nullable: true }, + smtpPort: { type: "integer", nullable: true }, + smtpUser: { type: "string", nullable: true }, + smtpPass: { type: "string", nullable: true }, + tosUrl: { type: "string", nullable: true }, + repositoryUrl: { type: "string" }, + feedbackUrl: { type: "string" }, + useObjectStorage: { type: "boolean" }, + objectStorageBaseUrl: { type: "string", nullable: true }, + objectStorageBucket: { type: "string", nullable: true }, + objectStoragePrefix: { type: "string", nullable: true }, + objectStorageEndpoint: { type: "string", nullable: true }, + objectStorageRegion: { type: "string", nullable: true }, + objectStoragePort: { type: "integer", nullable: true }, + objectStorageAccessKey: { type: "string", nullable: true }, + objectStorageSecretKey: { type: "string", nullable: true }, + objectStorageUseSSL: { type: "boolean" }, + objectStorageUseProxy: { type: "boolean" }, + objectStorageSetPublicRead: { type: "boolean" }, + objectStorageS3ForcePathStyle: { type: "boolean" }, + enableIpLogging: { type: "boolean" }, + enableActiveEmailValidation: { type: "boolean" }, + experimentalFeatures: { + type: "object", + nullable: true, + properties: { + postImports: { type: "boolean" }, + }, + }, + enableServerMachineStats: { type: "boolean" }, + enableIdenticonGeneration: { type: "boolean" }, + donationLink: { type: "string", nullable: true }, + autofollowedAccount: { type: "string", nullable: true }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const set = {} as Partial; + + if (typeof ps.disableRegistration === "boolean") { + set.disableRegistration = ps.disableRegistration; + } + + if (typeof ps.disableLocalTimeline === "boolean") { + set.disableLocalTimeline = ps.disableLocalTimeline; + } + + if (typeof ps.disableRecommendedTimeline === "boolean") { + set.disableRecommendedTimeline = ps.disableRecommendedTimeline; + } + + if (typeof ps.disableGlobalTimeline === "boolean") { + set.disableGlobalTimeline = ps.disableGlobalTimeline; + } + + if (typeof ps.defaultReaction === "string") { + set.defaultReaction = ps.defaultReaction; + } + + if (Array.isArray(ps.pinnedUsers)) { + set.pinnedUsers = ps.pinnedUsers.filter(Boolean); + } + + if (Array.isArray(ps.customMOTD)) { + set.customMOTD = ps.customMOTD.filter(Boolean); + } + + if (Array.isArray(ps.customSplashIcons)) { + set.customSplashIcons = ps.customSplashIcons.filter(Boolean); + } + + if (Array.isArray(ps.recommendedInstances)) { + set.recommendedInstances = ps.recommendedInstances.filter(Boolean); + if (set.recommendedInstances?.length > 0) { + set.recommendedInstances.forEach((instance, index) => { + if (/^https?:\/\//i.test(instance)) { + set.recommendedInstances![index] = instance + .replace(/^https?:\/\//i, "") + .replace(/\/$/, ""); + } + }); + } + } + + if (Array.isArray(ps.hiddenTags)) { + set.hiddenTags = ps.hiddenTags.filter(Boolean); + } + + if (Array.isArray(ps.blockedHosts)) { + let lastValue = ""; + set.blockedHosts = ps.blockedHosts.sort().filter((h) => { + const lv = lastValue; + lastValue = h; + return h !== "" && h !== lv; + }); + } + + if (Array.isArray(ps.silencedHosts)) { + let lastValue = ""; + set.silencedHosts = ps.silencedHosts.sort().filter((h) => { + const lv = lastValue; + lastValue = h; + return h !== "" && h !== lv; + }); + } + + if (ps.themeColor !== undefined) { + set.themeColor = ps.themeColor; + } + + if (Array.isArray(ps.allowedHosts)) { + set.allowedHosts = ps.allowedHosts.filter(Boolean); + } + + if (typeof ps.privateMode === "boolean") { + set.privateMode = ps.privateMode; + } + + if (typeof ps.secureMode === "boolean") { + set.secureMode = ps.secureMode; + } + + if (ps.mascotImageUrl !== undefined) { + set.mascotImageUrl = ps.mascotImageUrl; + } + + if (ps.bannerUrl !== undefined) { + set.bannerUrl = ps.bannerUrl; + } + + if (ps.logoImageUrl !== undefined) { + set.logoImageUrl = ps.logoImageUrl; + } + + if (ps.iconUrl !== undefined) { + set.iconUrl = ps.iconUrl; + } + + if (ps.backgroundImageUrl !== undefined) { + set.backgroundImageUrl = ps.backgroundImageUrl; + } + + if (ps.logoImageUrl !== undefined) { + set.logoImageUrl = ps.logoImageUrl; + } + + if (ps.name !== undefined) { + set.name = ps.name; + } + + if (ps.description !== undefined) { + set.description = ps.description; + } + + if (ps.defaultLightTheme !== undefined) { + set.defaultLightTheme = ps.defaultLightTheme; + } + + if (ps.defaultDarkTheme !== undefined) { + set.defaultDarkTheme = ps.defaultDarkTheme; + } + + if (ps.localDriveCapacityMb !== undefined) { + set.localDriveCapacityMb = ps.localDriveCapacityMb; + } + + if (ps.remoteDriveCapacityMb !== undefined) { + set.remoteDriveCapacityMb = ps.remoteDriveCapacityMb; + } + + if (ps.lua4frozenDatabaseCapacityMb !== undefined) { + set.lua4frozenDatabaseCapacityMb = ps.lua4frozenDatabaseCapacityMb; + } + + if (ps.cacheRemoteFiles !== undefined) { + set.cacheRemoteFiles = ps.cacheRemoteFiles; + } + + if (ps.emailRequiredForSignup !== undefined) { + set.emailRequiredForSignup = ps.emailRequiredForSignup; + } + + if (ps.enableHcaptcha !== undefined) { + set.enableHcaptcha = ps.enableHcaptcha; + } + + if (ps.hcaptchaSiteKey !== undefined) { + set.hcaptchaSiteKey = ps.hcaptchaSiteKey; + } + + if (ps.hcaptchaSecretKey !== undefined) { + set.hcaptchaSecretKey = ps.hcaptchaSecretKey; + } + + if (ps.enableRecaptcha !== undefined) { + set.enableRecaptcha = ps.enableRecaptcha; + } + + if (ps.recaptchaSiteKey !== undefined) { + set.recaptchaSiteKey = ps.recaptchaSiteKey; + } + + if (ps.recaptchaSecretKey !== undefined) { + set.recaptchaSecretKey = ps.recaptchaSecretKey; + } + + if (ps.maintainerName !== undefined) { + set.maintainerName = ps.maintainerName; + } + + if (ps.maintainerEmail !== undefined) { + set.maintainerEmail = ps.maintainerEmail; + } + + if (Array.isArray(ps.langs)) { + set.langs = ps.langs.filter(Boolean); + } + + if (Array.isArray(ps.pinnedPages)) { + set.pinnedPages = ps.pinnedPages.filter(Boolean); + } + + if (ps.pinnedClipId !== undefined) { + set.pinnedClipId = ps.pinnedClipId; + } + + if (ps.summalyProxy !== undefined) { + set.summalyProxy = ps.summalyProxy; + } + + + if (ps.enableGithubIntegration !== undefined) { + set.enableGithubIntegration = ps.enableGithubIntegration; + } + + if (ps.githubClientId !== undefined) { + set.githubClientId = ps.githubClientId; + } + + if (ps.githubClientSecret !== undefined) { + set.githubClientSecret = ps.githubClientSecret; + } + + if (ps.enableDiscordIntegration !== undefined) { + set.enableDiscordIntegration = ps.enableDiscordIntegration; + } + + if (ps.discordClientId !== undefined) { + set.discordClientId = ps.discordClientId; + } + + if (ps.discordClientSecret !== undefined) { + set.discordClientSecret = ps.discordClientSecret; + } + + if (ps.enableEmail !== undefined) { + set.enableEmail = ps.enableEmail; + } + + if (ps.email !== undefined) { + set.email = ps.email; + } + + if (ps.smtpSecure !== undefined) { + set.smtpSecure = ps.smtpSecure; + } + + if (ps.smtpHost !== undefined) { + set.smtpHost = ps.smtpHost; + } + + if (ps.smtpPort !== undefined) { + set.smtpPort = ps.smtpPort; + } + + if (ps.smtpUser !== undefined) { + set.smtpUser = ps.smtpUser; + } + + if (ps.smtpPass !== undefined) { + set.smtpPass = ps.smtpPass; + } + + if (ps.errorImageUrl !== undefined) { + set.errorImageUrl = ps.errorImageUrl; + } + + if (ps.tosUrl !== undefined) { + set.ToSUrl = ps.tosUrl; + } + + if (ps.repositoryUrl !== undefined) { + set.repositoryUrl = ps.repositoryUrl; + } + + if (ps.feedbackUrl !== undefined) { + set.feedbackUrl = ps.feedbackUrl; + } + + if (ps.useObjectStorage !== undefined) { + set.useObjectStorage = ps.useObjectStorage; + } + + if (ps.objectStorageBaseUrl !== undefined) { + set.objectStorageBaseUrl = ps.objectStorageBaseUrl; + } + + if (ps.objectStorageBucket !== undefined) { + set.objectStorageBucket = ps.objectStorageBucket; + } + + if (ps.objectStoragePrefix !== undefined) { + set.objectStoragePrefix = ps.objectStoragePrefix; + } + + if (ps.objectStorageEndpoint !== undefined) { + set.objectStorageEndpoint = ps.objectStorageEndpoint; + } + + if (ps.objectStorageRegion !== undefined) { + set.objectStorageRegion = ps.objectStorageRegion; + } + + if (ps.objectStoragePort !== undefined) { + set.objectStoragePort = ps.objectStoragePort; + } + + if (ps.objectStorageAccessKey !== undefined) { + set.objectStorageAccessKey = ps.objectStorageAccessKey; + } + + if (ps.objectStorageSecretKey !== undefined) { + set.objectStorageSecretKey = ps.objectStorageSecretKey; + } + + if (ps.objectStorageUseSSL !== undefined) { + set.objectStorageUseSSL = ps.objectStorageUseSSL; + } + + if (ps.objectStorageUseProxy !== undefined) { + set.objectStorageUseProxy = ps.objectStorageUseProxy; + } + + if (ps.objectStorageSetPublicRead !== undefined) { + set.objectStorageSetPublicRead = ps.objectStorageSetPublicRead; + } + + if (ps.objectStorageS3ForcePathStyle !== undefined) { + set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle; + } + + if (ps.deeplAuthKey !== undefined) { + if (ps.deeplAuthKey === "") { + set.deeplAuthKey = null; + } else { + set.deeplAuthKey = ps.deeplAuthKey; + } + } + + if (ps.deeplIsPro !== undefined) { + set.deeplIsPro = ps.deeplIsPro; + } + + if (ps.libreTranslateApiUrl !== undefined) { + if (ps.libreTranslateApiUrl === "") { + set.libreTranslateApiUrl = null; + } else { + set.libreTranslateApiUrl = ps.libreTranslateApiUrl; + } + } + + if (ps.libreTranslateApiKey !== undefined) { + if (ps.libreTranslateApiKey === "") { + set.libreTranslateApiKey = null; + } else { + set.libreTranslateApiKey = ps.libreTranslateApiKey; + } + } + + if (ps.enableIpLogging !== undefined) { + set.enableIpLogging = ps.enableIpLogging; + } + + if (ps.enableActiveEmailValidation !== undefined) { + set.enableActiveEmailValidation = ps.enableActiveEmailValidation; + } + + if (ps.experimentalFeatures !== undefined) { + set.experimentalFeatures = ps.experimentalFeatures || undefined; + } + + if (ps.enableServerMachineStats !== undefined) { + set.enableServerMachineStats = ps.enableServerMachineStats; + } + + if (ps.enableIdenticonGeneration !== undefined) { + set.enableIdenticonGeneration = ps.enableIdenticonGeneration; + } + + if (ps.donationLink !== undefined) { + set.donationLink = ps.donationLink; + if (set.donationLink && !/^https?:\/\//i.test(set.donationLink)) { + set.donationLink = `https://${set.donationLink}`; + } + } + + if (ps.autofollowedAccount !== undefined) { + if (ps.autofollowedAccount === null) { + set.autofollowedAccount = null; + } + else { + // Verify account exists and is a local account + const user = await Users.findOneBy({ username: ps.autofollowedAccount, host: IsNull() }); + if (user !== null) { + set.autofollowedAccount = user.username; + } + else { + set.autofollowedAccount = null; + } + } + } + + const meta = await Metas.findOne({ + where: {}, + order: { + id: "DESC", + }, + }); + + if (meta) + await Metas.update(meta.id, set); + else + await Metas.save(set); + + insertModerationLog(me, "updateMeta"); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/update-user-note.ts b/packages/backend/src/server/api/endpoints/admin/update-user-note.ts new file mode 100644 index 0000000..04870d1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/update-user-note.ts @@ -0,0 +1,33 @@ +import { UserProfiles, Users } from "@/models/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + text: { type: "string" }, + }, + required: ["userId", "text"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error("user not found"); + } + + await UserProfiles.update( + { userId: user.id }, + { + moderationNote: ps.text, + }, + ); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/user-plans/add.ts b/packages/backend/src/server/api/endpoints/admin/user-plans/add.ts new file mode 100644 index 0000000..5f74541 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/user-plans/add.ts @@ -0,0 +1,43 @@ +import define from "../../../define.js"; +import { Plans, UserPlans, Users } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + planId: { type: "string", format: "misskey:id" }, + }, + required: ["userId", "planId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + await Users.findOneByOrFail({ id: ps.userId }); + await Plans.findOneByOrFail({ id: ps.planId }); + + const exists = await UserPlans.findOneBy({ + userId: ps.userId, + planId: ps.planId, + }); + if (exists) return; + + await UserPlans.insert({ + id: genId(), + createdAt: new Date(), + userId: ps.userId, + planId: ps.planId, + }); + + insertModerationLog(me, "addUserPlan", { + targetId: ps.userId, + planId: ps.planId, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/user-plans/list.ts b/packages/backend/src/server/api/endpoints/admin/user-plans/list.ts new file mode 100644 index 0000000..483ed2f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/user-plans/list.ts @@ -0,0 +1,33 @@ +import define from "../../../define.js"; +import { Plans, UserPlans, Users } from "@/models/index.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps) => { + await Users.findOneByOrFail({ id: ps.userId }); + + const joins = await UserPlans.find({ + where: { userId: ps.userId }, + relations: ["plan"], + order: { createdAt: "ASC" }, + }); + + return await Plans.packMany( + joins + .map((join) => join.plan) + .filter((plan): plan is NonNullable => plan != null), + ); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/user-plans/remove.ts b/packages/backend/src/server/api/endpoints/admin/user-plans/remove.ts new file mode 100644 index 0000000..e85fb57 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/user-plans/remove.ts @@ -0,0 +1,33 @@ +import define from "../../../define.js"; +import { UserPlans, Users } from "@/models/index.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + planId: { type: "string", format: "misskey:id" }, + }, + required: ["userId", "planId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + await Users.findOneByOrFail({ id: ps.userId }); + + await UserPlans.delete({ + userId: ps.userId, + planId: ps.planId, + }); + + insertModerationLog(me, "removeUserPlan", { + targetId: ps.userId, + planId: ps.planId, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/vacuum.ts b/packages/backend/src/server/api/endpoints/admin/vacuum.ts new file mode 100644 index 0000000..559b310 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/vacuum.ts @@ -0,0 +1,35 @@ +import define from "../../define.js"; +import { insertModerationLog } from "@/services/insert-moderation-log.js"; +import { db } from "@/db/postgre.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + full: { type: "boolean" }, + analyze: { type: "boolean" }, + }, + required: ["full", "analyze"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const params: string[] = []; + + if (ps.full) { + params.push("FULL"); + } + + if (ps.analyze) { + params.push("ANALYZE"); + } + + db.query(`VACUUM ${params.join(" ")}`); + + insertModerationLog(me, "vacuum", ps); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/verified-badge-requests.ts b/packages/backend/src/server/api/endpoints/admin/verified-badge-requests.ts new file mode 100644 index 0000000..483f7cc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/verified-badge-requests.ts @@ -0,0 +1,41 @@ +import define from "../../define.js"; +import { VerifiedBadgeRequests } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["admin"], + + requireCredential: true, + requireAdmin: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + state: { + type: "string", + enum: ["all", "pending", "approved", "rejected"], + default: "pending", + }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps) => { + const query = makePaginationQuery( + VerifiedBadgeRequests.createQueryBuilder("request"), + ps.sinceId, + ps.untilId, + ); + + if (ps.state !== "all") { + query.andWhere("request.status = :status", { status: ps.state }); + } + + const requests = await query.take(ps.limit).getMany(); + + return await VerifiedBadgeRequests.packMany(requests); +}); diff --git a/packages/backend/src/server/api/endpoints/announcements.ts b/packages/backend/src/server/api/endpoints/announcements.ts new file mode 100644 index 0000000..1bab61b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/announcements.ts @@ -0,0 +1,113 @@ +import { Announcements, AnnouncementReads } from "@/models/index.js"; +import define from "../define.js"; +import { makePaginationQuery } from "../common/make-pagination-query.js"; + +export const meta = { + tags: ["meta"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, + nullable: false, + format: "date-time", + }, + updatedAt: { + type: "string", + optional: false, + nullable: true, + format: "date-time", + }, + text: { + type: "string", + optional: false, + nullable: false, + }, + title: { + type: "string", + optional: false, + nullable: false, + }, + imageUrl: { + type: "string", + optional: false, + nullable: true, + }, + isRead: { + type: "boolean", + optional: true, + nullable: false, + }, + showPopup: { + type: "boolean", + optional: false, + nullable: false, + }, + isGoodNews: { + type: "boolean", + optional: false, + nullable: false, + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + withUnreads: { type: "boolean", default: false }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + Announcements.createQueryBuilder("announcement"), + ps.sinceId, + ps.untilId, + ); + + const announcements = await query.take(ps.limit).getMany(); + + if (user) { + const reads = ( + await AnnouncementReads.findBy({ + userId: user.id, + }) + ).map((x) => x.announcementId); + + for (const announcement of announcements) { + (announcement as any).isRead = reads.includes(announcement.id); + } + } + + return ( + ps.withUnreads ? announcements.filter((a: any) => !a.isRead) : announcements + ).map((a) => ({ + ...a, + createdAt: a.createdAt.toISOString(), + updatedAt: a.updatedAt?.toISOString() ?? null, + })); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/create.ts b/packages/backend/src/server/api/endpoints/antennas/create.ts new file mode 100644 index 0000000..ed16450 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/create.ts @@ -0,0 +1,160 @@ +import define from "../../define.js"; +import { genId } from "@/misc/gen-id.js"; +import { Antennas, UserLists, UserGroupJoinings } from "@/models/index.js"; +import { ApiError } from "../../error.js"; +import { publishInternalEvent } from "@/services/stream.js"; + +export const meta = { + tags: ["antennas"], + + requireCredential: true, + + kind: "write:account", + + errors: { + noSuchUserList: { + message: "No such user list.", + code: "NO_SUCH_USER_LIST", + id: "95063e93-a283-4b8b-9aa5-bcdb8df69a7f", + }, + + noSuchUserGroup: { + message: "No such user group.", + code: "NO_SUCH_USER_GROUP", + id: "aa3c0b9a-8cae-47c0-92ac-202ce5906682", + }, + + tooManyAntennas: { + message: "Too many antennas.", + code: "TOO_MANY_ANTENNAS", + id: "c3a5a51e-04d4-11ee-be56-0242ac120002", + }, + noKeywords: { + message: "No keywords.", + code: "NO_KEYWORDS", + id: "aa975b74-1ddb-11ee-be56-0242ac120002", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Antenna", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 100 }, + src: { + type: "string", + enum: ["home", "all", "users", "list", "group", "instances"], + }, + userListId: { type: "string", format: "misskey:id", nullable: true }, + userGroupId: { type: "string", format: "misskey:id", nullable: true }, + keywords: { + type: "array", + items: { + type: "array", + items: { + type: "string", + }, + }, + }, + excludeKeywords: { + type: "array", + items: { + type: "array", + items: { + type: "string", + }, + }, + }, + users: { + type: "array", + items: { + type: "string", + }, + }, + instances: { + type: "array", + items: { + type: "string", + }, + }, + caseSensitive: { type: "boolean" }, + withReplies: { type: "boolean" }, + withFile: { type: "boolean" }, + notify: { type: "boolean" }, + }, + required: [ + "name", + "src", + "keywords", + "excludeKeywords", + "users", + "instances", + "caseSensitive", + "withReplies", + "withFile", + "notify", + ], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + if (user.movedToUri != null) throw new ApiError(meta.errors.noSuchUserGroup); + if (ps.keywords.length === 0) throw new ApiError(meta.errors.noKeywords); + let userList; + let userGroupJoining; + + const antennas = await Antennas.findBy({ + userId: user.id, + }); + if (antennas.length > 5 && !user.isAdmin) { + throw new ApiError(meta.errors.tooManyAntennas); + } + + if (ps.src === "list" && ps.userListId) { + userList = await UserLists.findOneBy({ + id: ps.userListId, + userId: user.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchUserList); + } + } else if (ps.src === "group" && ps.userGroupId) { + userGroupJoining = await UserGroupJoinings.findOneBy({ + userGroupId: ps.userGroupId, + userId: user.id, + }); + + if (userGroupJoining == null) { + throw new ApiError(meta.errors.noSuchUserGroup); + } + } + + const antenna = await Antennas.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + src: ps.src, + userListId: userList ? userList.id : null, + userGroupJoiningId: userGroupJoining ? userGroupJoining.id : null, + keywords: ps.keywords, + excludeKeywords: ps.excludeKeywords, + users: ps.users, + instances: ps.instances, + caseSensitive: ps.caseSensitive, + withReplies: ps.withReplies, + withFile: ps.withFile, + notify: ps.notify, + }).then((x) => Antennas.findOneByOrFail(x.identifiers[0])); + + publishInternalEvent("antennaCreated", antenna); + + return await Antennas.pack(antenna); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/delete.ts b/packages/backend/src/server/api/endpoints/antennas/delete.ts new file mode 100644 index 0000000..a6cf790 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/delete.ts @@ -0,0 +1,43 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { Antennas } from "@/models/index.js"; +import { publishInternalEvent } from "@/services/stream.js"; + +export const meta = { + tags: ["antennas"], + + requireCredential: true, + + kind: "write:account", + + errors: { + noSuchAntenna: { + message: "No such antenna.", + code: "NO_SUCH_ANTENNA", + id: "b34dcf9d-348f-44bb-99d0-6c9314cfe2df", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + antennaId: { type: "string", format: "misskey:id" }, + }, + required: ["antennaId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const antenna = await Antennas.findOneBy({ + id: ps.antennaId, + userId: user.id, + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + await Antennas.delete(antenna.id); + + publishInternalEvent("antennaDeleted", antenna); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/list.ts b/packages/backend/src/server/api/endpoints/antennas/list.ts new file mode 100644 index 0000000..929b761 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/list.ts @@ -0,0 +1,36 @@ +import define from "../../define.js"; +import { Antennas } from "@/models/index.js"; + +export const meta = { + tags: ["antennas", "account"], + + requireCredential: true, + + kind: "read:account", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Antenna", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const antennas = await Antennas.findBy({ + userId: me.id, + }); + + return await Promise.all(antennas.map((x) => Antennas.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/markread.ts b/packages/backend/src/server/api/endpoints/antennas/markread.ts new file mode 100644 index 0000000..db8e683 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/markread.ts @@ -0,0 +1,42 @@ +import define from "../../define.js"; +import { Antennas } from "@/models/index.js"; +import { FindOptionsWhere } from "typeorm"; + +export const meta = { + tags: ["antennas", "account"], + + requireCredential: true, + + kind: "write:account", +} as const; + +export const paramDef = { + type: "object", + properties: { + antennaId: { type: "string", format: "misskey:id" }, + }, + required: ["antennaId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const antenna = await Antennas.findOneBy({ + userId: me.id, + id: ps.antennaId, + }); + + if (!antenna) { + return null; + } + + // await AntennaNotes.update( + // { + // antennaId: antenna.id, + // read: false, + // }, + // { + // read: true, + // }, + // ); + + return true; +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts new file mode 100644 index 0000000..294ef24 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -0,0 +1,146 @@ +import define from "../../define.js"; +import readNote from "@/services/note/read.js"; +import { Antennas, Notes } from "@/models/index.js"; +import { redisClient } from "@/db/redis.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { ApiError } from "../../error.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; +import { Note } from "@/models/entities/note.js"; +import { generateExcludeMemorietQuery } from "@/server/api/common/generate-exclude-memoriet-query.js"; + +export const meta = { + tags: ["antennas", "account", "notes"], + + requireCredential: true, + + kind: "read:account", + + errors: { + noSuchAntenna: { + message: "No such antenna.", + code: "NO_SUCH_ANTENNA", + id: "850926e0-fd3b-49b6-b69a-b28a5dbd82fe", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + pagination: { + type: "string", + nullable: false, + optional: false, + }, + notes: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + antennaId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + pagination: { type: "string", default: "+" }, + }, + required: ["antennaId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const antenna = await Antennas.findOneBy({ + id: ps.antennaId, + userId: user.id, + }); + + let pagination = ps.pagination || "+"; + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + let notes: Note[] = []; + let paginationMap: string[][] = []; + + while (notes.length < ps.limit && pagination !== "-1") { + // exclusive range + if (pagination != "+" && !pagination.startsWith("(")) + pagination = `(${pagination}`; + + const noteIdsRes = await redisClient.xrevrange( + `antennaTimeline:${antenna.id}`, + pagination, + "-", + "COUNT", + ps.limit - notes.length, + ); + + const noteIds = noteIdsRes.map((x) => x[1][1]); + + if (noteIds.length === 0) { + pagination = "-1"; + break; + } + + const query = makePaginationQuery(Notes.createQueryBuilder("note")) + .where("note.id IN (:...noteIds)", { noteIds: noteIds }) + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser") + .andWhere("note.visibility != 'home'"); + + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + generateExcludeMemorietQuery(query); + + pagination = noteIdsRes[noteIdsRes.length - 1][0]; + paginationMap = paginationMap.concat( + noteIdsRes.map((x) => [x[1][1], x[0]]), + ); + notes = notes.concat(await query.take(ps.limit - notes.length).getMany()); + } + + if (notes.length === 0) { + return { pagination: "-1", notes: [] }; + } else { + readNote(user.id, notes); + } + + const packedNotes = (await Notes.packMany(notes, user)).sort( + (a, b) => + paginationMap.findIndex((p) => p[0] == a.id) - + paginationMap.findIndex((p) => p[0] == b.id), + ); + + if (notes.length < ps.limit) { + pagination = "-1"; + } else { + // I'm so sorry, FIXME: rewrite pagination system + pagination = paginationMap.find( + (p) => + p[0] == + packedNotes[packedNotes.length - (packedNotes.length > 1 ? 2 : 1)].id, + )[1]; + } + + return { + pagination: pagination, + notes: packedNotes, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/show.ts b/packages/backend/src/server/api/endpoints/antennas/show.ts new file mode 100644 index 0000000..350d739 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/show.ts @@ -0,0 +1,48 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { Antennas } from "@/models/index.js"; + +export const meta = { + tags: ["antennas", "account"], + + requireCredential: true, + + kind: "read:account", + + errors: { + noSuchAntenna: { + message: "No such antenna.", + code: "NO_SUCH_ANTENNA", + id: "c06569fb-b025-4f23-b22d-1fcd20d2816b", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Antenna", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + antennaId: { type: "string", format: "misskey:id" }, + }, + required: ["antennaId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + // Fetch the antenna + const antenna = await Antennas.findOneBy({ + id: ps.antennaId, + userId: me.id, + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + return await Antennas.pack(antenna); +}); diff --git a/packages/backend/src/server/api/endpoints/antennas/update.ts b/packages/backend/src/server/api/endpoints/antennas/update.ts new file mode 100644 index 0000000..f491c0b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/antennas/update.ts @@ -0,0 +1,157 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { Antennas, UserLists, UserGroupJoinings } from "@/models/index.js"; +import { publishInternalEvent } from "@/services/stream.js"; + +export const meta = { + tags: ["antennas"], + + requireCredential: true, + + kind: "write:account", + + errors: { + noSuchAntenna: { + message: "No such antenna.", + code: "NO_SUCH_ANTENNA", + id: "10c673ac-8852-48eb-aa1f-f5b67f069290", + }, + + noSuchUserList: { + message: "No such user list.", + code: "NO_SUCH_USER_LIST", + id: "1c6b35c9-943e-48c2-81e4-2844989407f7", + }, + + noSuchUserGroup: { + message: "No such user group.", + code: "NO_SUCH_USER_GROUP", + id: "109ed789-b6eb-456e-b8a9-6059d567d385", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Antenna", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + antennaId: { type: "string", format: "misskey:id" }, + name: { type: "string", minLength: 1, maxLength: 100 }, + src: { + type: "string", + enum: ["home", "all", "users", "list", "group", "instances"], + }, + userListId: { type: "string", format: "misskey:id", nullable: true }, + userGroupId: { type: "string", format: "misskey:id", nullable: true }, + keywords: { + type: "array", + items: { + type: "array", + items: { + type: "string", + }, + }, + }, + excludeKeywords: { + type: "array", + items: { + type: "array", + items: { + type: "string", + }, + }, + }, + users: { + type: "array", + items: { + type: "string", + }, + }, + instances: { + type: "array", + items: { + type: "string", + }, + }, + caseSensitive: { type: "boolean" }, + withReplies: { type: "boolean" }, + withFile: { type: "boolean" }, + notify: { type: "boolean" }, + }, + required: [ + "antennaId", + "name", + "src", + "keywords", + "excludeKeywords", + "users", + "instances", + "caseSensitive", + "withReplies", + "withFile", + "notify", + ], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Fetch the antenna + const antenna = await Antennas.findOneBy({ + id: ps.antennaId, + userId: user.id, + }); + + if (antenna == null) { + throw new ApiError(meta.errors.noSuchAntenna); + } + + let userList; + let userGroupJoining; + + if (ps.src === "list" && ps.userListId) { + userList = await UserLists.findOneBy({ + id: ps.userListId, + userId: user.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchUserList); + } + } else if (ps.src === "group" && ps.userGroupId) { + userGroupJoining = await UserGroupJoinings.findOneBy({ + userGroupId: ps.userGroupId, + userId: user.id, + }); + + if (userGroupJoining == null) { + throw new ApiError(meta.errors.noSuchUserGroup); + } + } + + await Antennas.update(antenna.id, { + name: ps.name, + src: ps.src, + userListId: userList ? userList.id : null, + userGroupJoiningId: userGroupJoining ? userGroupJoining.id : null, + keywords: ps.keywords, + excludeKeywords: ps.excludeKeywords, + users: ps.users, + instances: ps.instances, + caseSensitive: ps.caseSensitive, + withReplies: ps.withReplies, + withFile: ps.withFile, + notify: ps.notify, + }); + + publishInternalEvent( + "antennaUpdated", + await Antennas.findOneByOrFail({ id: antenna.id }), + ); + + return await Antennas.pack(antenna.id); +}); diff --git a/packages/backend/src/server/api/endpoints/ap/get.ts b/packages/backend/src/server/api/endpoints/ap/get.ts new file mode 100644 index 0000000..bf3ad09 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/ap/get.ts @@ -0,0 +1,37 @@ +import define from "../../define.js"; +import Resolver from "@/remote/activitypub/resolver.js"; +import { HOUR } from "@/const.js"; + +export const meta = { + tags: ["federation"], + + requireCredential: true, + requireAdmin: true, + + limit: { + duration: HOUR, + max: 30, + }, + + errors: {}, + + res: { + type: "object", + optional: false, + nullable: false, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + uri: { type: "string" }, + }, + required: ["uri"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const resolver = new Resolver(); + const object = await resolver.resolve(ps.uri); + return object; +}); diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts new file mode 100644 index 0000000..a4d6dee --- /dev/null +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -0,0 +1,181 @@ +import define from "../../define.js"; +import { createPerson } from "@/remote/activitypub/models/person.js"; +import { createNote } from "@/remote/activitypub/models/note.js"; +import DbResolver from "@/remote/activitypub/db-resolver.js"; +import Resolver from "@/remote/activitypub/resolver.js"; +import { ApiError } from "../../error.js"; +import { extractDbHost } from "@/misc/convert-host.js"; +import { Users, Notes } from "@/models/index.js"; +import type { Note } from "@/models/entities/note.js"; +import type { CacheableLocalUser, User } from "@/models/entities/user.js"; +import { isActor, isPost, getApId } from "@/remote/activitypub/type.js"; +import type { SchemaType } from "@/misc/schema.js"; +import { MINUTE } from "@/const.js"; +import { shouldBlockInstance } from "@/misc/should-block-instance.js"; +import { updateQuestion } from "@/remote/activitypub/models/question.js"; +import { populatePoll } from "@/models/repositories/note.js"; +import { redisClient } from "@/db/redis.js"; + +export const meta = { + tags: ["federation"], + + requireCredential: true, + + limit: { + duration: MINUTE, + max: 10, + }, + + errors: { + noSuchObject: { + message: "No such object.", + code: "NO_SUCH_OBJECT", + id: "dc94d745-1262-4e63-a17d-fecaa57efc82", + }, + }, + + res: { + optional: false, + nullable: false, + oneOf: [ + { + type: "object", + properties: { + type: { + type: "string", + optional: false, + nullable: false, + enum: ["User"], + }, + object: { + type: "object", + optional: false, + nullable: false, + ref: "UserDetailedNotMe", + }, + }, + }, + { + type: "object", + properties: { + type: { + type: "string", + optional: false, + nullable: false, + enum: ["Note"], + }, + object: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, + }, + ], + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + uri: { type: "string" }, + }, + required: ["uri"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const object = await fetchAny(ps.uri, me); + if (object) { + return object; + } else { + throw new ApiError(meta.errors.noSuchObject); + } +}); + +/*** + * Resolve User or Note from URI + */ +async function fetchAny( + uri: string, + me: CacheableLocalUser | null | undefined, +): Promise | null> { + // Wait if blocked. + if (await shouldBlockInstance(extractDbHost(uri))) return null; + + const dbResolver = new DbResolver(); + const resolver = new Resolver(); + resolver.setUser(me); + + const [user, note] = await Promise.all([ + dbResolver.getUserFromApId(uri), + dbResolver.getNoteFromApId(uri), + ]); + let local = await mergePack(me, user, note); + if (local) { + if (local.type === "Note" && note?.uri && note.hasPoll) { + // Update questions if the stored (remote) note contains the poll + const key = `pollFetched:${note.uri}`; + if ((await redisClient.exists(key)) === 0) { + if (await updateQuestion(note.uri, resolver)) { + local.object.poll = await populatePoll(note, me?.id ?? null); + } + // Allow fetching the poll again after 1 minute + await redisClient.set(key, 1, "EX", 60); + } + } + return local; + } + + // fetching Object once from remote + const object = await resolver.resolve(uri); + + // /@user If a URI other than the id is specified, + // the URI is determined here + if (uri !== object.id) { + local = await mergePack( + me, + ...(await Promise.all([ + dbResolver.getUserFromApId(getApId(object)), + dbResolver.getNoteFromApId(getApId(object)), + ])), + ); + if (local != null) return local; + } + + return await mergePack( + me, + isActor(object) + ? await createPerson(getApId(object), resolver.reset()) + : null, + isPost(object) + ? await createNote(getApId(object), resolver.reset(), true) + : null, + ); +} + +async function mergePack( + me: CacheableLocalUser | null | undefined, + user: User | null | undefined, + note: Note | null | undefined, +): Promise | null> { + if (user != null) { + return { + type: "User", + object: await Users.pack(user, me, { detail: true }), + }; + } else if (note != null) { + try { + const object = await Notes.pack(note, me, { detail: true }); + + return { + type: "Note", + object, + }; + } catch (e) { + return null; + } + } + + return null; +} diff --git a/packages/backend/src/server/api/endpoints/app/create.ts b/packages/backend/src/server/api/endpoints/app/create.ts new file mode 100644 index 0000000..2317ac0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/app/create.ts @@ -0,0 +1,67 @@ +import define from "../../define.js"; +import { Apps } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { unique } from "@/prelude/array.js"; +import { secureRndstr } from "@/misc/secure-rndstr.js"; + +export const meta = { + tags: ["app"], + + requireCredential: false, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "App", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + name: { type: "string" }, + description: { type: "string" }, + permission: { + type: "array", + uniqueItems: true, + items: { + type: "string", + }, + }, + callbackUrl: { type: "string", nullable: true }, + }, + required: ["name", "description", "permission"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + if (user?.movedToUri != null) + return await Apps.pack("", null, { + detail: true, + includeSecret: true, + }); + // Generate secret + const secret = secureRndstr(32); + + // for backward compatibility + const permission = unique( + ps.permission.map((v) => v.replace(/^(.+)(\/|-)(read|write)$/, "$3:$1")), + ); + + // Create account + const app = await Apps.insert({ + id: genId(), + createdAt: new Date(), + userId: user ? user.id : null, + name: ps.name, + description: ps.description, + permission, + callbackUrl: ps.callbackUrl, + secret: secret, + }).then((x) => Apps.findOneByOrFail(x.identifiers[0])); + + return await Apps.pack(app, null, { + detail: true, + includeSecret: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/app/show.ts b/packages/backend/src/server/api/endpoints/app/show.ts new file mode 100644 index 0000000..6094951 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/app/show.ts @@ -0,0 +1,46 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { Apps } from "@/models/index.js"; + +export const meta = { + tags: ["app"], + + errors: { + noSuchApp: { + message: "No such app.", + code: "NO_SUCH_APP", + id: "dce83913-2dc6-4093-8a7b-71dbb11718a3", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "App", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + appId: { type: "string", format: "misskey:id" }, + }, + required: ["appId"], +} as const; + +export default define(meta, paramDef, async (ps, user, token) => { + const isSecure = user != null && token == null; + + // Lookup app + const ap = await Apps.findOneBy({ id: ps.appId }); + + if (ap == null) { + throw new ApiError(meta.errors.noSuchApp); + } + + return await Apps.pack(ap, user, { + detail: true, + includeSecret: isSecure && ap.userId === user!.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/auth/accept.ts b/packages/backend/src/server/api/endpoints/auth/accept.ts new file mode 100644 index 0000000..194611f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/auth/accept.ts @@ -0,0 +1,78 @@ +import * as crypto from "node:crypto"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { AuthSessions, AccessTokens, Apps } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { secureRndstr } from "@/misc/secure-rndstr.js"; + +export const meta = { + tags: ["auth"], + + requireCredential: true, + + secure: true, + + errors: { + noSuchSession: { + message: "No such session.", + code: "NO_SUCH_SESSION", + id: "9c72d8de-391a-43c1-9d06-08d29efde8df", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + token: { type: "string" }, + }, + required: ["token"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Fetch token + const session = await AuthSessions.findOneBy({ token: ps.token }); + + if (session == null) { + throw new ApiError(meta.errors.noSuchSession); + } + + // Generate access token + const accessToken = secureRndstr(32); + + // Fetch exist access token + const exist = await AccessTokens.exist({ + where: { + appId: session.appId, + userId: user.id, + }, + }); + + if (!exist) { + // Lookup app + const app = await Apps.findOneByOrFail({ id: session.appId }); + + // Generate Hash + const sha256 = crypto.createHash("sha256"); + sha256.update(accessToken + app.secret); + const hash = sha256.digest("hex"); + + const now = new Date(); + + // Insert access token doc + await AccessTokens.insert({ + id: genId(), + createdAt: now, + lastUsedAt: now, + appId: session.appId, + userId: user.id, + token: accessToken, + hash: hash, + }); + } + + // Update session + await AuthSessions.update(session.id, { + userId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/auth/session/generate.ts b/packages/backend/src/server/api/endpoints/auth/session/generate.ts new file mode 100644 index 0000000..1defb94 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/auth/session/generate.ts @@ -0,0 +1,74 @@ +import { v4 as uuid } from "uuid"; +import config from "@/config/index.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { Apps, AuthSessions } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; + +export const meta = { + tags: ["auth"], + + requireCredential: false, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + token: { + type: "string", + optional: false, + nullable: false, + }, + url: { + type: "string", + optional: false, + nullable: false, + format: "url", + }, + }, + }, + + errors: { + noSuchApp: { + message: "No such app.", + code: "NO_SUCH_APP", + id: "92f93e63-428e-4f2f-a5a4-39e1407fe998", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + appSecret: { type: "string" }, + }, + required: ["appSecret"], +} as const; + +export default define(meta, paramDef, async (ps) => { + // Lookup app + const app = await Apps.findOneBy({ + secret: ps.appSecret, + }); + + if (app == null) { + throw new ApiError(meta.errors.noSuchApp); + } + + // Generate token + const token = uuid(); + + // Create session token document + const doc = await AuthSessions.insert({ + id: genId(), + createdAt: new Date(), + appId: app.id, + token: token, + }).then((x) => AuthSessions.findOneByOrFail(x.identifiers[0])); + + return { + token: doc.token, + url: `${config.authUrl}/${doc.token}`, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/auth/session/show.ts b/packages/backend/src/server/api/endpoints/auth/session/show.ts new file mode 100644 index 0000000..01a5fe5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/auth/session/show.ts @@ -0,0 +1,63 @@ +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { AuthSessions } from "@/models/index.js"; + +export const meta = { + tags: ["auth"], + + requireCredential: false, + + errors: { + noSuchSession: { + message: "No such session.", + code: "NO_SUCH_SESSION", + id: "bd72c97d-eba7-4adb-a467-f171b8847250", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + app: { + type: "object", + optional: false, + nullable: false, + ref: "App", + }, + token: { + type: "string", + optional: false, + nullable: false, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + token: { type: "string" }, + }, + required: ["token"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Lookup session + const session = await AuthSessions.findOneBy({ + token: ps.token, + }); + + if (session == null) { + throw new ApiError(meta.errors.noSuchSession); + } + + return await AuthSessions.pack(session, user); +}); diff --git a/packages/backend/src/server/api/endpoints/auth/session/userkey.ts b/packages/backend/src/server/api/endpoints/auth/session/userkey.ts new file mode 100644 index 0000000..0e97bf4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/auth/session/userkey.ts @@ -0,0 +1,99 @@ +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { Apps, AuthSessions, AccessTokens, Users } from "@/models/index.js"; + +export const meta = { + tags: ["auth"], + + requireCredential: false, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + accessToken: { + type: "string", + optional: false, + nullable: false, + }, + + user: { + type: "object", + optional: false, + nullable: false, + ref: "UserDetailedNotMe", + }, + }, + }, + + errors: { + noSuchApp: { + message: "No such app.", + code: "NO_SUCH_APP", + id: "fcab192a-2c5a-43b7-8ad8-9b7054d8d40d", + }, + + noSuchSession: { + message: "No such session.", + code: "NO_SUCH_SESSION", + id: "5b5a1503-8bc8-4bd0-8054-dc189e8cdcb3", + }, + + pendingSession: { + message: "This session is not completed yet.", + code: "PENDING_SESSION", + id: "8c8a4145-02cc-4cca-8e66-29ba60445a8e", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + appSecret: { type: "string" }, + token: { type: "string" }, + }, + required: ["appSecret", "token"], +} as const; + +export default define(meta, paramDef, async (ps) => { + // Lookup app + const app = await Apps.findOneBy({ + secret: ps.appSecret, + }); + + if (app == null) { + throw new ApiError(meta.errors.noSuchApp); + } + + // Fetch token + const session = await AuthSessions.findOneBy({ + token: ps.token, + appId: app.id, + }); + + if (session == null) { + throw new ApiError(meta.errors.noSuchSession); + } + + if (session.userId == null) { + throw new ApiError(meta.errors.pendingSession); + } + + // Lookup access token + const accessToken = await AccessTokens.findOneByOrFail({ + appId: app.id, + userId: session.userId, + }); + + // Delete session + AuthSessions.delete(session.id); + + return { + accessToken: accessToken.token, + user: await Users.pack(session.userId, null, { + detail: true, + }), + }; +}); diff --git a/packages/backend/src/server/api/endpoints/bites/create.ts b/packages/backend/src/server/api/endpoints/bites/create.ts new file mode 100644 index 0000000..3d7eefe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/bites/create.ts @@ -0,0 +1,74 @@ +import { Bites } from "@/models/index.js"; +import define from "../../define.js"; +import { createBite } from "@/services/create-bite.js"; +import { MINUTE } from "@/const.js"; +import { ApiError } from "../../error.js"; + +export const meta = { + tags: ["bites"], + + requireCredential: true, + + limit: { + duration: MINUTE, + max: 30, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Bite", + }, + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "7a80aef8-e4ca-43c2-a997-a6e9b0198374", + }, + bitesDisabled: { + message: "User doesn't allow bites.", + code: "BITES_DISABLED", + id: "a8cfcada-42e1-4ef4-b15e-6fcd903458b7", + }, + bitesFollowersOnly: { + message: "User only lets followers bite them.", + code: "BITES_FOLLOWERS_ONLY", + id: "26a2ed34-a1df-408c-9f75-d7459380fb60", + }, + youHaveBeenBlocked: { + message: "You cannot bite because you have been blocked by this user.", + code: "YOU_HAVE_BEEN_BLOCKED", + id: "c15a5199-7422-4968-941a-2a462c478f7d", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + targetType: { type: "string", enum: ["user", "bite", "note"] }, + targetId: { type: "string", format: "misskey:id" }, + }, + required: ["targetType", "targetId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + let biteId; + try { + biteId = await createBite(me, ps.targetType, ps.targetId); + } catch (err: any) { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + if (err.id === "f82d8d34-beaf-42f3-9135-477d32288213") + throw new ApiError(meta.errors.youHaveBeenBlocked); + if (err.id === "35363f14-f489-45e2-81a9-558450710dfe") + throw new ApiError(meta.errors.bitesFollowersOnly); + if (err.id === "92ce0141-760d-4163-a7a2-73b349e3d133") + throw new ApiError(meta.errors.bitesDisabled); + throw err; + } + + return await Bites.pack(biteId, me); +}); diff --git a/packages/backend/src/server/api/endpoints/bites/show.ts b/packages/backend/src/server/api/endpoints/bites/show.ts new file mode 100644 index 0000000..7027e07 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/bites/show.ts @@ -0,0 +1,25 @@ +import { Bites } from "@/models/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["bites"], + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Bite", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + biteId: { type: "string", format: "misskey:id" }, + }, + required: ["biteId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + return await Bites.pack(ps.biteId, me); +}); diff --git a/packages/backend/src/server/api/endpoints/blocking/create.ts b/packages/backend/src/server/api/endpoints/blocking/create.ts new file mode 100644 index 0000000..dddc731 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/blocking/create.ts @@ -0,0 +1,105 @@ +import create from "@/services/blocking/create.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getUser } from "../../common/getters.js"; +import { Blockings, NoteWatchings, Users } from "@/models/index.js"; +import { HOUR } from "@/const.js"; +import { getGroupActor } from "../../common/get-group-actor.js"; + +export const meta = { + tags: ["account"], + + limit: { + duration: HOUR, + max: 100, + }, + + requireCredential: true, + + kind: "write:blocks", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "7cc4f851-e2f1-4621-9633-ec9e1d00c01e", + }, + + blockeeIsYourself: { + message: "Blockee is yourself.", + code: "BLOCKEE_IS_YOURSELF", + id: "88b19138-f28d-42c0-8499-6a31bbd0fdc6", + }, + + alreadyBlocking: { + message: "You are already blocking that user.", + code: "ALREADY_BLOCKING", + id: "787fed64-acb9-464a-82eb-afbd745b9614", + }, + + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "8193cd43-8319-4383-9591-a093cbb4aa3a", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "UserDetailedNotMe", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + groupId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const blocker = await Users.findOneByOrFail({ id: user.id }); + const group = await getGroupActor(ps.groupId, user); + if (ps.groupId != null && group == null) throw new ApiError(meta.errors.noSuchGroup); + + // 自分自身 + if (group == null && user.id === ps.userId) { + throw new ApiError(meta.errors.blockeeIsYourself); + } + + // Get blockee + const blockee = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check if already blocking + const exist = await Blockings.exist({ + where: { + blockeeId: blockee.id, + ...(group ? { groupId: group.id } : { blockerId: blocker.id, groupId: null }), + }, + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyBlocking); + } + + await create(blocker, blockee, group?.id ?? null); + + if (group == null) { + NoteWatchings.delete({ + userId: blocker.id, + noteUserId: blockee.id, + }); + } + + return await Users.pack(blockee.id, blocker, { + detail: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/blocking/delete.ts b/packages/backend/src/server/api/endpoints/blocking/delete.ts new file mode 100644 index 0000000..2296d8c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/blocking/delete.ts @@ -0,0 +1,99 @@ +import deleteBlocking from "@/services/blocking/delete.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getUser } from "../../common/getters.js"; +import { Blockings, Users } from "@/models/index.js"; +import { HOUR } from "@/const.js"; +import { getGroupActor } from "../../common/get-group-actor.js"; + +export const meta = { + tags: ["account"], + + limit: { + duration: HOUR, + max: 100, + }, + + requireCredential: true, + + kind: "write:blocks", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "8621d8bf-c358-4303-a066-5ea78610eb3f", + }, + + blockeeIsYourself: { + message: "Blockee is yourself.", + code: "BLOCKEE_IS_YOURSELF", + id: "06f6fac6-524b-473c-a354-e97a40ae6eac", + }, + + notBlocking: { + message: "You are not blocking that user.", + code: "NOT_BLOCKING", + id: "291b2efa-60c6-45c0-9f6a-045c8f9b02cd", + }, + + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "b797376f-f8cc-405b-a1d6-250b84cc80c2", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "UserDetailedNotMe", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + groupId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const blocker = await Users.findOneByOrFail({ id: user.id }); + const group = await getGroupActor(ps.groupId, user); + if (ps.groupId != null && group == null) throw new ApiError(meta.errors.noSuchGroup); + + // Check if the blockee is yourself + if (group == null && user.id === ps.userId) { + throw new ApiError(meta.errors.blockeeIsYourself); + } + + // Get blockee + const blockee = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check not blocking + const exist = await Blockings.exist({ + where: { + blockeeId: blockee.id, + ...(group ? { groupId: group.id } : { blockerId: blocker.id, groupId: null }), + }, + }); + + if (!exist) { + throw new ApiError(meta.errors.notBlocking); + } + + // Delete blocking + await deleteBlocking(blocker, blockee, group?.id ?? null); + + return await Users.pack(blockee.id, blocker, { + detail: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/blocking/list.ts b/packages/backend/src/server/api/endpoints/blocking/list.ts new file mode 100644 index 0000000..36f63d7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/blocking/list.ts @@ -0,0 +1,66 @@ +import define from "../../define.js"; +import { Blockings } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { ApiError } from "../../error.js"; +import { getGroupActor } from "../../common/get-group-actor.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + kind: "read:blocks", + + errors: { + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "b31c1162-4adf-4b4b-b3e3-3f2fb8ffcd5f", + }, + }, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Blocking", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + groupId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const group = await getGroupActor(ps.groupId, me); + if (ps.groupId != null && group == null) throw new ApiError(meta.errors.noSuchGroup); + + const query = makePaginationQuery( + Blockings.createQueryBuilder("blocking"), + ps.sinceId, + ps.untilId, + ); + + if (group) { + query.andWhere("blocking.groupId = :groupId", { groupId: group.id }); + } else { + query.andWhere("blocking.blockerId = :meId", { meId: me.id }); + query.andWhere("blocking.groupId IS NULL"); + } + + const blockings = await query.take(ps.limit).getMany(); + + return await Blockings.packMany(blockings, me); +}); diff --git a/packages/backend/src/server/api/endpoints/call-blocking/create.ts b/packages/backend/src/server/api/endpoints/call-blocking/create.ts new file mode 100644 index 0000000..19cad88 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/call-blocking/create.ts @@ -0,0 +1,103 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getUser } from "../../common/getters.js"; +import { CallBlockings, Users } from "@/models/index.js"; +import { HOUR } from "@/const.js"; +import { genId } from "@/misc/gen-id.js"; +import { getGroupActor } from "../../common/get-group-actor.js"; + +export const meta = { + tags: ["account"], + + limit: { + duration: HOUR, + max: 100, + }, + + requireCredential: true, + + kind: "write:blocks", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "e476b7c0-03fd-44d3-8de2-efc43b15b7e0", + }, + + blockeeIsYourself: { + message: "Blockee is yourself.", + code: "BLOCKEE_IS_YOURSELF", + id: "ee4c68c6-2a3d-4e13-8842-d4e7a341f109", + }, + + alreadyBlocking: { + message: "You are already rejecting calls from that user.", + code: "ALREADY_CALL_BLOCKING", + id: "9601ea36-97cd-4232-b2d8-326e42e15df4", + }, + + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "1d7a15c6-41e5-4e5c-9ce5-a59b83a3b1ee", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "UserDetailedNotMe", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + groupId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const blocker = await Users.findOneByOrFail({ id: user.id }); + const group = await getGroupActor(ps.groupId, user); + if (ps.groupId != null && group == null) throw new ApiError(meta.errors.noSuchGroup); + + if (group == null && user.id === ps.userId) { + throw new ApiError(meta.errors.blockeeIsYourself); + } + + const blockee = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const exist = await CallBlockings.exist({ + where: { + blockeeId: blockee.id, + ...(group ? { groupId: group.id } : { blockerId: blocker.id, groupId: null }), + }, + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyBlocking); + } + + await CallBlockings.insert({ + id: genId(), + createdAt: new Date(), + blockerId: blocker.id, + blockeeId: blockee.id, + groupId: group?.id ?? null, + blocker, + blockee, + }); + + return await Users.pack(blockee.id, blocker, { + detail: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/call-blocking/delete.ts b/packages/backend/src/server/api/endpoints/call-blocking/delete.ts new file mode 100644 index 0000000..448f45b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/call-blocking/delete.ts @@ -0,0 +1,92 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getUser } from "../../common/getters.js"; +import { CallBlockings, Users } from "@/models/index.js"; +import { HOUR } from "@/const.js"; +import { getGroupActor } from "../../common/get-group-actor.js"; + +export const meta = { + tags: ["account"], + + limit: { + duration: HOUR, + max: 100, + }, + + requireCredential: true, + + kind: "write:blocks", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "84d4f8bd-c78f-4b64-a542-76daefbd2338", + }, + + blockeeIsYourself: { + message: "Blockee is yourself.", + code: "BLOCKEE_IS_YOURSELF", + id: "8b347b91-c674-45af-8f4f-1fbbbcbd6f08", + }, + + notBlocking: { + message: "You are not rejecting calls from that user.", + code: "NOT_CALL_BLOCKING", + id: "7bd363a2-278f-46f8-a03a-ee1220302a3c", + }, + + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "27f63aa2-58cc-418a-8e9b-83d13f46048f", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "UserDetailedNotMe", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + groupId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const blocker = await Users.findOneByOrFail({ id: user.id }); + const group = await getGroupActor(ps.groupId, user); + if (ps.groupId != null && group == null) throw new ApiError(meta.errors.noSuchGroup); + + if (group == null && user.id === ps.userId) { + throw new ApiError(meta.errors.blockeeIsYourself); + } + + const blockee = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const blocking = await CallBlockings.findOneBy({ + blockeeId: blockee.id, + ...(group ? { groupId: group.id } : { blockerId: blocker.id, groupId: null }), + }); + + if (!blocking) { + throw new ApiError(meta.errors.notBlocking); + } + + await CallBlockings.delete(blocking.id); + + return await Users.pack(blockee.id, blocker, { + detail: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/call-blocking/list.ts b/packages/backend/src/server/api/endpoints/call-blocking/list.ts new file mode 100644 index 0000000..8b2ef61 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/call-blocking/list.ts @@ -0,0 +1,65 @@ +import define from "../../define.js"; +import { CallBlockings } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { ApiError } from "../../error.js"; +import { getGroupActor } from "../../common/get-group-actor.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + kind: "read:blocks", + + errors: { + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "a863670f-a52a-4bf7-8ddf-d64a83a22dda", + }, + }, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + groupId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const group = await getGroupActor(ps.groupId, me); + if (ps.groupId != null && group == null) throw new ApiError(meta.errors.noSuchGroup); + + const query = makePaginationQuery( + CallBlockings.createQueryBuilder("call_blocking"), + ps.sinceId, + ps.untilId, + ); + + if (group) { + query.andWhere("call_blocking.groupId = :groupId", { groupId: group.id }); + } else { + query.andWhere("call_blocking.blockerId = :meId", { meId: me.id }); + query.andWhere("call_blocking.groupId IS NULL"); + } + + const blockings = await query.take(ps.limit).getMany(); + + return await CallBlockings.packMany(blockings, me); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/create.ts b/packages/backend/src/server/api/endpoints/channels/create.ts new file mode 100644 index 0000000..26a3448 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/create.ts @@ -0,0 +1,68 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { Channels, DriveFiles } from "@/models/index.js"; +import type { Channel } from "@/models/entities/channel.js"; +import { genId } from "@/misc/gen-id.js"; + +export const meta = { + tags: ["channels"], + + requireCredential: true, + + kind: "write:channels", + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Channel", + }, + + errors: { + noSuchFile: { + message: "No such file.", + code: "NO_SUCH_FILE", + id: "cd1e9f3e-5a12-4ab4-96f6-5d0a2cc32050", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 128 }, + description: { + type: "string", + nullable: true, + minLength: 1, + maxLength: 2048, + }, + bannerId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: ["name"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + let banner = null; + if (ps.bannerId != null) { + banner = await DriveFiles.findOneBy({ + id: ps.bannerId, + userId: user.id, + }); + + if (banner == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + const channel = await Channels.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + description: ps.description || null, + bannerId: banner ? banner.id : null, + } as Channel).then((x) => Channels.findOneByOrFail(x.identifiers[0])); + + return await Channels.pack(channel, user); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/featured.ts b/packages/backend/src/server/api/endpoints/channels/featured.ts new file mode 100644 index 0000000..67fb8c8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/featured.ts @@ -0,0 +1,36 @@ +import define from "../../define.js"; +import { Channels } from "@/models/index.js"; + +export const meta = { + tags: ["channels"], + + requireCredential: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Channel", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = Channels.createQueryBuilder("channel") + .where("channel.lastNotedAt IS NOT NULL") + .orderBy("channel.lastNotedAt", "DESC"); + + const channels = await query.take(10).getMany(); + + return await Promise.all(channels.map((x) => Channels.pack(x, me))); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/follow.ts b/packages/backend/src/server/api/endpoints/channels/follow.ts new file mode 100644 index 0000000..de05543 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/follow.ts @@ -0,0 +1,48 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { Channels, ChannelFollowings } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { publishUserEvent } from "@/services/stream.js"; + +export const meta = { + tags: ["channels"], + + requireCredential: true, + + kind: "write:channels", + + errors: { + noSuchChannel: { + message: "No such channel.", + code: "NO_SUCH_CHANNEL", + id: "c0031718-d573-4e85-928e-10039f1fbb68", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + channelId: { type: "string", format: "misskey:id" }, + }, + required: ["channelId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const channel = await Channels.findOneBy({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + await ChannelFollowings.insert({ + id: genId(), + createdAt: new Date(), + followerId: user.id, + followeeId: channel.id, + }); + + publishUserEvent(user.id, "followChannel", channel); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/followed.ts b/packages/backend/src/server/api/endpoints/channels/followed.ts new file mode 100644 index 0000000..993a211 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/followed.ts @@ -0,0 +1,59 @@ +import define from "../../define.js"; +import { Channels, ChannelFollowings } from "@/models/index.js"; + +export const meta = { + tags: ["channels", "account"], + + requireCredential: true, + + kind: "read:channels", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Channel", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 5 }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = ChannelFollowings.createQueryBuilder("following").andWhere({ + followerId: me.id, + }); + if (ps.sinceId) { + query.andWhere('following."followeeId" > :sinceId', { + sinceId: ps.sinceId, + }); + } + if (ps.untilId) { + query.andWhere('following."followeeId" < :untilId', { + untilId: ps.untilId, + }); + } + if (ps.sinceId && !ps.untilId) { + query.orderBy('following."followeeId"', "ASC"); + } else { + query.orderBy('following."followeeId"', "DESC"); + } + + const followings = await query.take(ps.limit).getMany(); + + return await Promise.all( + followings.map((x) => Channels.pack(x.followeeId, me)), + ); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/owned.ts b/packages/backend/src/server/api/endpoints/channels/owned.ts new file mode 100644 index 0000000..78d9e80 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/owned.ts @@ -0,0 +1,45 @@ +import define from "../../define.js"; +import { Channels } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["channels", "account"], + + requireCredential: true, + + kind: "read:channels", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Channel", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 5 }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery( + Channels.createQueryBuilder(), + ps.sinceId, + ps.untilId, + ).andWhere({ userId: me.id }); + + const channels = await query.take(ps.limit).getMany(); + + return await Promise.all(channels.map((x) => Channels.pack(x, me))); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/search.ts b/packages/backend/src/server/api/endpoints/channels/search.ts new file mode 100644 index 0000000..1362a3c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/search.ts @@ -0,0 +1,69 @@ +import define from "../../define.js"; +import { Brackets } from "typeorm"; +import { Endpoint } from "@/server/api/endpoint-base.js"; +import { makePaginationQuery } from "@/server/api/common/make-pagination-query.js"; +import { Channels } from "@/models/index.js"; +import { DI } from "@/di-symbols.js"; +import { sqlLikeEscape } from "@/misc/sql-like-escape.js"; + +export const meta = { + tags: ["channels"], + + requireCredential: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Channel", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + query: { type: "string" }, + type: { + type: "string", + enum: ["nameAndDescription", "nameOnly"], + default: "nameAndDescription", + }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 5 }, + }, + required: ["query"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery( + Channels.createQueryBuilder("channel"), + ps.sinceId, + ps.untilId, + ); + + if (ps.type === "nameAndDescription") { + query.andWhere( + new Brackets((qb) => { + qb.where("channel.name ILIKE :q", { + q: `%${sqlLikeEscape(ps.query)}%`, + }).orWhere("channel.description ILIKE :q", { + q: `%${sqlLikeEscape(ps.query)}%`, + }); + }), + ); + } else { + query.andWhere("channel.name ILIKE :q", { + q: `%${sqlLikeEscape(ps.query)}%`, + }); + } + + const channels = await query.take(ps.limit).getMany(); + + return await Promise.all(channels.map((x) => Channels.pack(x, me))); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/show.ts b/packages/backend/src/server/api/endpoints/channels/show.ts new file mode 100644 index 0000000..34858c2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/show.ts @@ -0,0 +1,44 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { Channels } from "@/models/index.js"; + +export const meta = { + tags: ["channels"], + + requireCredential: true, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Channel", + }, + + errors: { + noSuchChannel: { + message: "No such channel.", + code: "NO_SUCH_CHANNEL", + id: "6f6c314b-7486-4897-8966-c04a66a02923", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + channelId: { type: "string", format: "misskey:id" }, + }, + required: ["channelId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const channel = await Channels.findOneBy({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + return await Channels.pack(channel, me); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts new file mode 100644 index 0000000..719883c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -0,0 +1,79 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { Notes, Channels } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { activeUsersChart } from "@/services/chart/index.js"; +import { generateExcludeMemorietQuery } from "@/server/api/common/generate-exclude-memoriet-query.js"; + +export const meta = { + tags: ["notes", "channels"], + + requireCredential: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, + + errors: { + noSuchChannel: { + message: "No such channel.", + code: "NO_SUCH_CHANNEL", + id: "4d0eeeba-a02c-4c3c-9966-ef60d38d2e7f", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + channelId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + sinceDate: { type: "integer" }, + untilDate: { type: "integer" }, + }, + required: ["channelId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const channel = await Channels.findOneBy({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + //#region Construct query + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ps.sinceDate, + ps.untilDate, + ) + .andWhere("note.channelId = :channelId", { channelId: channel.id }) + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser") + .leftJoinAndSelect("note.channel", "channel"); + generateExcludeMemorietQuery(query); + //#endregion + + const timeline = await query.take(ps.limit).getMany(); + + if (user) activeUsersChart.read(user); + + return await Notes.packMany(timeline, user); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/unfollow.ts b/packages/backend/src/server/api/endpoints/channels/unfollow.ts new file mode 100644 index 0000000..654a4fb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/unfollow.ts @@ -0,0 +1,45 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { Channels, ChannelFollowings } from "@/models/index.js"; +import { publishUserEvent } from "@/services/stream.js"; + +export const meta = { + tags: ["channels"], + + requireCredential: true, + + kind: "write:channels", + + errors: { + noSuchChannel: { + message: "No such channel.", + code: "NO_SUCH_CHANNEL", + id: "19959ee9-0153-4c51-bbd9-a98c49dc59d6", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + channelId: { type: "string", format: "misskey:id" }, + }, + required: ["channelId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const channel = await Channels.findOneBy({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + await ChannelFollowings.delete({ + followerId: user.id, + followeeId: channel.id, + }); + + publishUserEvent(user.id, "unfollowChannel", channel); +}); diff --git a/packages/backend/src/server/api/endpoints/channels/update.ts b/packages/backend/src/server/api/endpoints/channels/update.ts new file mode 100644 index 0000000..d9f6f76 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/update.ts @@ -0,0 +1,90 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { Channels, DriveFiles } from "@/models/index.js"; + +export const meta = { + tags: ["channels"], + + requireCredential: true, + + kind: "write:channels", + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Channel", + }, + + errors: { + noSuchChannel: { + message: "No such channel.", + code: "NO_SUCH_CHANNEL", + id: "f9c5467f-d492-4c3c-9a8d-a70dacc86512", + }, + + accessDenied: { + message: "You do not have edit privilege of the channel.", + code: "ACCESS_DENIED", + id: "1fb7cb09-d46a-4fdf-b8df-057788cce513", + }, + + noSuchFile: { + message: "No such file.", + code: "NO_SUCH_FILE", + id: "e86c14a4-0da2-4032-8df3-e737a04c7f3b", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + channelId: { type: "string", format: "misskey:id" }, + name: { type: "string", minLength: 1, maxLength: 128 }, + description: { + type: "string", + nullable: true, + minLength: 1, + maxLength: 2048, + }, + bannerId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: ["channelId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const channel = await Channels.findOneBy({ + id: ps.channelId, + }); + + if (channel == null) { + throw new ApiError(meta.errors.noSuchChannel); + } + + if (channel.userId !== me.id) { + throw new ApiError(meta.errors.accessDenied); + } + + let banner = undefined; + if (ps.bannerId != null) { + banner = await DriveFiles.findOneBy({ + id: ps.bannerId, + userId: me.id, + }); + + if (banner == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } else if (ps.bannerId === null) { + banner = null; + } + + await Channels.update(channel.id, { + ...(ps.name !== undefined ? { name: ps.name } : {}), + ...(ps.description !== undefined ? { description: ps.description } : {}), + ...(banner ? { bannerId: banner.id } : {}), + }); + + return await Channels.pack(channel.id, me); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/active-users.ts b/packages/backend/src/server/api/endpoints/charts/active-users.ts new file mode 100644 index 0000000..3817a32 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/active-users.ts @@ -0,0 +1,31 @@ +import { getJsonSchema } from "@/services/chart/core.js"; +import { activeUsersChart } from "@/services/chart/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["charts", "users"], + requireCredentialPrivateMode: true, + + res: getJsonSchema(activeUsersChart.schema), + + allowGet: true, + cacheSec: 60 * 60, +} as const; + +export const paramDef = { + type: "object", + properties: { + span: { type: "string", enum: ["day", "hour"] }, + limit: { type: "integer", minimum: 1, maximum: 500, default: 30 }, + offset: { type: "integer", nullable: true, default: null }, + }, + required: ["span"], +} as const; + +export default define(meta, paramDef, async (ps) => { + return await activeUsersChart.getChart( + ps.span, + ps.limit, + ps.offset ? new Date(ps.offset) : null, + ); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/ap-request.ts b/packages/backend/src/server/api/endpoints/charts/ap-request.ts new file mode 100644 index 0000000..9e9013c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/ap-request.ts @@ -0,0 +1,31 @@ +import { getJsonSchema } from "@/services/chart/core.js"; +import { apRequestChart } from "@/services/chart/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["charts"], + requireCredentialPrivateMode: true, + + res: getJsonSchema(apRequestChart.schema), + + allowGet: true, + cacheSec: 60 * 60, +} as const; + +export const paramDef = { + type: "object", + properties: { + span: { type: "string", enum: ["day", "hour"] }, + limit: { type: "integer", minimum: 1, maximum: 500, default: 30 }, + offset: { type: "integer", nullable: true, default: null }, + }, + required: ["span"], +} as const; + +export default define(meta, paramDef, async (ps) => { + return await apRequestChart.getChart( + ps.span, + ps.limit, + ps.offset ? new Date(ps.offset) : null, + ); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/drive.ts b/packages/backend/src/server/api/endpoints/charts/drive.ts new file mode 100644 index 0000000..03ac4c0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/drive.ts @@ -0,0 +1,31 @@ +import { getJsonSchema } from "@/services/chart/core.js"; +import { driveChart } from "@/services/chart/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["charts", "drive"], + requireCredentialPrivateMode: true, + + res: getJsonSchema(driveChart.schema), + + allowGet: true, + cacheSec: 60 * 60, +} as const; + +export const paramDef = { + type: "object", + properties: { + span: { type: "string", enum: ["day", "hour"] }, + limit: { type: "integer", minimum: 1, maximum: 500, default: 30 }, + offset: { type: "integer", nullable: true, default: null }, + }, + required: ["span"], +} as const; + +export default define(meta, paramDef, async (ps) => { + return await driveChart.getChart( + ps.span, + ps.limit, + ps.offset ? new Date(ps.offset) : null, + ); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/federation.ts b/packages/backend/src/server/api/endpoints/charts/federation.ts new file mode 100644 index 0000000..5862aad --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/federation.ts @@ -0,0 +1,31 @@ +import { getJsonSchema } from "@/services/chart/core.js"; +import { federationChart } from "@/services/chart/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["charts"], + requireCredentialPrivateMode: true, + + res: getJsonSchema(federationChart.schema), + + allowGet: true, + cacheSec: 60 * 60, +} as const; + +export const paramDef = { + type: "object", + properties: { + span: { type: "string", enum: ["day", "hour"] }, + limit: { type: "integer", minimum: 1, maximum: 500, default: 30 }, + offset: { type: "integer", nullable: true, default: null }, + }, + required: ["span"], +} as const; + +export default define(meta, paramDef, async (ps) => { + return await federationChart.getChart( + ps.span, + ps.limit, + ps.offset ? new Date(ps.offset) : null, + ); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/hashtag.ts b/packages/backend/src/server/api/endpoints/charts/hashtag.ts new file mode 100644 index 0000000..0af1e35 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/hashtag.ts @@ -0,0 +1,33 @@ +import { getJsonSchema } from "@/services/chart/core.js"; +import { hashtagChart } from "@/services/chart/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["charts", "hashtags"], + requireCredentialPrivateMode: true, + + res: getJsonSchema(hashtagChart.schema), + + allowGet: true, + cacheSec: 60 * 60, +} as const; + +export const paramDef = { + type: "object", + properties: { + span: { type: "string", enum: ["day", "hour"] }, + limit: { type: "integer", minimum: 1, maximum: 500, default: 30 }, + offset: { type: "integer", nullable: true, default: null }, + tag: { type: "string" }, + }, + required: ["span", "tag"], +} as const; + +export default define(meta, paramDef, async (ps) => { + return await hashtagChart.getChart( + ps.span, + ps.limit, + ps.offset ? new Date(ps.offset) : null, + ps.tag, + ); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/instance.ts b/packages/backend/src/server/api/endpoints/charts/instance.ts new file mode 100644 index 0000000..11a1dbc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/instance.ts @@ -0,0 +1,33 @@ +import { getJsonSchema } from "@/services/chart/core.js"; +import { instanceChart } from "@/services/chart/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["charts"], + requireCredentialPrivateMode: true, + + res: getJsonSchema(instanceChart.schema), + + allowGet: true, + cacheSec: 60 * 60, +} as const; + +export const paramDef = { + type: "object", + properties: { + span: { type: "string", enum: ["day", "hour"] }, + limit: { type: "integer", minimum: 1, maximum: 500, default: 30 }, + offset: { type: "integer", nullable: true, default: null }, + host: { type: "string" }, + }, + required: ["span", "host"], +} as const; + +export default define(meta, paramDef, async (ps) => { + return await instanceChart.getChart( + ps.span, + ps.limit, + ps.offset ? new Date(ps.offset) : null, + ps.host, + ); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/notes.ts b/packages/backend/src/server/api/endpoints/charts/notes.ts new file mode 100644 index 0000000..27e69a4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/notes.ts @@ -0,0 +1,31 @@ +import { getJsonSchema } from "@/services/chart/core.js"; +import { notesChart } from "@/services/chart/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["charts", "notes"], + requireCredentialPrivateMode: true, + + res: getJsonSchema(notesChart.schema), + + allowGet: true, + cacheSec: 60 * 60, +} as const; + +export const paramDef = { + type: "object", + properties: { + span: { type: "string", enum: ["day", "hour"] }, + limit: { type: "integer", minimum: 1, maximum: 500, default: 30 }, + offset: { type: "integer", nullable: true, default: null }, + }, + required: ["span"], +} as const; + +export default define(meta, paramDef, async (ps) => { + return await notesChart.getChart( + ps.span, + ps.limit, + ps.offset ? new Date(ps.offset) : null, + ); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/user/drive.ts b/packages/backend/src/server/api/endpoints/charts/user/drive.ts new file mode 100644 index 0000000..178ba45 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/user/drive.ts @@ -0,0 +1,33 @@ +import { getJsonSchema } from "@/services/chart/core.js"; +import { perUserDriveChart } from "@/services/chart/index.js"; +import define from "../../../define.js"; + +export const meta = { + tags: ["charts", "drive", "users"], + requireCredentialPrivateMode: true, + + res: getJsonSchema(perUserDriveChart.schema), + + allowGet: true, + cacheSec: 60 * 60, +} as const; + +export const paramDef = { + type: "object", + properties: { + span: { type: "string", enum: ["day", "hour"] }, + limit: { type: "integer", minimum: 1, maximum: 500, default: 30 }, + offset: { type: "integer", nullable: true, default: null }, + userId: { type: "string", format: "misskey:id" }, + }, + required: ["span", "userId"], +} as const; + +export default define(meta, paramDef, async (ps) => { + return await perUserDriveChart.getChart( + ps.span, + ps.limit, + ps.offset ? new Date(ps.offset) : null, + ps.userId, + ); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/user/following.ts b/packages/backend/src/server/api/endpoints/charts/user/following.ts new file mode 100644 index 0000000..6a0c22d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/user/following.ts @@ -0,0 +1,33 @@ +import define from "../../../define.js"; +import { getJsonSchema } from "@/services/chart/core.js"; +import { perUserFollowingChart } from "@/services/chart/index.js"; + +export const meta = { + tags: ["charts", "users", "following"], + requireCredentialPrivateMode: true, + + res: getJsonSchema(perUserFollowingChart.schema), + + allowGet: true, + cacheSec: 60 * 60, +} as const; + +export const paramDef = { + type: "object", + properties: { + span: { type: "string", enum: ["day", "hour"] }, + limit: { type: "integer", minimum: 1, maximum: 500, default: 30 }, + offset: { type: "integer", nullable: true, default: null }, + userId: { type: "string", format: "misskey:id" }, + }, + required: ["span", "userId"], +} as const; + +export default define(meta, paramDef, async (ps) => { + return await perUserFollowingChart.getChart( + ps.span, + ps.limit, + ps.offset ? new Date(ps.offset) : null, + ps.userId, + ); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/user/notes.ts b/packages/backend/src/server/api/endpoints/charts/user/notes.ts new file mode 100644 index 0000000..d788076 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/user/notes.ts @@ -0,0 +1,33 @@ +import { getJsonSchema } from "@/services/chart/core.js"; +import { perUserNotesChart } from "@/services/chart/index.js"; +import define from "../../../define.js"; + +export const meta = { + tags: ["charts", "users", "notes"], + requireCredentialPrivateMode: true, + + res: getJsonSchema(perUserNotesChart.schema), + + allowGet: true, + cacheSec: 60 * 60, +} as const; + +export const paramDef = { + type: "object", + properties: { + span: { type: "string", enum: ["day", "hour"] }, + limit: { type: "integer", minimum: 1, maximum: 500, default: 30 }, + offset: { type: "integer", nullable: true, default: null }, + userId: { type: "string", format: "misskey:id" }, + }, + required: ["span", "userId"], +} as const; + +export default define(meta, paramDef, async (ps) => { + return await perUserNotesChart.getChart( + ps.span, + ps.limit, + ps.offset ? new Date(ps.offset) : null, + ps.userId, + ); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts new file mode 100644 index 0000000..5b0048c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts @@ -0,0 +1,33 @@ +import { getJsonSchema } from "@/services/chart/core.js"; +import { perUserReactionsChart } from "@/services/chart/index.js"; +import define from "../../../define.js"; + +export const meta = { + tags: ["charts", "users", "reactions"], + requireCredentialPrivateMode: true, + + res: getJsonSchema(perUserReactionsChart.schema), + + allowGet: true, + cacheSec: 60 * 60, +} as const; + +export const paramDef = { + type: "object", + properties: { + span: { type: "string", enum: ["day", "hour"] }, + limit: { type: "integer", minimum: 1, maximum: 500, default: 30 }, + offset: { type: "integer", nullable: true, default: null }, + userId: { type: "string", format: "misskey:id" }, + }, + required: ["span", "userId"], +} as const; + +export default define(meta, paramDef, async (ps) => { + return await perUserReactionsChart.getChart( + ps.span, + ps.limit, + ps.offset ? new Date(ps.offset) : null, + ps.userId, + ); +}); diff --git a/packages/backend/src/server/api/endpoints/charts/users.ts b/packages/backend/src/server/api/endpoints/charts/users.ts new file mode 100644 index 0000000..8973f01 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/charts/users.ts @@ -0,0 +1,31 @@ +import { getJsonSchema } from "@/services/chart/core.js"; +import { usersChart } from "@/services/chart/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["charts", "users"], + requireCredentialPrivateMode: true, + + res: getJsonSchema(usersChart.schema), + + allowGet: true, + cacheSec: 60 * 60, +} as const; + +export const paramDef = { + type: "object", + properties: { + span: { type: "string", enum: ["day", "hour"] }, + limit: { type: "integer", minimum: 1, maximum: 500, default: 30 }, + offset: { type: "integer", nullable: true, default: null }, + }, + required: ["span"], +} as const; + +export default define(meta, paramDef, async (ps) => { + return await usersChart.getChart( + ps.span, + ps.limit, + ps.offset ? new Date(ps.offset) : null, + ); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/add-note.ts b/packages/backend/src/server/api/endpoints/clips/add-note.ts new file mode 100644 index 0000000..416af6f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/add-note.ts @@ -0,0 +1,76 @@ +import define from "../../define.js"; +import { ClipNotes, Clips } from "@/models/index.js"; +import { ApiError } from "../../error.js"; +import { genId } from "@/misc/gen-id.js"; +import { getNote } from "../../common/getters.js"; + +export const meta = { + tags: ["account", "notes", "clips"], + + requireCredential: true, + + kind: "write:account", + + errors: { + noSuchClip: { + message: "No such clip.", + code: "NO_SUCH_CLIP", + id: "d6e76cc0-a1b5-4c7c-a287-73fa9c716dcf", + }, + + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "fc8c0b49-c7a3-4664-a0a6-b418d386bb8b", + }, + + alreadyClipped: { + message: "The note has already been clipped.", + code: "ALREADY_CLIPPED", + id: "734806c4-542c-463a-9311-15c512803965", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + clipId: { type: "string", format: "misskey:id" }, + noteId: { type: "string", format: "misskey:id" }, + }, + required: ["clipId", "noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const clip = await Clips.findOneBy({ + id: ps.clipId, + userId: user.id, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + const note = await getNote(ps.noteId, user).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + const exist = await ClipNotes.exist({ + where: { + noteId: note.id, + clipId: clip.id, + }, + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyClipped); + } + + await ClipNotes.insert({ + id: genId(), + noteId: note.id, + clipId: clip.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/create.ts b/packages/backend/src/server/api/endpoints/clips/create.ts new file mode 100644 index 0000000..918e946 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/create.ts @@ -0,0 +1,46 @@ +import define from "../../define.js"; +import { genId } from "@/misc/gen-id.js"; +import { Clips } from "@/models/index.js"; + +export const meta = { + tags: ["clips"], + + requireCredential: true, + + kind: "write:account", + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Clip", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 100 }, + isPublic: { type: "boolean", default: false }, + description: { + type: "string", + nullable: true, + minLength: 1, + maxLength: 2048, + }, + }, + required: ["name"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const clip = await Clips.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + isPublic: ps.isPublic, + description: ps.description, + }).then((x) => Clips.findOneByOrFail(x.identifiers[0])); + + return await Clips.pack(clip); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/delete.ts b/packages/backend/src/server/api/endpoints/clips/delete.ts new file mode 100644 index 0000000..8f2489d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/delete.ts @@ -0,0 +1,40 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { Clips } from "@/models/index.js"; + +export const meta = { + tags: ["clips"], + + requireCredential: true, + + kind: "write:account", + + errors: { + noSuchClip: { + message: "No such clip.", + code: "NO_SUCH_CLIP", + id: "70ca08ba-6865-4630-b6fb-8494759aa754", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + clipId: { type: "string", format: "misskey:id" }, + }, + required: ["clipId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const clip = await Clips.findOneBy({ + id: ps.clipId, + userId: user.id, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + await Clips.delete(clip.id); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/list.ts b/packages/backend/src/server/api/endpoints/clips/list.ts new file mode 100644 index 0000000..d1625ee --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/list.ts @@ -0,0 +1,36 @@ +import define from "../../define.js"; +import { Clips } from "@/models/index.js"; + +export const meta = { + tags: ["clips", "account"], + + requireCredential: true, + + kind: "read:account", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Clip", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const clips = await Clips.findBy({ + userId: me.id, + }); + + return await Promise.all(clips.map((x) => Clips.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts new file mode 100644 index 0000000..a414b46 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -0,0 +1,88 @@ +import define from "../../define.js"; +import { ClipNotes, Clips, Notes } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { ApiError } from "../../error.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; + +export const meta = { + tags: ["account", "notes", "clips"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + kind: "read:account", + + errors: { + noSuchClip: { + message: "No such clip.", + code: "NO_SUCH_CLIP", + id: "1d7645e6-2b6d-4635-b0fe-fe22b0e72e00", + }, + }, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + clipId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: ["clipId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const clip = await Clips.findOneBy({ + id: ps.clipId, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + if (!clip.isPublic && (user == null || clip.userId !== user.id)) { + throw new ApiError(meta.errors.noSuchClip); + } + + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ) + .innerJoin( + ClipNotes.metadata.targetName, + "clipNote", + "clipNote.noteId = note.id", + ) + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser") + .andWhere("clipNote.clipId = :clipId", { clipId: clip.id }); + + if (user) { + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + } + + const notes = await query.take(ps.limit).getMany(); + + return await Notes.packMany(notes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/remove-note.ts b/packages/backend/src/server/api/endpoints/clips/remove-note.ts new file mode 100644 index 0000000..2cc19ac --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/remove-note.ts @@ -0,0 +1,57 @@ +import define from "../../define.js"; +import { ClipNotes, Clips } from "@/models/index.js"; +import { ApiError } from "../../error.js"; +import { getNote } from "../../common/getters.js"; + +export const meta = { + tags: ["account", "notes", "clips"], + + requireCredential: true, + + kind: "write:account", + + errors: { + noSuchClip: { + message: "No such clip.", + code: "NO_SUCH_CLIP", + id: "b80525c6-97f7-49d7-a42d-ebccd49cfd52", + }, + + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "aff017de-190e-434b-893e-33a9ff5049d8", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + clipId: { type: "string", format: "misskey:id" }, + noteId: { type: "string", format: "misskey:id" }, + }, + required: ["clipId", "noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const clip = await Clips.findOneBy({ + id: ps.clipId, + userId: user.id, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + const note = await getNote(ps.noteId).catch((e) => { + if (e.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + await ClipNotes.delete({ + noteId: note.id, + clipId: clip.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/show.ts b/packages/backend/src/server/api/endpoints/clips/show.ts new file mode 100644 index 0000000..14709b5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/show.ts @@ -0,0 +1,52 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { Clips } from "@/models/index.js"; + +export const meta = { + tags: ["clips", "account"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + kind: "read:account", + + errors: { + noSuchClip: { + message: "No such clip.", + code: "NO_SUCH_CLIP", + id: "c3c5fe33-d62c-44d2-9ea5-d997703f5c20", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Clip", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + clipId: { type: "string", format: "misskey:id" }, + }, + required: ["clipId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + // Fetch the clip + const clip = await Clips.findOneBy({ + id: ps.clipId, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + if (!clip.isPublic && (me == null || clip.userId !== me.id)) { + throw new ApiError(meta.errors.noSuchClip); + } + + return await Clips.pack(clip); +}); diff --git a/packages/backend/src/server/api/endpoints/clips/update.ts b/packages/backend/src/server/api/endpoints/clips/update.ts new file mode 100644 index 0000000..e78f36e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/update.ts @@ -0,0 +1,62 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { Clips } from "@/models/index.js"; + +export const meta = { + tags: ["clips"], + + requireCredential: true, + + kind: "write:account", + + errors: { + noSuchClip: { + message: "No such clip.", + code: "NO_SUCH_CLIP", + id: "b4d92d70-b216-46fa-9a3f-a8c811699257", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Clip", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + clipId: { type: "string", format: "misskey:id" }, + name: { type: "string", minLength: 1, maxLength: 100 }, + isPublic: { type: "boolean" }, + description: { + type: "string", + nullable: true, + minLength: 1, + maxLength: 2048, + }, + }, + required: ["clipId", "name"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Fetch the clip + const clip = await Clips.findOneBy({ + id: ps.clipId, + userId: user.id, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + await Clips.update(clip.id, { + name: ps.name, + description: ps.description, + isPublic: ps.isPublic, + }); + + return await Clips.pack(clip.id); +}); diff --git a/packages/backend/src/server/api/endpoints/compatibility/custom-emojis.ts b/packages/backend/src/server/api/endpoints/compatibility/custom-emojis.ts new file mode 100644 index 0000000..62e0836 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/compatibility/custom-emojis.ts @@ -0,0 +1,37 @@ +import { Emojis } from "@/models/index.js"; +import type { Emoji } from "@/models/entities/emoji.js"; +import { IsNull, In } from "typeorm"; +import { FILE_TYPE_BROWSERSAFE } from "@/const.js"; +import define from "../../define.js"; + +export const meta = { + requireCredential: false, + requireCredentialPrivateMode: true, + allowGet: true, + + tags: ["meta"], +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async () => { + const now = Date.now(); + const emojis: Emoji[] = await Emojis.find({ + where: { host: IsNull(), type: In(FILE_TYPE_BROWSERSAFE) }, + select: ["name", "originalUrl", "publicUrl", "category"], + }); + + const emojiList = emojis.map((emoji) => ({ + shortcode: emoji.name, + url: emoji.originalUrl, + static_url: emoji.publicUrl, + visible_in_picker: true, + category: emoji.category, + })); + + return emojiList; +}); diff --git a/packages/backend/src/server/api/endpoints/compatibility/peers.ts b/packages/backend/src/server/api/endpoints/compatibility/peers.ts new file mode 100644 index 0000000..30f6e0e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/compatibility/peers.ts @@ -0,0 +1,25 @@ +import { Instances } from "@/models/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["meta"], + requireCredential: false, + requireCredentialPrivateMode: true, + allowGet: true, + cacheSec: 60, +} as const; + +export const paramDef = { + type: "object", +} as const; + +export default define(meta, paramDef, async (ps) => { + const instances = await Instances.find({ + select: ["host"], + where: { + isSuspended: false, + }, + }); + + return instances.map((instance) => instance.host); +}); diff --git a/packages/backend/src/server/api/endpoints/custom-motd.ts b/packages/backend/src/server/api/endpoints/custom-motd.ts new file mode 100644 index 0000000..098a676 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/custom-motd.ts @@ -0,0 +1,33 @@ +// import { IsNull } from 'typeorm'; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import define from "../define.js"; + +export const meta = { + tags: ["meta"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async () => { + const meta = await fetchMeta(); + const motd = await Promise.all(meta.customMOTD.map((x) => x)); + return motd; +}); diff --git a/packages/backend/src/server/api/endpoints/custom-splash-icons.ts b/packages/backend/src/server/api/endpoints/custom-splash-icons.ts new file mode 100644 index 0000000..c4833a4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/custom-splash-icons.ts @@ -0,0 +1,33 @@ +// import { IsNull } from 'typeorm'; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import define from "../define.js"; + +export const meta = { + tags: ["meta"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async () => { + const meta = await fetchMeta(); + const icons = await Promise.all(meta.customSplashIcons.map((x) => x)); + return icons; +}); diff --git a/packages/backend/src/server/api/endpoints/drive.ts b/packages/backend/src/server/api/endpoints/drive.ts new file mode 100644 index 0000000..ce98b53 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive.ts @@ -0,0 +1,50 @@ +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { DriveFiles } from "@/models/index.js"; +import define from "../define.js"; + +export const meta = { + tags: ["drive", "account"], + + requireCredential: true, + + kind: "read:drive", + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + capacity: { + type: "number", + optional: false, + nullable: false, + }, + usage: { + type: "number", + optional: false, + nullable: false, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const instance = await fetchMeta(true); + + // Calculate drive usage + const usage = await DriveFiles.calcDriveUsageOf(user.id); + + return { + capacity: + 1024 * + 1024 * + (user.driveCapacityOverrideMb || instance.localDriveCapacityMb), + usage: usage, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts new file mode 100644 index 0000000..c749e49 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/files.ts @@ -0,0 +1,72 @@ +import define from "../../define.js"; +import { DriveFiles } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["drive"], + + requireCredential: true, + + kind: "read:drive", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "DriveFile", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + folderId: { + type: "string", + format: "misskey:id", + nullable: true, + default: null, + }, + type: { + type: "string", + nullable: true, + pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1), + }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + DriveFiles.createQueryBuilder("file"), + ps.sinceId, + ps.untilId, + ).andWhere("file.userId = :userId", { userId: user.id }); + + if (ps.folderId) { + query.andWhere("file.folderId = :folderId", { folderId: ps.folderId }); + } else { + query.andWhere("file.folderId IS NULL"); + } + + if (ps.type) { + if (ps.type.endsWith("/*")) { + query.andWhere("file.type like :type", { + type: `${ps.type.replace("/*", "/")}%`, + }); + } else { + query.andWhere("file.type = :type", { type: ps.type }); + } + } + + const files = await query.take(ps.limit).getMany(); + + return await DriveFiles.packMany(files, { detail: false, self: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders.ts b/packages/backend/src/server/api/endpoints/drive/folders.ts new file mode 100644 index 0000000..ed0d388 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders.ts @@ -0,0 +1,57 @@ +import define from "../../define.js"; +import { DriveFolders } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["drive"], + + requireCredential: true, + + kind: "read:drive", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "DriveFolder", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + folderId: { + type: "string", + format: "misskey:id", + nullable: true, + default: null, + }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + DriveFolders.createQueryBuilder("folder"), + ps.sinceId, + ps.untilId, + ).andWhere("folder.userId = :userId", { userId: user.id }); + + if (ps.folderId) { + query.andWhere("folder.parentId = :parentId", { parentId: ps.folderId }); + } else { + query.andWhere("folder.parentId IS NULL"); + } + + const folders = await query.take(ps.limit).getMany(); + + return await Promise.all(folders.map((folder) => DriveFolders.pack(folder))); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/create.ts b/packages/backend/src/server/api/endpoints/drive/folders/create.ts new file mode 100644 index 0000000..d50f5f2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders/create.ts @@ -0,0 +1,69 @@ +import { publishDriveStream } from "@/services/stream.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { DriveFolders } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; + +export const meta = { + tags: ["drive"], + + requireCredential: true, + + kind: "write:drive", + + errors: { + noSuchFolder: { + message: "No such folder.", + code: "NO_SUCH_FOLDER", + id: "53326628-a00d-40a6-a3cd-8975105c0f95", + }, + }, + + res: { + type: "object" as const, + optional: false as const, + nullable: false as const, + ref: "DriveFolder", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + name: { type: "string", default: "Untitled", maxLength: 200 }, + parentId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // If the parent folder is specified + let parent = null; + if (ps.parentId) { + // Fetch parent folder + parent = await DriveFolders.findOneBy({ + id: ps.parentId, + userId: user.id, + }); + + if (parent == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + } + + // Create folder + const folder = await DriveFolders.insert({ + id: genId(), + createdAt: new Date(), + name: ps.name, + parentId: parent !== null ? parent.id : null, + userId: user.id, + }).then((x) => DriveFolders.findOneByOrFail(x.identifiers[0])); + + const folderObj = await DriveFolders.pack(folder); + + // Publish folderCreated event + publishDriveStream(user.id, "folderCreated", folderObj); + + return folderObj; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/delete.ts b/packages/backend/src/server/api/endpoints/drive/folders/delete.ts new file mode 100644 index 0000000..98895a7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders/delete.ts @@ -0,0 +1,60 @@ +import define from "../../../define.js"; +import { publishDriveStream } from "@/services/stream.js"; +import { ApiError } from "../../../error.js"; +import { DriveFolders, DriveFiles } from "@/models/index.js"; + +export const meta = { + tags: ["drive"], + + requireCredential: true, + + kind: "write:drive", + + errors: { + noSuchFolder: { + message: "No such folder.", + code: "NO_SUCH_FOLDER", + id: "1069098f-c281-440f-b085-f9932edbe091", + }, + + hasChildFilesOrFolders: { + message: "This folder has child files or folders.", + code: "HAS_CHILD_FILES_OR_FOLDERS", + id: "b0fc8a17-963c-405d-bfbc-859a487295e1", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + folderId: { type: "string", format: "misskey:id" }, + }, + required: ["folderId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Get folder + const folder = await DriveFolders.findOneBy({ + id: ps.folderId, + userId: user.id, + }); + + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + + const [childFoldersCount, childFilesCount] = await Promise.all([ + DriveFolders.countBy({ parentId: folder.id }), + DriveFiles.countBy({ folderId: folder.id }), + ]); + + if (childFoldersCount !== 0 || childFilesCount !== 0) { + throw new ApiError(meta.errors.hasChildFilesOrFolders); + } + + await DriveFolders.delete(folder.id); + + // Publish folderCreated event + publishDriveStream(user.id, "folderDeleted", folder.id); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/find.ts b/packages/backend/src/server/api/endpoints/drive/folders/find.ts new file mode 100644 index 0000000..45451fb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders/find.ts @@ -0,0 +1,47 @@ +import define from "../../../define.js"; +import { DriveFolders } from "@/models/index.js"; +import { IsNull } from "typeorm"; + +export const meta = { + tags: ["drive"], + + requireCredential: true, + + kind: "read:drive", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "DriveFolder", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + name: { type: "string" }, + parentId: { + type: "string", + format: "misskey:id", + nullable: true, + default: null, + }, + }, + required: ["name"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const folders = await DriveFolders.findBy({ + name: ps.name, + userId: user.id, + parentId: ps.parentId ?? IsNull(), + }); + + return await Promise.all(folders.map((folder) => DriveFolders.pack(folder))); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/show.ts b/packages/backend/src/server/api/endpoints/drive/folders/show.ts new file mode 100644 index 0000000..6a72a22 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders/show.ts @@ -0,0 +1,50 @@ +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { DriveFolders } from "@/models/index.js"; + +export const meta = { + tags: ["drive"], + + requireCredential: true, + + kind: "read:drive", + + res: { + type: "object", + optional: false, + nullable: false, + ref: "DriveFolder", + }, + + errors: { + noSuchFolder: { + message: "No such folder.", + code: "NO_SUCH_FOLDER", + id: "d74ab9eb-bb09-4bba-bf24-fb58f761e1e9", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + folderId: { type: "string", format: "misskey:id" }, + }, + required: ["folderId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Get folder + const folder = await DriveFolders.findOneBy({ + id: ps.folderId, + userId: user.id, + }); + + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + + return await DriveFolders.pack(folder, { + detail: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/folders/update.ts b/packages/backend/src/server/api/endpoints/drive/folders/update.ts new file mode 100644 index 0000000..929a69b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/folders/update.ts @@ -0,0 +1,118 @@ +import { publishDriveStream } from "@/services/stream.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { DriveFolders } from "@/models/index.js"; + +export const meta = { + tags: ["drive"], + + requireCredential: true, + + kind: "write:drive", + + errors: { + noSuchFolder: { + message: "No such folder.", + code: "NO_SUCH_FOLDER", + id: "f7974dac-2c0d-4a27-926e-23583b28e98e", + }, + + noSuchParentFolder: { + message: "No such parent folder.", + code: "NO_SUCH_PARENT_FOLDER", + id: "ce104e3a-faaf-49d5-b459-10ff0cbbcaa1", + }, + + recursiveNesting: { + message: "It can not be structured like nesting folders recursively.", + code: "NO_SUCH_PARENT_FOLDER", + id: "ce104e3a-faaf-49d5-b459-10ff0cbbcaa1", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "DriveFolder", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + folderId: { type: "string", format: "misskey:id" }, + name: { type: "string", maxLength: 200 }, + parentId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: ["folderId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Fetch folder + const folder = await DriveFolders.findOneBy({ + id: ps.folderId, + userId: user.id, + }); + + if (folder == null) { + throw new ApiError(meta.errors.noSuchFolder); + } + + if (ps.name) folder.name = ps.name; + + if (ps.parentId !== undefined) { + if (ps.parentId === folder.id) { + throw new ApiError(meta.errors.recursiveNesting); + } else if (ps.parentId === null) { + folder.parentId = null; + } else { + // Get parent folder + const parent = await DriveFolders.findOneBy({ + id: ps.parentId, + userId: user.id, + }); + + if (parent == null) { + throw new ApiError(meta.errors.noSuchParentFolder); + } + + // Check if the circular reference will occur + async function checkCircle(folderId: string): Promise { + // Fetch folder + const folder2 = await DriveFolders.findOneBy({ + id: folderId, + }); + + if (folder2!.id === folder!.id) { + return true; + } else if (folder2!.parentId) { + return await checkCircle(folder2!.parentId); + } else { + return false; + } + } + + if (parent.parentId !== null) { + if (await checkCircle(parent.parentId)) { + throw new ApiError(meta.errors.recursiveNesting); + } + } + + folder.parentId = parent.id; + } + } + + // Update + DriveFolders.update(folder.id, { + name: folder.name, + parentId: folder.parentId, + }); + + const folderObj = await DriveFolders.pack(folder); + + // Publish folderUpdated event + publishDriveStream(user.id, "folderUpdated", folderObj); + + return folderObj; +}); diff --git a/packages/backend/src/server/api/endpoints/drive/stream.ts b/packages/backend/src/server/api/endpoints/drive/stream.ts new file mode 100644 index 0000000..0c9654c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/drive/stream.ts @@ -0,0 +1,59 @@ +import define from "../../define.js"; +import { DriveFiles } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["drive"], + + requireCredential: true, + + kind: "read:drive", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "DriveFile", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + type: { + type: "string", + pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1), + }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + DriveFiles.createQueryBuilder("file"), + ps.sinceId, + ps.untilId, + ).andWhere("file.userId = :userId", { userId: user.id }); + + if (ps.type) { + if (ps.type.endsWith("/*")) { + query.andWhere("file.type like :type", { + type: `${ps.type.replace("/*", "/")}%`, + }); + } else { + query.andWhere("file.type = :type", { type: ps.type }); + } + } + + const files = await query.take(ps.limit).getMany(); + + return await DriveFiles.packMany(files, { detail: false, self: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/email-address/available.ts b/packages/backend/src/server/api/endpoints/email-address/available.ts new file mode 100644 index 0000000..dc3c5e4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/email-address/available.ts @@ -0,0 +1,38 @@ +import define from "../../define.js"; +import { validateEmailForAccount } from "@/services/validate-email-for-account.js"; + +export const meta = { + tags: ["users"], + + requireCredential: false, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + available: { + type: "boolean", + optional: false, + nullable: false, + }, + reason: { + type: "string", + optional: false, + nullable: true, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + emailAddress: { type: "string" }, + }, + required: ["emailAddress"], +} as const; + +export default define(meta, paramDef, async (ps) => { + return await validateEmailForAccount(ps.emailAddress); +}); diff --git a/packages/backend/src/server/api/endpoints/emoji.ts b/packages/backend/src/server/api/endpoints/emoji.ts new file mode 100644 index 0000000..23c464a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/emoji.ts @@ -0,0 +1,81 @@ +import define from "../define.js"; +import { ApiError } from "../error.js"; +import { populateEmojiOrUserEmoji } from "@/misc/populate-emojis.js"; + +export const meta = { + tags: ["meta"], + + requireCredential: false, + allowGet: true, + cacheSec: 3600, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + name: { + type: "string", + optional: false, + nullable: false, + }, + url: { + type: "string", + optional: false, + nullable: false, + }, + glyph: { + type: "boolean", + optional: false, + nullable: false, + }, + glyphUrl: { + type: "string", + optional: false, + nullable: true, + }, + width: { + type: "number", + optional: false, + nullable: true, + }, + height: { + type: "number", + optional: false, + nullable: true, + }, + }, + }, + errors: { + noSuchEmoji: { + message: "No such emoji.", + code: "NO_SUCH_EMOJI", + id: "fc46b5a4-6b92-49e7-9e24-8d9a974f31a4", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + name: { + type: "string", + }, + }, + required: ["name"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const name = /^([:;]).+\1$/.test(ps.name) ? ps.name.slice(1, -1) : ps.name; + const emoji = await populateEmojiOrUserEmoji(name, null); + if (!emoji) throw new ApiError(meta.errors.noSuchEmoji); + + return { + name: emoji.name, + url: emoji.url, + glyph: emoji.glyph, + glyphUrl: emoji.glyphUrl, + width: emoji.width, + height: emoji.height, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/endpoint.ts b/packages/backend/src/server/api/endpoints/endpoint.ts new file mode 100644 index 0000000..ad0ce45 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/endpoint.ts @@ -0,0 +1,27 @@ +import define from "../define.js"; +import endpoints from "../endpoints.js"; + +export const meta = { + requireCredential: false, + + tags: ["meta"], +} as const; + +export const paramDef = { + type: "object", + properties: { + endpoint: { type: "string" }, + }, + required: ["endpoint"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const ep = endpoints.find((x) => x.name === ps.endpoint); + if (ep == null) return null; + return { + params: Object.entries(ep.params.properties || {}).map(([k, v]) => ({ + name: k, + type: v.type.charAt(0).toUpperCase() + v.type.slice(1), + })), + }; +}); diff --git a/packages/backend/src/server/api/endpoints/endpoints.ts b/packages/backend/src/server/api/endpoints/endpoints.ts new file mode 100644 index 0000000..c5844f8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/endpoints.ts @@ -0,0 +1,35 @@ +import define from "../define.js"; +import endpoints from "../endpoints.js"; + +export const meta = { + requireCredential: false, + + tags: ["meta"], + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + example: [ + "admin/abuse-user-reports", + "admin/accounts/create", + "admin/announcements/create", + "...", + ], + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async () => { + return endpoints.map((x) => x.name); +}); diff --git a/packages/backend/src/server/api/endpoints/export-custom-emojis.ts b/packages/backend/src/server/api/endpoints/export-custom-emojis.ts new file mode 100644 index 0000000..f4fc43c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/export-custom-emojis.ts @@ -0,0 +1,22 @@ +import { createExportCustomEmojisJob } from "@/queue/index.js"; +import define from "../define.js"; +import { HOUR } from "@/const.js"; + +export const meta = { + secure: true, + requireCredential: true, + limit: { + duration: HOUR, + max: 1, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + createExportCustomEmojisJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/followers.ts b/packages/backend/src/server/api/endpoints/federation/followers.ts new file mode 100644 index 0000000..4c6d83a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/followers.ts @@ -0,0 +1,46 @@ +import define from "../../define.js"; +import { Followings } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["federation"], + + requireCredential: true, + requireAdmin: true, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Following", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + host: { type: "string" }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + }, + required: ["host"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery( + Followings.createQueryBuilder("following"), + ps.sinceId, + ps.untilId, + ).andWhere("following.followeeHost = :host", { host: ps.host }); + + const followings = await query.take(ps.limit).getMany(); + + return await Followings.packMany(followings, me, { populateFollowee: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/following.ts b/packages/backend/src/server/api/endpoints/federation/following.ts new file mode 100644 index 0000000..88b1686 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/following.ts @@ -0,0 +1,46 @@ +import define from "../../define.js"; +import { Followings } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["federation"], + + requireCredential: true, + requireAdmin: true, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Following", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + host: { type: "string" }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + }, + required: ["host"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery( + Followings.createQueryBuilder("following"), + ps.sinceId, + ps.untilId, + ).andWhere("following.followerHost = :host", { host: ps.host }); + + const followings = await query.take(ps.limit).getMany(); + + return await Followings.packMany(followings, me, { populateFollowee: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/instances.ts b/packages/backend/src/server/api/endpoints/federation/instances.ts new file mode 100644 index 0000000..f942232 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/instances.ts @@ -0,0 +1,195 @@ +import config from "@/config/index.js"; +import define from "../../define.js"; +import { Instances } from "@/models/index.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { sqlLikeEscape } from "@/misc/sql-like-escape.js"; + +export const meta = { + tags: ["federation"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "FederationInstance", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + host: { + type: "string", + nullable: true, + description: "Omit or use `null` to not filter by host.", + }, + blocked: { type: "boolean", nullable: true }, + notResponding: { type: "boolean", nullable: true }, + suspended: { type: "boolean", nullable: true }, + federating: { type: "boolean", nullable: true }, + silenced: { type: "boolean", nullable: true }, + subscribing: { type: "boolean", nullable: true }, + publishing: { type: "boolean", nullable: true }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 30 }, + offset: { type: "integer", default: 0 }, + sort: { type: "string" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + if (!me) { + ps.blocked = false; + ps.suspended = false; + ps.silenced = false; + } + + const query = Instances.createQueryBuilder("instance"); + + switch (ps.sort) { + case "+pubSub": + query + .orderBy("instance.followingCount", "DESC") + .orderBy("instance.followersCount", "DESC"); + break; + case "-pubSub": + query + .orderBy("instance.followingCount", "ASC") + .orderBy("instance.followersCount", "ASC"); + break; + case "+notes": + query.orderBy("instance.notesCount", "DESC"); + break; + case "-notes": + query.orderBy("instance.notesCount", "ASC"); + break; + case "+users": + query.orderBy("instance.usersCount", "DESC"); + break; + case "-users": + query.orderBy("instance.usersCount", "ASC"); + break; + case "+following": + query.orderBy("instance.followingCount", "DESC"); + break; + case "-following": + query.orderBy("instance.followingCount", "ASC"); + break; + case "+followers": + query.orderBy("instance.followersCount", "DESC"); + break; + case "-followers": + query.orderBy("instance.followersCount", "ASC"); + break; + case "+caughtAt": + query.orderBy("instance.caughtAt", "DESC"); + break; + case "-caughtAt": + query.orderBy("instance.caughtAt", "ASC"); + break; + case "+lastCommunicatedAt": + query.orderBy("instance.lastCommunicatedAt", "DESC"); + break; + case "-lastCommunicatedAt": + query.orderBy("instance.lastCommunicatedAt", "ASC"); + break; + + default: + query.orderBy("instance.id", "DESC"); + break; + } + + if (typeof ps.blocked === "boolean") { + const meta = await fetchMeta(true); + if (ps.blocked) { + if (meta.blockedHosts.length === 0) { + return []; + } + query.andWhere("instance.host IN (:...blocks)", { + blocks: meta.blockedHosts, + }); + } else if (meta.blockedHosts.length > 0) { + query.andWhere("instance.host NOT IN (:...blocks)", { + blocks: meta.blockedHosts, + }); + } + } + + if (typeof ps.silenced === "boolean") { + const meta = await fetchMeta(true); + if (ps.silenced) { + if (meta.silencedHosts.length === 0) { + return []; + } + query.andWhere("instance.host IN (:...silences)", { + silences: meta.silencedHosts, + }); + } else if (meta.silencedHosts.length > 0) { + query.andWhere("instance.host NOT IN (:...silences)", { + silences: meta.silencedHosts, + }); + } + } + + if (typeof ps.notResponding === "boolean") { + if (ps.notResponding) { + query.andWhere("instance.isNotResponding = TRUE"); + } else { + query.andWhere("instance.isNotResponding = FALSE"); + } + } + + if (typeof ps.suspended === "boolean") { + if (ps.suspended) { + query.andWhere("instance.isSuspended = TRUE"); + } else { + query.andWhere("instance.isSuspended = FALSE"); + } + } + + if (typeof ps.federating === "boolean") { + if (ps.federating) { + query.andWhere( + "((instance.followingCount > 0) OR (instance.followersCount > 0))", + ); + } else { + query.andWhere( + "((instance.followingCount = 0) AND (instance.followersCount = 0))", + ); + } + } + + if (typeof ps.subscribing === "boolean") { + if (ps.subscribing) { + query.andWhere("instance.followersCount > 0"); + } else { + query.andWhere("instance.followersCount = 0"); + } + } + + if (typeof ps.publishing === "boolean") { + if (ps.publishing) { + query.andWhere("instance.followingCount > 0"); + } else { + query.andWhere("instance.followingCount = 0"); + } + } + + if (ps.host) { + query.andWhere("instance.host like :host", { + host: `%${sqlLikeEscape(ps.host.toLowerCase())}%`, + }); + } + + const instances = await query.take(ps.limit).skip(ps.offset).getMany(); + + return await Instances.packMany(instances, me !== null); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/show-instance.ts b/packages/backend/src/server/api/endpoints/federation/show-instance.ts new file mode 100644 index 0000000..5a60cc9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/show-instance.ts @@ -0,0 +1,36 @@ +import define from "../../define.js"; +import { Instances } from "@/models/index.js"; +import { toPuny } from "@/misc/convert-host.js"; + +export const meta = { + tags: ["federation"], + + requireCredential: true, + requireCredentialPrivateMode: true, + + res: { + oneOf: [ + { + type: "object", + ref: "FederationInstance", + }, + { + type: "null", + }, + ], + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + host: { type: "string" }, + }, + required: ["host"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const instance = await Instances.findOneBy({ host: toPuny(ps.host) }); + + return instance ? await Instances.pack(instance, me !== null) : null; +}); diff --git a/packages/backend/src/server/api/endpoints/federation/stats.ts b/packages/backend/src/server/api/endpoints/federation/stats.ts new file mode 100644 index 0000000..0d6eb36 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/stats.ts @@ -0,0 +1,69 @@ +import { IsNull, MoreThan, Not } from "typeorm"; +import { Followings, Instances } from "@/models/index.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["federation"], + + requireCredential: false, + + allowGet: true, + cacheSec: 60 * 60, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const [topSubInstances, topPubInstances, allSubCount, allPubCount] = + await Promise.all([ + Instances.find({ + where: { + followersCount: MoreThan(0), + }, + order: { + followersCount: "DESC", + }, + take: ps.limit, + }), + Instances.find({ + where: { + followingCount: MoreThan(0), + }, + order: { + followingCount: "DESC", + }, + take: ps.limit, + }), + Followings.count({ + where: { + followeeHost: Not(IsNull()), + }, + }), + Followings.count({ + where: { + followerHost: Not(IsNull()), + }, + }), + ]); + + const gotSubCount = topSubInstances + .map((x) => x.followersCount) + .reduce((a, b) => a + b, 0); + const gotPubCount = topPubInstances + .map((x) => x.followingCount) + .reduce((a, b) => a + b, 0); + + return await awaitAll({ + topSubInstances: Instances.packMany(topSubInstances, me !== null), + otherFollowersCount: Math.max(0, allSubCount - gotSubCount), + topPubInstances: Instances.packMany(topPubInstances, me !== null), + otherFollowingCount: Math.max(0, allPubCount - gotPubCount), + }); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts new file mode 100644 index 0000000..9d6e4a7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/update-remote-user.ts @@ -0,0 +1,22 @@ +import define from "../../define.js"; +import { getRemoteUser } from "../../common/getters.js"; +import { updatePerson } from "@/remote/activitypub/models/person.js"; + +export const meta = { + tags: ["federation"], + + requireCredential: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const user = await getRemoteUser(ps.userId); + await updatePerson(user.uri!, undefined, undefined, user); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/users.ts b/packages/backend/src/server/api/endpoints/federation/users.ts new file mode 100644 index 0000000..ded0a26 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/users.ts @@ -0,0 +1,45 @@ +import define from "../../define.js"; +import { Users } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["federation"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "UserDetailedNotMe", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + host: { type: "string" }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + }, + required: ["host"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery( + Users.createQueryBuilder("user"), + ps.sinceId, + ps.untilId, + ).andWhere("user.host = :host", { host: ps.host }); + + const users = await query.take(ps.limit).getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/fetch-rss.ts b/packages/backend/src/server/api/endpoints/fetch-rss.ts new file mode 100644 index 0000000..b73d726 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/fetch-rss.ts @@ -0,0 +1,38 @@ +import Parser from "rss-parser"; +import { getResponse } from "@/misc/fetch.js"; +import config from "@/config/index.js"; +import define from "../define.js"; + +const rssParser = new Parser(); + +export const meta = { + tags: ["meta"], + + requireCredential: false, + allowGet: true, + cacheSec: 60 * 3, +} as const; + +export const paramDef = { + type: "object", + properties: { + url: { type: "string" }, + }, + required: ["url"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const res = await getResponse({ + url: ps.url, + method: "GET", + headers: Object.assign({ + "User-Agent": config.userAgent, + Accept: "application/rss+xml, */*", + }), + timeout: 5000, + }); + + const text = await res.text(); + + return rssParser.parseString(text); +}); diff --git a/packages/backend/src/server/api/endpoints/following/create.ts b/packages/backend/src/server/api/endpoints/following/create.ts new file mode 100644 index 0000000..48ae6ae --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/create.ts @@ -0,0 +1,109 @@ +import create from "@/services/following/create.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getUser } from "../../common/getters.js"; +import { Followings, Users } from "@/models/index.js"; +import { IdentifiableError } from "@/misc/identifiable-error.js"; +import { HOUR } from "@/const.js"; + +export const meta = { + tags: ["following", "users"], + + limit: { + duration: HOUR, + max: 100, + }, + + requireCredential: true, + + kind: "write:following", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "fcd2eef9-a9b2-4c4f-8624-038099e90aa5", + }, + + followeeIsYourself: { + message: "Followee is yourself.", + code: "FOLLOWEE_IS_YOURSELF", + id: "26fbe7bb-a331-4857-af17-205b426669a9", + }, + + alreadyFollowing: { + message: "You are already following that user.", + code: "ALREADY_FOLLOWING", + id: "35387507-38c7-4cb9-9197-300b93783fa0", + }, + + blocking: { + message: "You are blocking that user.", + code: "BLOCKING", + id: "4e2206ec-aa4f-4960-b865-6c23ac38e2d9", + }, + + blocked: { + message: "You are blocked by that user.", + code: "BLOCKED", + id: "c4ab57cc-4e41-45e9-bfd9-584f61e35ce0", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "UserLite", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const follower = user; + + // 自分自身 + if (user.id === ps.userId) { + throw new ApiError(meta.errors.followeeIsYourself); + } + + // Get followee + const followee = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check if already following + const exist = await Followings.exist({ + where: { + followerId: follower.id, + followeeId: followee.id, + }, + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyFollowing); + } + + try { + await create(follower, followee); + } catch (e) { + if (e instanceof IdentifiableError) { + if (e.id === "710e8fb0-b8c3-4922-be49-d5d93d8e6a6e") + throw new ApiError(meta.errors.blocking); + if (e.id === "3338392a-f764-498d-8855-db939dcf8c48") + throw new ApiError(meta.errors.blocked); + } + throw e; + } + + return await Users.pack(followee.id, user); +}); diff --git a/packages/backend/src/server/api/endpoints/following/delete.ts b/packages/backend/src/server/api/endpoints/following/delete.ts new file mode 100644 index 0000000..cbc6097 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/delete.ts @@ -0,0 +1,86 @@ +import deleteFollowing from "@/services/following/delete.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getUser } from "../../common/getters.js"; +import { Followings, Users } from "@/models/index.js"; +import { HOUR } from "@/const.js"; + +export const meta = { + tags: ["following", "users"], + + limit: { + duration: HOUR, + max: 100, + }, + + requireCredential: true, + + kind: "write:following", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "5b12c78d-2b28-4dca-99d2-f56139b42ff8", + }, + + followeeIsYourself: { + message: "Followee is yourself.", + code: "FOLLOWEE_IS_YOURSELF", + id: "d9e400b9-36b0-4808-b1d8-79e707f1296c", + }, + + notFollowing: { + message: "You are not following that user.", + code: "NOT_FOLLOWING", + id: "5dbf82f5-c92b-40b1-87d1-6c8c0741fd09", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "UserLite", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const follower = user; + + // Check if the followee is yourself + if (user.id === ps.userId) { + throw new ApiError(meta.errors.followeeIsYourself); + } + + // Get followee + const followee = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check not following + const exist = await Followings.exist({ + where: { + followerId: follower.id, + followeeId: followee.id, + }, + }); + + if (!exist) { + throw new ApiError(meta.errors.notFollowing); + } + + await deleteFollowing(follower, followee); + + return await Users.pack(followee.id, user); +}); diff --git a/packages/backend/src/server/api/endpoints/following/invalidate.ts b/packages/backend/src/server/api/endpoints/following/invalidate.ts new file mode 100644 index 0000000..01ccc27 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/invalidate.ts @@ -0,0 +1,86 @@ +import deleteFollowing from "@/services/following/delete.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getUser } from "../../common/getters.js"; +import { Followings, Users } from "@/models/index.js"; +import { HOUR } from "@/const.js"; + +export const meta = { + tags: ["following", "users"], + + limit: { + duration: HOUR, + max: 100, + }, + + requireCredential: true, + + kind: "write:following", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "5b12c78d-2b28-4dca-99d2-f56139b42ff8", + }, + + followerIsYourself: { + message: "Follower is yourself.", + code: "FOLLOWER_IS_YOURSELF", + id: "07dc03b9-03da-422d-885b-438313707662", + }, + + notFollowing: { + message: "The other use is not following you.", + code: "NOT_FOLLOWING", + id: "5dbf82f5-c92b-40b1-87d1-6c8c0741fd09", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "UserLite", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const followee = user; + + // Check if the follower is yourself + if (user.id === ps.userId) { + throw new ApiError(meta.errors.followerIsYourself); + } + + // Get follower + const follower = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check not following + const exist = await Followings.exist({ + where: { + followerId: follower.id, + followeeId: followee.id, + }, + }); + + if (!exist) { + throw new ApiError(meta.errors.notFollowing); + } + + await deleteFollowing(follower, followee); + + return await Users.pack(followee.id, user); +}); diff --git a/packages/backend/src/server/api/endpoints/following/requests/accept.ts b/packages/backend/src/server/api/endpoints/following/requests/accept.ts new file mode 100644 index 0000000..a4fc052 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/requests/accept.ts @@ -0,0 +1,50 @@ +import acceptFollowRequest from "@/services/following/requests/accept.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { getUser } from "../../../common/getters.js"; + +export const meta = { + tags: ["following", "account"], + + requireCredential: true, + + kind: "write:following", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "66ce1645-d66c-46bb-8b79-96739af885bd", + }, + noFollowRequest: { + message: "No follow request.", + code: "NO_FOLLOW_REQUEST", + id: "bcde4f8b-0913-4614-8881-614e522fb041", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Fetch follower + const follower = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + await acceptFollowRequest(user, follower).catch((e) => { + if (e.id === "8884c2dd-5795-4ac9-b27e-6a01d38190f9") + throw new ApiError(meta.errors.noFollowRequest); + throw e; + }); + + return; +}); diff --git a/packages/backend/src/server/api/endpoints/following/requests/cancel.ts b/packages/backend/src/server/api/endpoints/following/requests/cancel.ts new file mode 100644 index 0000000..f309e32 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/requests/cancel.ts @@ -0,0 +1,64 @@ +import cancelFollowRequest from "@/services/following/requests/cancel.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { getUser } from "../../../common/getters.js"; +import { Users } from "@/models/index.js"; +import { IdentifiableError } from "@/misc/identifiable-error.js"; + +export const meta = { + tags: ["following", "account"], + + requireCredential: true, + + kind: "write:following", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "4e68c551-fc4c-4e46-bb41-7d4a37bf9dab", + }, + + followRequestNotFound: { + message: "Follow request not found.", + code: "FOLLOW_REQUEST_NOT_FOUND", + id: "089b125b-d338-482a-9a09-e2622ac9f8d4", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "UserLite", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Fetch followee + const followee = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + try { + await cancelFollowRequest(followee, user); + } catch (e) { + if (e instanceof IdentifiableError) { + if (e.id === "17447091-ce07-46dd-b331-c1fd4f15b1e7") + throw new ApiError(meta.errors.followRequestNotFound); + } + throw e; + } + + return await Users.pack(followee.id, user); +}); diff --git a/packages/backend/src/server/api/endpoints/following/requests/list.ts b/packages/backend/src/server/api/endpoints/following/requests/list.ts new file mode 100644 index 0000000..6ba23de --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/requests/list.ts @@ -0,0 +1,55 @@ +import define from "../../../define.js"; +import { FollowRequests } from "@/models/index.js"; + +export const meta = { + tags: ["following", "account"], + + requireCredential: true, + + kind: "read:following", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + follower: { + type: "object", + optional: false, + nullable: false, + ref: "UserLite", + }, + followee: { + type: "object", + optional: false, + nullable: false, + ref: "UserLite", + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const reqs = await FollowRequests.findBy({ + followeeId: user.id, + }); + + return await Promise.all(reqs.map((req) => FollowRequests.pack(req))); +}); diff --git a/packages/backend/src/server/api/endpoints/following/requests/reject.ts b/packages/backend/src/server/api/endpoints/following/requests/reject.ts new file mode 100644 index 0000000..fedc0db --- /dev/null +++ b/packages/backend/src/server/api/endpoints/following/requests/reject.ts @@ -0,0 +1,41 @@ +import { rejectFollowRequest } from "@/services/following/reject.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { getUser } from "../../../common/getters.js"; + +export const meta = { + tags: ["following", "account"], + + requireCredential: true, + + kind: "write:following", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "abc2ffa6-25b2-4380-ba99-321ff3a94555", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Fetch follower + const follower = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + await rejectFollowRequest(user, follower); + + return; +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/featured.ts b/packages/backend/src/server/api/endpoints/gallery/featured.ts new file mode 100644 index 0000000..d478e8e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/featured.ts @@ -0,0 +1,40 @@ +import define from "../../define.js"; +import { GalleryPosts } from "@/models/index.js"; + +export const meta = { + tags: ["gallery"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "GalleryPost", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = GalleryPosts.createQueryBuilder("post") + .andWhere("post.createdAt > :date", { + date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3), + }) + .andWhere("post.likedCount > 0") + .orderBy("post.likedCount", "DESC"); + + const posts = await query.take(10).getMany(); + + return await GalleryPosts.packMany(posts, me); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/popular.ts b/packages/backend/src/server/api/endpoints/gallery/popular.ts new file mode 100644 index 0000000..5eef68d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/popular.ts @@ -0,0 +1,37 @@ +import define from "../../define.js"; +import { GalleryPosts } from "@/models/index.js"; + +export const meta = { + tags: ["gallery"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "GalleryPost", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = GalleryPosts.createQueryBuilder("post") + .andWhere("post.likedCount > 0") + .orderBy("post.likedCount", "DESC"); + + const posts = await query.take(10).getMany(); + + return await GalleryPosts.packMany(posts, me); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts.ts b/packages/backend/src/server/api/endpoints/gallery/posts.ts new file mode 100644 index 0000000..f97c161 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts.ts @@ -0,0 +1,42 @@ +import define from "../../define.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { GalleryPosts } from "@/models/index.js"; + +export const meta = { + tags: ["gallery"], + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "GalleryPost", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery( + GalleryPosts.createQueryBuilder("post"), + ps.sinceId, + ps.untilId, + ).innerJoinAndSelect("post.user", "user"); + + const posts = await query.take(ps.limit).getMany(); + + return await GalleryPosts.packMany(posts, me); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/create.ts b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts new file mode 100644 index 0000000..f3b3768 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/create.ts @@ -0,0 +1,81 @@ +import define from "../../../define.js"; +import { DriveFiles, GalleryPosts } from "@/models/index.js"; +import { genId } from "../../../../../misc/gen-id.js"; +import { GalleryPost } from "@/models/entities/gallery-post.js"; +import { ApiError } from "../../../error.js"; +import type { DriveFile } from "@/models/entities/drive-file.js"; +import { HOUR } from "@/const.js"; + +export const meta = { + tags: ["gallery"], + + requireCredential: true, + + kind: "write:gallery", + + limit: { + duration: HOUR, + max: 300, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "GalleryPost", + }, + + errors: {}, +} as const; + +export const paramDef = { + type: "object", + properties: { + title: { type: "string", minLength: 1 }, + description: { type: "string", nullable: true }, + fileIds: { + type: "array", + uniqueItems: true, + minItems: 1, + maxItems: 32, + items: { + type: "string", + format: "misskey:id", + }, + }, + isSensitive: { type: "boolean", default: false }, + }, + required: ["title", "fileIds"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const files = ( + await Promise.all( + ps.fileIds.map((fileId) => + DriveFiles.findOneBy({ + id: fileId, + userId: user.id, + }), + ), + ) + ).filter((file): file is DriveFile => file != null); + + if (files.length === 0) { + throw new Error(); + } + + const post = await GalleryPosts.insert( + new GalleryPost({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + title: ps.title, + description: ps.description, + userId: user.id, + isSensitive: ps.isSensitive, + fileIds: files.map((file) => file.id), + }), + ).then((x) => GalleryPosts.findOneByOrFail(x.identifiers[0])); + + return await GalleryPosts.pack(post, user); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts new file mode 100644 index 0000000..9fd9a50 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts @@ -0,0 +1,40 @@ +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { GalleryPosts } from "@/models/index.js"; + +export const meta = { + tags: ["gallery"], + + requireCredential: true, + + kind: "write:gallery", + + errors: { + noSuchPost: { + message: "No such post.", + code: "NO_SUCH_POST", + id: "ae52f367-4bd7-4ecd-afc6-5672fff427f5", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + postId: { type: "string", format: "misskey:id" }, + }, + required: ["postId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const post = await GalleryPosts.findOneBy({ + id: ps.postId, + userId: user.id, + }); + + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + await GalleryPosts.delete(post.id); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/like.ts b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts new file mode 100644 index 0000000..2506e40 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/like.ts @@ -0,0 +1,63 @@ +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { GalleryPosts, GalleryLikes } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; + +export const meta = { + tags: ["gallery"], + + requireCredential: true, + + kind: "write:gallery-likes", + + errors: { + noSuchPost: { + message: "No such post.", + code: "NO_SUCH_POST", + id: "56c06af3-1287-442f-9701-c93f7c4a62ff", + }, + + alreadyLiked: { + message: "The post has already been liked.", + code: "ALREADY_LIKED", + id: "40e9ed56-a59c-473a-bf3f-f289c54fb5a7", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + postId: { type: "string", format: "misskey:id" }, + }, + required: ["postId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const post = await GalleryPosts.findOneBy({ id: ps.postId }); + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + // if already liked + const exist = await GalleryLikes.exist({ + where: { + postId: post.id, + userId: user.id, + }, + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyLiked); + } + + // Create like + await GalleryLikes.insert({ + id: genId(), + createdAt: new Date(), + postId: post.id, + userId: user.id, + }); + + GalleryPosts.increment({ id: post.id }, "likedCount", 1); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/show.ts b/packages/backend/src/server/api/endpoints/gallery/posts/show.ts new file mode 100644 index 0000000..87e272f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/show.ts @@ -0,0 +1,45 @@ +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { GalleryPosts } from "@/models/index.js"; + +export const meta = { + tags: ["gallery"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + errors: { + noSuchPost: { + message: "No such post.", + code: "NO_SUCH_POST", + id: "1137bf14-c5b0-4604-85bb-5b5371b1cd45", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "GalleryPost", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + postId: { type: "string", format: "misskey:id" }, + }, + required: ["postId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const post = await GalleryPosts.findOneBy({ + id: ps.postId, + }); + + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + return await GalleryPosts.pack(post, me); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts new file mode 100644 index 0000000..03bc299 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/unlike.ts @@ -0,0 +1,54 @@ +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { GalleryPosts, GalleryLikes } from "@/models/index.js"; + +export const meta = { + tags: ["gallery"], + + requireCredential: true, + + kind: "write:gallery-likes", + + errors: { + noSuchPost: { + message: "No such post.", + code: "NO_SUCH_POST", + id: "c32e6dd0-b555-4413-925e-b3757d19ed84", + }, + + notLiked: { + message: "You have not liked that post.", + code: "NOT_LIKED", + id: "e3e8e06e-be37-41f7-a5b4-87a8250288f0", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + postId: { type: "string", format: "misskey:id" }, + }, + required: ["postId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const post = await GalleryPosts.findOneBy({ id: ps.postId }); + if (post == null) { + throw new ApiError(meta.errors.noSuchPost); + } + + const like = await GalleryLikes.findOneBy({ + postId: post.id, + userId: user.id, + }); + + if (like == null) { + throw new ApiError(meta.errors.notLiked); + } + + // Delete like + await GalleryLikes.delete(like.id); + + GalleryPosts.decrement({ id: post.id }, "likedCount", 1); +}); diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/update.ts b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts new file mode 100644 index 0000000..64e2041 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/gallery/posts/update.ts @@ -0,0 +1,84 @@ +import define from "../../../define.js"; +import { DriveFiles, GalleryPosts } from "@/models/index.js"; +import { GalleryPost } from "@/models/entities/gallery-post.js"; +import { ApiError } from "../../../error.js"; +import type { DriveFile } from "@/models/entities/drive-file.js"; +import { HOUR } from "@/const.js"; + +export const meta = { + tags: ["gallery"], + + requireCredential: true, + + kind: "write:gallery", + + limit: { + duration: HOUR, + max: 300, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "GalleryPost", + }, + + errors: {}, +} as const; + +export const paramDef = { + type: "object", + properties: { + postId: { type: "string", format: "misskey:id" }, + title: { type: "string", minLength: 1 }, + description: { type: "string", nullable: true }, + fileIds: { + type: "array", + uniqueItems: true, + minItems: 1, + maxItems: 32, + items: { + type: "string", + format: "misskey:id", + }, + }, + isSensitive: { type: "boolean", default: false }, + }, + required: ["postId", "title", "fileIds"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const files = ( + await Promise.all( + ps.fileIds.map((fileId) => + DriveFiles.findOneBy({ + id: fileId, + userId: user.id, + }), + ), + ) + ).filter((file): file is DriveFile => file != null); + + if (files.length === 0) { + throw new Error(); + } + + await GalleryPosts.update( + { + id: ps.postId, + userId: user.id, + }, + { + updatedAt: new Date(), + title: ps.title, + description: ps.description, + isSensitive: ps.isSensitive, + fileIds: files.map((file) => file.id), + }, + ); + + const post = await GalleryPosts.findOneByOrFail({ id: ps.postId }); + + return await GalleryPosts.pack(post, user); +}); diff --git a/packages/backend/src/server/api/endpoints/get-online-users-count.ts b/packages/backend/src/server/api/endpoints/get-online-users-count.ts new file mode 100644 index 0000000..805674a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/get-online-users-count.ts @@ -0,0 +1,27 @@ +import { MoreThan } from "typeorm"; +import { USER_ONLINE_THRESHOLD } from "@/const.js"; +import { Users } from "@/models/index.js"; +import define from "../define.js"; + +export const meta = { + tags: ["meta"], + + requireCredential: false, + requireCredentialPrivateMode: true, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async () => { + const count = await Users.countBy({ + lastActiveDate: MoreThan(new Date(Date.now() - USER_ONLINE_THRESHOLD)), + }); + + return { + count, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/get-sounds.ts b/packages/backend/src/server/api/endpoints/get-sounds.ts new file mode 100644 index 0000000..f7edd38 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/get-sounds.ts @@ -0,0 +1,30 @@ +import { readdir } from "fs/promises"; +import define from "../define.js"; + +export const meta = { + tags: ["meta"], + requireCredential: false, + requireCredentialPrivateMode: false, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async () => { + const music_files: (string | null)[] = [null]; + const directory = ( + await readdir("./assets/sounds", { withFileTypes: true }) + ).filter((potentialFolder) => potentialFolder.isDirectory()); + for await (const folder of directory) { + const files = (await readdir(`./assets/sounds/${folder.name}`)).filter( + (potentialSong) => potentialSong.endsWith(".mp3"), + ); + for await (const file of files) { + music_files.push(`${folder.name}/${file.replace(".mp3", "")}`); + } + } + return music_files; +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/list.ts b/packages/backend/src/server/api/endpoints/hashtags/list.ts new file mode 100644 index 0000000..df99a1e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/hashtags/list.ts @@ -0,0 +1,112 @@ +import define from "../../define.js"; +import { Hashtags } from "@/models/index.js"; + +export const meta = { + tags: ["hashtags"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Hashtag", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + attachedToUserOnly: { type: "boolean", default: false }, + attachedToLocalUserOnly: { type: "boolean", default: false }, + attachedToRemoteUserOnly: { type: "boolean", default: false }, + sort: { + type: "string", + enum: [ + "+mentionedUsers", + "-mentionedUsers", + "+mentionedLocalUsers", + "-mentionedLocalUsers", + "+mentionedRemoteUsers", + "-mentionedRemoteUsers", + "+attachedUsers", + "-attachedUsers", + "+attachedLocalUsers", + "-attachedLocalUsers", + "+attachedRemoteUsers", + "-attachedRemoteUsers", + ], + }, + }, + required: ["sort"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = Hashtags.createQueryBuilder("tag"); + + if (ps.attachedToUserOnly) query.andWhere("tag.attachedUsersCount != 0"); + if (ps.attachedToLocalUserOnly) + query.andWhere("tag.attachedLocalUsersCount != 0"); + if (ps.attachedToRemoteUserOnly) + query.andWhere("tag.attachedRemoteUsersCount != 0"); + + switch (ps.sort) { + case "+mentionedUsers": + query.orderBy("tag.mentionedUsersCount", "DESC"); + break; + case "-mentionedUsers": + query.orderBy("tag.mentionedUsersCount", "ASC"); + break; + case "+mentionedLocalUsers": + query.orderBy("tag.mentionedLocalUsersCount", "DESC"); + break; + case "-mentionedLocalUsers": + query.orderBy("tag.mentionedLocalUsersCount", "ASC"); + break; + case "+mentionedRemoteUsers": + query.orderBy("tag.mentionedRemoteUsersCount", "DESC"); + break; + case "-mentionedRemoteUsers": + query.orderBy("tag.mentionedRemoteUsersCount", "ASC"); + break; + case "+attachedUsers": + query.orderBy("tag.attachedUsersCount", "DESC"); + break; + case "-attachedUsers": + query.orderBy("tag.attachedUsersCount", "ASC"); + break; + case "+attachedLocalUsers": + query.orderBy("tag.attachedLocalUsersCount", "DESC"); + break; + case "-attachedLocalUsers": + query.orderBy("tag.attachedLocalUsersCount", "ASC"); + break; + case "+attachedRemoteUsers": + query.orderBy("tag.attachedRemoteUsersCount", "DESC"); + break; + case "-attachedRemoteUsers": + query.orderBy("tag.attachedRemoteUsersCount", "ASC"); + break; + } + + query.select([ + "tag.name", + "tag.mentionedUsersCount", + "tag.mentionedLocalUsersCount", + "tag.mentionedRemoteUsersCount", + "tag.attachedUsersCount", + "tag.attachedLocalUsersCount", + "tag.attachedRemoteUsersCount", + ]); + + const tags = await query.take(ps.limit).getMany(); + + return Hashtags.packMany(tags); +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/search.ts b/packages/backend/src/server/api/endpoints/hashtags/search.ts new file mode 100644 index 0000000..cde586a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/hashtags/search.ts @@ -0,0 +1,45 @@ +import define from "../../define.js"; +import { Hashtags } from "@/models/index.js"; +import { sqlLikeEscape } from "@/misc/sql-like-escape.js"; + +export const meta = { + tags: ["hashtags"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + query: { type: "string" }, + offset: { type: "integer", default: 0 }, + }, + required: ["query"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const hashtags = await Hashtags.createQueryBuilder("tag") + .where("tag.name like :q", { + q: `${sqlLikeEscape(ps.query.toLowerCase())}%`, + }) + .orderBy("tag.count", "DESC") + .groupBy("tag.id") + .take(ps.limit) + .skip(ps.offset) + .getMany(); + + return hashtags.map((tag) => tag.name); +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/show.ts b/packages/backend/src/server/api/endpoints/hashtags/show.ts new file mode 100644 index 0000000..8cf90e4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/hashtags/show.ts @@ -0,0 +1,45 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { Hashtags } from "@/models/index.js"; +import { normalizeForSearch } from "@/misc/normalize-for-search.js"; + +export const meta = { + tags: ["hashtags"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Hashtag", + }, + + errors: { + noSuchHashtag: { + message: "No such hashtag.", + code: "NO_SUCH_HASHTAG", + id: "110ee688-193e-4a3a-9ecf-c167b2e6981e", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + tag: { type: "string" }, + }, + required: ["tag"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const hashtag = await Hashtags.findOneBy({ + name: normalizeForSearch(ps.tag), + }); + if (hashtag == null) { + throw new ApiError(meta.errors.noSuchHashtag); + } + + return await Hashtags.pack(hashtag); +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/trend.ts b/packages/backend/src/server/api/endpoints/hashtags/trend.ts new file mode 100644 index 0000000..e2a8345 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/hashtags/trend.ts @@ -0,0 +1,178 @@ +import { Brackets } from "typeorm"; +import define from "../../define.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { Notes } from "@/models/index.js"; +import type { Note } from "@/models/entities/note.js"; +import { safeForSql } from "@/misc/safe-for-sql.js"; +import { normalizeForSearch } from "@/misc/normalize-for-search.js"; + +/* +トレンドに載るためには「『直近a分間のユニーク投稿数が今からa分前~今からb分前の間のユニーク投稿数のn倍以上』のハッシュタグの上位5位以内に入る」ことが必要 +ユニーク投稿数とはそのハッシュタグと投稿ユーザーのペアのカウントで、例えば同じユーザーが複数回同じハッシュタグを投稿してもそのハッシュタグのユニーク投稿数は1とカウントされる + +..が理想だけどPostgreSQLでどうするのか分からないので単に「直近Aの内に投稿されたユニーク投稿数が多いハッシュタグ」で妥協する +*/ + +const rangeA = 1000 * 60 * 60; // 60分 +//const rangeB = 1000 * 60 * 120; // 2時間 +//const coefficient = 1.25; // 「n倍」の部分 +//const requiredUsers = 3; // 最低何人がそのタグを投稿している必要があるか + +const max = 5; + +export const meta = { + tags: ["hashtags"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + tag: { + type: "string", + optional: false, + nullable: false, + }, + chart: { + type: "array", + optional: false, + nullable: false, + items: { + type: "number", + optional: false, + nullable: false, + }, + }, + usersCount: { + type: "number", + optional: false, + nullable: false, + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async () => { + const instance = await fetchMeta(true); + const hiddenTags = instance.hiddenTags.map((t) => normalizeForSearch(t)); + + const now = new Date(); // 5分単位で丸めた現在日時 + now.setMinutes(Math.round(now.getMinutes() / 5) * 5, 0, 0); + + const tagNotes = await Notes.createQueryBuilder("note") + .where("note.createdAt > :date", { date: new Date(now.getTime() - rangeA) }) + .andWhere( + new Brackets((qb) => { + qb.where(`note.visibility = 'public'`).orWhere( + `note.visibility = 'home'`, + ); + }), + ) + .andWhere(`note.tags != '{}'`) + .select(["note.tags", "note.userId"]) + .cache(60000) // 1 min + .getMany(); + + if (tagNotes.length === 0) { + return []; + } + + const tags: { + name: string; + users: Note["userId"][]; + }[] = []; + + for (const note of tagNotes) { + for (const tag of note.tags) { + if (hiddenTags.includes(tag)) continue; + + const x = tags.find((x) => x.name === tag); + if (x) { + if (!x.users.includes(note.userId)) { + x.users.push(note.userId); + } + } else { + tags.push({ + name: tag, + users: [note.userId], + }); + } + } + } + + // タグを人気順に並べ替え + const hots = tags + .sort((a, b) => b.users.length - a.users.length) + .map((tag) => tag.name) + .slice(0, max); + + //#region 2(または3)で話題と判定されたタグそれぞれについて過去の投稿数グラフを取得する + const countPromises: Promise[] = []; + + const range = 20; + + // 10分 + const interval = 1000 * 60 * 10; + + for (let i = 0; i < range; i++) { + countPromises.push( + Promise.all( + hots.map((tag) => + Notes.createQueryBuilder("note") + .select("count(distinct note.userId)") + .where( + `'{"${safeForSql(tag) ? tag : "aichan_kawaii"}"}' <@ note.tags`, + ) + .andWhere("note.createdAt < :lt", { + lt: new Date(now.getTime() - interval * i), + }) + .andWhere("note.createdAt > :gt", { + gt: new Date(now.getTime() - interval * (i + 1)), + }) + .cache(60000) // 1 min + .getRawOne() + .then((x) => parseInt(x.count, 10)), + ), + ), + ); + } + + const countsLog = await Promise.all(countPromises); + //#endregion + + const totalCounts = await Promise.all( + hots.map((tag) => + Notes.createQueryBuilder("note") + .select("count(distinct note.userId)") + .where(`'{"${safeForSql(tag) ? tag : "aichan_kawaii"}"}' <@ note.tags`) + .andWhere("note.createdAt > :gt", { + gt: new Date(now.getTime() - rangeA), + }) + .cache(60000 * 60) // 60 min + .getRawOne() + .then((x) => parseInt(x.count, 10)), + ), + ); + + const stats = hots.map((tag, i) => ({ + tag, + chart: countsLog.map((counts) => counts[i]), + usersCount: totalCounts[i], + })); + + return stats; +}); diff --git a/packages/backend/src/server/api/endpoints/hashtags/users.ts b/packages/backend/src/server/api/endpoints/hashtags/users.ts new file mode 100644 index 0000000..532c663 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/hashtags/users.ts @@ -0,0 +1,92 @@ +import define from "../../define.js"; +import { Users } from "@/models/index.js"; +import { normalizeForSearch } from "@/misc/normalize-for-search.js"; + +export const meta = { + requireCredential: false, + requireCredentialPrivateMode: true, + + tags: ["hashtags", "users"], + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "UserDetailed", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + tag: { type: "string" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sort: { + type: "string", + enum: [ + "+follower", + "-follower", + "+createdAt", + "-createdAt", + "+updatedAt", + "-updatedAt", + ], + }, + state: { type: "string", enum: ["all", "alive"], default: "all" }, + origin: { + type: "string", + enum: ["combined", "local", "remote"], + default: "local", + }, + }, + required: ["tag", "sort"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = Users.createQueryBuilder("user").where( + ":tag = ANY(user.tags)", + { tag: normalizeForSearch(ps.tag) }, + ); + + const recent = new Date(Date.now() - 1000 * 60 * 60 * 24 * 5); + + if (ps.state === "alive") { + query.andWhere("user.updatedAt > :date", { date: recent }); + } + + if (ps.origin === "local") { + query.andWhere("user.host IS NULL"); + } else if (ps.origin === "remote") { + query.andWhere("user.host IS NOT NULL"); + } + + switch (ps.sort) { + case "+follower": + query.orderBy("user.followersCount", "DESC"); + break; + case "-follower": + query.orderBy("user.followersCount", "ASC"); + break; + case "+createdAt": + query.orderBy("user.createdAt", "DESC"); + break; + case "-createdAt": + query.orderBy("user.createdAt", "ASC"); + break; + case "+updatedAt": + query.orderBy("user.updatedAt", "DESC"); + break; + case "-updatedAt": + query.orderBy("user.updatedAt", "ASC"); + break; + } + + const users = await query.take(ps.limit).getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/i.ts b/packages/backend/src/server/api/endpoints/i.ts new file mode 100644 index 0000000..3954344 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i.ts @@ -0,0 +1,31 @@ +import { Users } from "@/models/index.js"; +import define from "../define.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "MeDetailed", + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user, token) => { + const isSecure = token == null; + + // ここで渡ってきている user はキャッシュされていて古い可能性もあるので id だけ渡す + return await Users.pack(user.id, user, { + detail: true, + includeSecrets: isSecure, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/done.ts b/packages/backend/src/server/api/endpoints/i/2fa/done.ts new file mode 100644 index 0000000..05d57d2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/done.ts @@ -0,0 +1,51 @@ +import { publishMainStream } from "@/services/stream.js"; +import * as OTPAuth from "otpauth"; +import define from "../../../define.js"; +import { Users, UserProfiles } from "@/models/index.js"; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + token: { type: "string" }, + }, + required: ["token"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const token = ps.token.replace(/\s/g, ""); + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + if (profile.twoFactorTempSecret == null) { + throw new Error("二段階認証の設定が開始されていません"); + } + + const delta = OTPAuth.TOTP.validate({ + secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret), + digits: 6, + token, + window: 1, + }); + + if (delta === null) { + throw new Error("not verified"); + } + + await UserProfiles.update(user.id, { + twoFactorSecret: profile.twoFactorTempSecret, + twoFactorEnabled: true, + }); + + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: true, + }); + + publishMainStream(user.id, "meUpdated", iObj); +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts new file mode 100644 index 0000000..5e8b7e2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/key-done.ts @@ -0,0 +1,149 @@ +import * as cbor from "cbor"; +import define from "../../../define.js"; +import { + UserProfiles, + UserSecurityKeys, + AttestationChallenges, + Users, +} from "@/models/index.js"; +import config from "@/config/index.js"; +import { procedures, hash } from "../../../2fa.js"; +import { publishMainStream } from "@/services/stream.js"; +import { comparePassword } from "@/misc/password.js"; + +const rpIdHashReal = hash(Buffer.from(config.hostname, "utf-8")); + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + clientDataJSON: { type: "string" }, + attestationObject: { type: "string" }, + password: { type: "string" }, + challengeId: { type: "string" }, + name: { type: "string", minLength: 1, maxLength: 30 }, + }, + required: [ + "clientDataJSON", + "attestationObject", + "password", + "challengeId", + "name", + ], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + // Compare password + const same = await comparePassword(ps.password, profile.password!); + + if (!same) { + throw new Error("incorrect password"); + } + + if (!profile.twoFactorEnabled) { + throw new Error("2fa not enabled"); + } + + const clientData = JSON.parse(ps.clientDataJSON); + + if (clientData.type !== "webauthn.create") { + throw new Error("not a creation attestation"); + } + if (clientData.origin !== `${config.scheme}://${config.host}`) { + throw new Error("origin mismatch"); + } + + const clientDataJSONHash = hash(Buffer.from(ps.clientDataJSON, "utf-8")); + + const attestation = await cbor.decodeFirst(ps.attestationObject); + + const rpIdHash = attestation.authData.slice(0, 32); + if (!rpIdHashReal.equals(rpIdHash)) { + throw new Error("rpIdHash mismatch"); + } + + const flags = attestation.authData[32]; + + if (!(flags & 1)) { + throw new Error("user not present"); + } + + const authData = Buffer.from(attestation.authData); + const credentialIdLength = authData.readUInt16BE(53); + const credentialId = authData.slice(55, 55 + credentialIdLength); + const publicKeyData = authData.slice(55 + credentialIdLength); + const publicKey: Map = await cbor.decodeFirst(publicKeyData); + if (publicKey.get(3) !== -7) { + throw new Error("alg mismatch"); + } + + if (!(procedures as any)[attestation.fmt]) { + throw new Error("unsupported fmt"); + } + + const verificationData = (procedures as any)[attestation.fmt].verify({ + attStmt: attestation.attStmt, + authenticatorData: authData, + clientDataHash: clientDataJSONHash, + credentialId, + publicKey, + rpIdHash, + }); + if (!verificationData.valid) throw new Error("signature invalid"); + + const attestationChallenge = await AttestationChallenges.findOneBy({ + userId: user.id, + id: ps.challengeId, + registrationChallenge: true, + challenge: hash(clientData.challenge).toString("hex"), + }); + + if (!attestationChallenge) { + throw new Error("non-existent challenge"); + } + + await AttestationChallenges.delete({ + userId: user.id, + id: ps.challengeId, + }); + + // Expired challenge (> 5min old) + if ( + new Date().getTime() - attestationChallenge.createdAt.getTime() >= + 5 * 60 * 1000 + ) { + throw new Error("expired challenge"); + } + + const credentialIdString = credentialId.toString("hex"); + + await UserSecurityKeys.insert({ + userId: user.id, + id: credentialIdString, + lastUsed: new Date(), + name: ps.name, + publicKey: verificationData.publicKey.toString("hex"), + }); + + // Publish meUpdated event + publishMainStream( + user.id, + "meUpdated", + await Users.pack(user.id, user, { + detail: true, + includeSecrets: true, + }), + ); + + return { + id: credentialIdString, + name: ps.name, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts new file mode 100644 index 0000000..b9f3426 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/password-less.ts @@ -0,0 +1,61 @@ +import define from "../../../define.js"; +import { Users, UserProfiles, UserSecurityKeys } from "@/models/index.js"; +import { publishMainStream } from "@/services/stream.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + requireCredential: true, + + secure: true, + + errors: { + noKey: { + message: "No security key.", + code: "NO_SECURITY_KEY", + id: "f9c54d7f-d4c2-4d3c-9a8g-a70daac86512", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + value: { type: "boolean" }, + }, + required: ["value"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + if (ps.value === true) { + // セキュリティキーがなければパスワードレスを有効にはできない + const keyCount = await UserSecurityKeys.count({ + where: { + userId: user.id, + }, + select: { + id: true, + name: true, + lastUsed: true, + }, + }); + + if (keyCount === 0) { + await UserProfiles.update(user.id, { + usePasswordLessLogin: false, + }); + + throw new ApiError(meta.errors.noKey); + } + } + + await UserProfiles.update(user.id, { + usePasswordLessLogin: ps.value, + }); + + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: true, + }); + + publishMainStream(user.id, "meUpdated", iObj); +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts new file mode 100644 index 0000000..a10dc9b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/register-key.ts @@ -0,0 +1,61 @@ +import define from "../../../define.js"; +import { UserProfiles, AttestationChallenges } from "@/models/index.js"; +import { promisify } from "node:util"; +import * as crypto from "node:crypto"; +import { genId } from "@/misc/gen-id.js"; +import { hash } from "../../../2fa.js"; +import { comparePassword } from "@/misc/password.js"; + +const randomBytes = promisify(crypto.randomBytes); + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + password: { type: "string" }, + }, + required: ["password"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + // Compare password + const same = await comparePassword(ps.password, profile.password!); + + if (!same) { + throw new Error("incorrect password"); + } + + // if (!profile.twoFactorEnabled) { + // throw new Error("2fa not enabled"); + // } + + // 32 byte challenge + const entropy = await randomBytes(32); + const challenge = entropy + .toString("base64") + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + + const challengeId = genId(); + + await AttestationChallenges.insert({ + userId: user.id, + id: challengeId, + challenge: hash(Buffer.from(challenge, "utf-8")).toString("hex"), + createdAt: new Date(), + registrationChallenge: true, + }); + + return { + challengeId, + challenge, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/register.ts b/packages/backend/src/server/api/endpoints/i/2fa/register.ts new file mode 100644 index 0000000..cf391ca --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/register.ts @@ -0,0 +1,56 @@ +import * as OTPAuth from "otpauth"; +import * as QRCode from "qrcode"; +import config from "@/config/index.js"; +import { UserProfiles } from "@/models/index.js"; +import define from "../../../define.js"; +import { comparePassword } from "@/misc/password.js"; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + password: { type: "string" }, + }, + required: ["password"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + // Compare password + const same = await comparePassword(ps.password, profile.password!); + + if (!same) { + throw new Error("incorrect password"); + } + + // Generate user's secret key + const secret = new OTPAuth.Secret(); + + await UserProfiles.update(user.id, { + twoFactorTempSecret: secret.base32, + }); + + // Get the data URL of the authenticator URL + const totp = new OTPAuth.TOTP({ + secret, + digits: 6, + label: user.username, + issuer: config.host, + }); + const url = totp.toString(); + const qr = await QRCode.toDataURL(url); + + return { + qr, + url, + secret: secret.base32, + label: user.username, + issuer: config.host, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts new file mode 100644 index 0000000..d91c8f2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/remove-key.ts @@ -0,0 +1,66 @@ +import { comparePassword } from "@/misc/password.js"; +import define from "../../../define.js"; +import { UserProfiles, UserSecurityKeys, Users } from "@/models/index.js"; +import { publishMainStream } from "@/services/stream.js"; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + password: { type: "string" }, + credentialId: { type: "string" }, + }, + required: ["password", "credentialId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + // Compare password + const same = await comparePassword(ps.password, profile.password!); + + if (!same) { + throw new Error("incorrect password"); + } + + // Make sure we only delete the user's own creds + await UserSecurityKeys.delete({ + userId: user.id, + id: ps.credentialId, + }); + + // 使われているキーがなくなったらパスワードレスログインをやめる + const keyCount = await UserSecurityKeys.count({ + where: { + userId: user.id, + }, + select: { + id: true, + name: true, + lastUsed: true, + }, + }); + + if (keyCount === 0) { + await UserProfiles.update(me.id, { + usePasswordLessLogin: false, + }); + } + + // Publish meUpdated event + publishMainStream( + user.id, + "meUpdated", + await Users.pack(user.id, user, { + detail: true, + includeSecrets: true, + }), + ); + + return {}; +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts new file mode 100644 index 0000000..54f1422 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/unregister.ts @@ -0,0 +1,42 @@ +import { publishMainStream } from "@/services/stream.js"; +import define from "../../../define.js"; +import { Users, UserProfiles } from "@/models/index.js"; +import { comparePassword } from "@/misc/password.js"; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + password: { type: "string" }, + }, + required: ["password"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + // Compare password + const same = await comparePassword(ps.password, profile.password!); + + if (!same) { + throw new Error("incorrect password"); + } + + await UserProfiles.update(user.id, { + twoFactorSecret: null, + twoFactorEnabled: false, + usePasswordLessLogin: false, + }); + + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: true, + }); + + publishMainStream(user.id, "meUpdated", iObj); +}); diff --git a/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts new file mode 100644 index 0000000..7587ec7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/2fa/update-key.ts @@ -0,0 +1,58 @@ +import { publishMainStream } from "@/services/stream.js"; +import define from "../../../define.js"; +import { Users, UserSecurityKeys } from "@/models/index.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + requireCredential: true, + + secure: true, + + errors: { + noSuchKey: { + message: "No such key.", + code: "NO_SUCH_KEY", + id: "f9c5467f-d492-4d3c-9a8g-a70dacc86512", + }, + + accessDenied: { + message: "You do not have edit privilege of the channel.", + code: "ACCESS_DENIED", + id: "1fb7cb09-d46a-4fff-b8df-057708cce513", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 30 }, + credentialId: { type: "string" }, + }, + required: ["name", "credentialId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const key = await UserSecurityKeys.findOneBy({ + id: ps.credentialId, + }); + + if (key == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + if (key.userId !== user.id) { + throw new ApiError(meta.errors.accessDenied); + } + + await UserSecurityKeys.update(key.id, { + name: ps.name, + }); + + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: true, + }); + + publishMainStream(user.id, "meUpdated", iObj); +}); diff --git a/packages/backend/src/server/api/endpoints/i/apps.ts b/packages/backend/src/server/api/endpoints/i/apps.ts new file mode 100644 index 0000000..b951601 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/apps.ts @@ -0,0 +1,56 @@ +import define from "../../define.js"; +import { AccessTokens } from "@/models/index.js"; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + sort: { + type: "string", + enum: ["+createdAt", "-createdAt", "+lastUsedAt", "-lastUsedAt"], + }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = AccessTokens.createQueryBuilder("token").where( + "token.userId = :userId", + { userId: user.id }, + ); + + switch (ps.sort) { + case "+createdAt": + query.orderBy("token.createdAt", "DESC"); + break; + case "-createdAt": + query.orderBy("token.createdAt", "ASC"); + break; + case "+lastUsedAt": + query.orderBy("token.lastUsedAt", "DESC"); + break; + case "-lastUsedAt": + query.orderBy("token.lastUsedAt", "ASC"); + break; + default: + query.orderBy("token.id", "ASC"); + break; + } + + const tokens = await query.getMany(); + + return await Promise.all( + tokens.map((token) => ({ + id: token.id, + name: token.name, + createdAt: token.createdAt, + lastUsedAt: token.lastUsedAt, + permission: token.permission, + })), + ); +}); diff --git a/packages/backend/src/server/api/endpoints/i/authorized-apps.ts b/packages/backend/src/server/api/endpoints/i/authorized-apps.ts new file mode 100644 index 0000000..f759b23 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/authorized-apps.ts @@ -0,0 +1,40 @@ +import define from "../../define.js"; +import { AccessTokens, Apps } from "@/models/index.js"; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + offset: { type: "integer", default: 0 }, + sort: { type: "string", enum: ["desc", "asc"], default: "desc" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Get tokens + const tokens = await AccessTokens.find({ + where: { + userId: user.id, + }, + take: ps.limit, + skip: ps.offset, + order: { + id: ps.sort === "asc" ? 1 : -1, + }, + }); + + return await Promise.all( + tokens.map((token) => + Apps.pack(token.appId, user, { + detail: true, + }), + ), + ); +}); diff --git a/packages/backend/src/server/api/endpoints/i/change-password.ts b/packages/backend/src/server/api/endpoints/i/change-password.ts new file mode 100644 index 0000000..8bbb3ad --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/change-password.ts @@ -0,0 +1,36 @@ +import define from "../../define.js"; +import { UserProfiles } from "@/models/index.js"; +import { hashPassword, comparePassword } from "@/misc/password.js"; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + currentPassword: { type: "string" }, + newPassword: { type: "string", minLength: 1 }, + }, + required: ["currentPassword", "newPassword"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + // Compare password + const same = await comparePassword(ps.currentPassword, profile.password!); + + if (!same) { + throw new Error("incorrect password"); + } + + // Generate hash of password + const hash = await hashPassword(ps.newPassword); + + await UserProfiles.update(user.id, { + password: hash, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts new file mode 100644 index 0000000..781abe0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -0,0 +1,35 @@ +import { UserProfiles, Users } from "@/models/index.js"; +import { deleteAccount } from "@/services/delete-account.js"; +import define from "../../define.js"; +import { comparePassword } from "@/misc/password.js"; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + password: { type: "string" }, + }, + required: ["password"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + const userDetailed = await Users.findOneByOrFail({ id: user.id }); + if (userDetailed.isDeleted) { + return; + } + + // Compare password + const same = await comparePassword(ps.password, profile.password!); + + if (!same) { + throw new Error("incorrect password"); + } + + await deleteAccount(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-blocking.ts b/packages/backend/src/server/api/endpoints/i/export-blocking.ts new file mode 100644 index 0000000..4517ad5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-blocking.ts @@ -0,0 +1,22 @@ +import define from "../../define.js"; +import { createExportBlockingJob } from "@/queue/index.js"; +import { HOUR } from "@/const.js"; + +export const meta = { + secure: true, + requireCredential: true, + limit: { + duration: HOUR, + max: 1, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + createExportBlockingJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-following.ts b/packages/backend/src/server/api/endpoints/i/export-following.ts new file mode 100644 index 0000000..a228de8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-following.ts @@ -0,0 +1,25 @@ +import define from "../../define.js"; +import { createExportFollowingJob } from "@/queue/index.js"; +import { HOUR } from "@/const.js"; + +export const meta = { + secure: true, + requireCredential: true, + limit: { + duration: HOUR, + max: 1, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + excludeMuting: { type: "boolean", default: false }, + excludeInactive: { type: "boolean", default: false }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + createExportFollowingJob(user, ps.excludeMuting, ps.excludeInactive); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-mute.ts b/packages/backend/src/server/api/endpoints/i/export-mute.ts new file mode 100644 index 0000000..7bddc43 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-mute.ts @@ -0,0 +1,22 @@ +import define from "../../define.js"; +import { createExportMuteJob } from "@/queue/index.js"; +import { HOUR } from "@/const.js"; + +export const meta = { + secure: true, + requireCredential: true, + limit: { + duration: HOUR, + max: 1, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + createExportMuteJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-notes.ts b/packages/backend/src/server/api/endpoints/i/export-notes.ts new file mode 100644 index 0000000..48506ed --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-notes.ts @@ -0,0 +1,22 @@ +import define from "../../define.js"; +import { createExportNotesJob } from "@/queue/index.js"; +import { DAY } from "@/const.js"; + +export const meta = { + secure: true, + requireCredential: true, + limit: { + duration: DAY, + max: 1, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + createExportNotesJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/export-user-lists.ts b/packages/backend/src/server/api/endpoints/i/export-user-lists.ts new file mode 100644 index 0000000..a71b173 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/export-user-lists.ts @@ -0,0 +1,22 @@ +import define from "../../define.js"; +import { createExportUserListsJob } from "@/queue/index.js"; +import { MINUTE } from "@/const.js"; + +export const meta = { + secure: true, + requireCredential: true, + limit: { + duration: MINUTE, + max: 1, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + createExportUserListsJob(user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/favorites.ts b/packages/backend/src/server/api/endpoints/i/favorites.ts new file mode 100644 index 0000000..f0dbd2d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/favorites.ts @@ -0,0 +1,47 @@ +import define from "../../define.js"; +import { NoteFavorites } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["account", "notes", "favorites"], + + requireCredential: true, + + kind: "read:favorites", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "NoteFavorite", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + NoteFavorites.createQueryBuilder("favorite"), + ps.sinceId, + ps.untilId, + ) + .andWhere("favorite.userId = :meId", { meId: user.id }) + .leftJoinAndSelect("favorite.note", "note"); + + const favorites = await query.take(ps.limit).getMany(); + + return await NoteFavorites.packMany(favorites, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/gallery/likes.ts b/packages/backend/src/server/api/endpoints/i/gallery/likes.ts new file mode 100644 index 0000000..d71ee3e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/gallery/likes.ts @@ -0,0 +1,60 @@ +import define from "../../../define.js"; +import { GalleryLikes } from "@/models/index.js"; +import { makePaginationQuery } from "../../../common/make-pagination-query.js"; + +export const meta = { + tags: ["account", "gallery"], + + requireCredential: true, + + kind: "read:gallery-likes", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + post: { + type: "object", + optional: false, + nullable: false, + ref: "GalleryPost", + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + GalleryLikes.createQueryBuilder("like"), + ps.sinceId, + ps.untilId, + ) + .andWhere("like.userId = :meId", { meId: user.id }) + .leftJoinAndSelect("like.post", "post"); + + const likes = await query.take(ps.limit).getMany(); + + return await GalleryLikes.packMany(likes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/gallery/posts.ts b/packages/backend/src/server/api/endpoints/i/gallery/posts.ts new file mode 100644 index 0000000..e471731 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/gallery/posts.ts @@ -0,0 +1,45 @@ +import { GalleryPosts } from "@/models/index.js"; +import define from "../../../define.js"; +import { makePaginationQuery } from "../../../common/make-pagination-query.js"; + +export const meta = { + tags: ["account", "gallery"], + + requireCredential: true, + + kind: "read:gallery", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "GalleryPost", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + GalleryPosts.createQueryBuilder("post"), + ps.sinceId, + ps.untilId, + ).andWhere("post.userId = :meId", { meId: user.id }); + + const posts = await query.take(ps.limit).getMany(); + + return await GalleryPosts.packMany(posts, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-blocking.ts b/packages/backend/src/server/api/endpoints/i/import-blocking.ts new file mode 100644 index 0000000..e4f1da6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-blocking.ts @@ -0,0 +1,60 @@ +import define from "../../define.js"; +import { createImportBlockingJob } from "@/queue/index.js"; +import { ApiError } from "../../error.js"; +import { DriveFiles } from "@/models/index.js"; +import { HOUR } from "@/const.js"; + +export const meta = { + secure: true, + requireCredential: true, + + limit: { + duration: HOUR, + max: 1, + }, + + errors: { + noSuchFile: { + message: "No such file.", + code: "NO_SUCH_FILE", + id: "ebb53e5f-6574-9c0c-0b92-7ca6def56d7e", + }, + + unexpectedFileType: { + message: "We need csv file.", + code: "UNEXPECTED_FILE_TYPE", + id: "b6fab7d6-d945-d67c-dfdb-32da1cd12cfe", + }, + + tooBigFile: { + message: "That file is too big.", + code: "TOO_BIG_FILE", + id: "b7fbf0b1-aeef-3b21-29ef-fadd4cb72ccf", + }, + + emptyFile: { + message: "That file is empty.", + code: "EMPTY_FILE", + id: "6f3a4dcc-f060-a707-4950-806fbdbe60d6", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + fileId: { type: "string", format: "misskey:id" }, + }, + required: ["fileId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const file = await DriveFiles.findOneBy({ id: ps.fileId }); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportBlockingJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-following.ts b/packages/backend/src/server/api/endpoints/i/import-following.ts new file mode 100644 index 0000000..1a6c9b5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-following.ts @@ -0,0 +1,59 @@ +import define from "../../define.js"; +import { createImportFollowingJob } from "@/queue/index.js"; +import { ApiError } from "../../error.js"; +import { DriveFiles } from "@/models/index.js"; +import { HOUR } from "@/const.js"; + +export const meta = { + secure: true, + requireCredential: true, + limit: { + duratition: HOUR, + max: 1, + }, + + errors: { + noSuchFile: { + message: "No such file.", + code: "NO_SUCH_FILE", + id: "b98644cf-a5ac-4277-a502-0b8054a709a3", + }, + + unexpectedFileType: { + message: "Must be a CSV or JSON file.", + code: "UNEXPECTED_FILE_TYPE", + id: "660f3599-bce0-4f95-9dde-311fd841c183", + }, + + tooBigFile: { + message: "That file is too big.", + code: "TOO_BIG_FILE", + id: "dee9d4ed-ad07-43ed-8b34-b2856398bc60", + }, + + emptyFile: { + message: "That file is empty.", + code: "EMPTY_FILE", + id: "31a1b42c-06f7-42ae-8a38-a661c5c9f691", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + fileId: { type: "string", format: "misskey:id" }, + }, + required: ["fileId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const file = await DriveFiles.findOneBy({ id: ps.fileId }); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 2_000_000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportFollowingJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-muting.ts b/packages/backend/src/server/api/endpoints/i/import-muting.ts new file mode 100644 index 0000000..20d240e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-muting.ts @@ -0,0 +1,60 @@ +import define from "../../define.js"; +import { createImportMutingJob } from "@/queue/index.js"; +import { ApiError } from "../../error.js"; +import { DriveFiles } from "@/models/index.js"; +import { HOUR } from "@/const.js"; + +export const meta = { + secure: true, + requireCredential: true, + + limit: { + duration: HOUR, + max: 1, + }, + + errors: { + noSuchFile: { + message: "No such file.", + code: "NO_SUCH_FILE", + id: "e674141e-bd2a-ba85-e616-aefb187c9c2a", + }, + + unexpectedFileType: { + message: "We need csv file.", + code: "UNEXPECTED_FILE_TYPE", + id: "568c6e42-c86c-ba09-c004-517f83f9f1a8", + }, + + tooBigFile: { + message: "That file is too big.", + code: "TOO_BIG_FILE", + id: "9b4ada6d-d7f7-0472-0713-4f558bd1ec9c", + }, + + emptyFile: { + message: "That file is empty.", + code: "EMPTY_FILE", + id: "d2f12af1-e7b4-feac-86a3-519548f2728e", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + fileId: { type: "string", format: "misskey:id" }, + }, + required: ["fileId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const file = await DriveFiles.findOneBy({ id: ps.fileId }); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 50000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportMutingJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-posts.ts b/packages/backend/src/server/api/endpoints/i/import-posts.ts new file mode 100644 index 0000000..a4cfa41 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-posts.ts @@ -0,0 +1,44 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { DAY } from "@/const.js"; + +export const meta = { + secure: true, + requireCredential: true, + limit: { + duration: DAY * 30, + max: 2, + }, + errors: { + noSuchFile: { + message: "No such file.", + code: "NO_SUCH_FILE", + id: "e674141e-bd2a-ba85-e616-aefb187c9c2a", + }, + + emptyFile: { + message: "That file is empty.", + code: "EMPTY_FILE", + id: "d2f12af1-e7b4-feac-86a3-519548f2728e", + }, + + importsDisabled: { + message: "Post imports are disabled for security reasons.", + code: "IMPORTS_DISABLED", + id: " bc9227e4-fb82-11ed-be56-0242ac120002", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + fileId: { type: "string", format: "misskey:id" }, + signatureCheck: { type: "boolean" }, + }, + required: ["fileId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + throw new ApiError(meta.errors.importsDisabled); +}); diff --git a/packages/backend/src/server/api/endpoints/i/import-user-lists.ts b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts new file mode 100644 index 0000000..03b1dff --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/import-user-lists.ts @@ -0,0 +1,59 @@ +import define from "../../define.js"; +import { createImportUserListsJob } from "@/queue/index.js"; +import { ApiError } from "../../error.js"; +import { DriveFiles } from "@/models/index.js"; +import { HOUR } from "@/const.js"; + +export const meta = { + secure: true, + requireCredential: true, + limit: { + duration: HOUR, + max: 1, + }, + + errors: { + noSuchFile: { + message: "No such file.", + code: "NO_SUCH_FILE", + id: "ea9cc34f-c415-4bc6-a6fe-28ac40357049", + }, + + unexpectedFileType: { + message: "We need csv file.", + code: "UNEXPECTED_FILE_TYPE", + id: "a3c9edda-dd9b-4596-be6a-150ef813745c", + }, + + tooBigFile: { + message: "That file is too big.", + code: "TOO_BIG_FILE", + id: "ae6e7a22-971b-4b52-b2be-fc0b9b121fe9", + }, + + emptyFile: { + message: "That file is empty.", + code: "EMPTY_FILE", + id: "99efe367-ce6e-4d44-93f8-5fae7b040356", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + fileId: { type: "string", format: "misskey:id" }, + }, + required: ["fileId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const file = await DriveFiles.findOneBy({ id: ps.fileId }); + + if (file == null) throw new ApiError(meta.errors.noSuchFile); + //if (!file.type.endsWith('/csv')) throw new ApiError(meta.errors.unexpectedFileType); + if (file.size > 30000) throw new ApiError(meta.errors.tooBigFile); + if (file.size === 0) throw new ApiError(meta.errors.emptyFile); + + createImportUserListsJob(user, file.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/known-as.ts b/packages/backend/src/server/api/endpoints/i/known-as.ts new file mode 100644 index 0000000..0d0c061 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/known-as.ts @@ -0,0 +1,108 @@ +import type { User } from "@/models/entities/user.js"; +import { Users } from "@/models/index.js"; +import { resolveUser } from "@/remote/resolve-user.js"; +import acceptAllFollowRequests from "@/services/following/requests/accept-all.js"; +import { publishToFollowers } from "@/services/i/update.js"; +import { publishMainStream } from "@/services/stream.js"; +import { DAY } from "@/const.js"; +import { apiLogger } from "../../logger.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { parse } from "@/misc/acct.js"; + +export const meta = { + tags: ["users"], + + secure: true, + requireCredential: true, + + limit: { + duration: DAY, + max: 30, + }, + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "fcd2eef9-a9b2-4c4f-8624-038099e90aa5", + }, + notRemote: { + message: "User is not remote. You can only migrate to other instances.", + code: "NOT_REMOTE", + id: "4362f8dc-731f-4ad8-a694-be2a88922a24", + }, + uriNull: { + message: "User ActivityPup URI is null.", + code: "URI_NULL", + id: "bf326f31-d430-4f97-9933-5d61e4d48a23", + }, + alreadyMoved: { + message: "You have already moved your account.", + code: "ALREADY_MOVED", + id: "56f20ec9-fd06-4fa5-841b-edd6d7d4fa31", + }, + yourself: { + message: "You can't set yourself as your own alias.", + code: "FORBIDDEN_TO_SET_YOURSELF", + id: "25c90186-4ab0-49c8-9bba-a1fa6c202ba4", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + alsoKnownAs: { + type: "array", + maxItems: 10, + uniqueItems: true, + items: { type: "string" }, + }, + }, + required: ["alsoKnownAs"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + if (!ps.alsoKnownAs) throw new ApiError(meta.errors.noSuchUser); + if (user.movedToUri) throw new ApiError(meta.errors.alreadyMoved); + + const newAka = new Set(); + + for (const line of ps.alsoKnownAs) { + if (!line) throw new ApiError(meta.errors.noSuchUser); + const { username, host } = parse(line); + + const aka = await resolveUser(username, host).catch((e) => { + apiLogger.warn(`failed to resolve remote user: ${e}`); + throw new ApiError(meta.errors.noSuchUser); + }); + + if (aka.id === user.id) throw new ApiError(meta.errors.yourself); + if (!aka.uri) throw new ApiError(meta.errors.uriNull); + + newAka.add(aka.uri); + } + + const updates = { + alsoKnownAs: newAka.size > 0 ? Array.from(newAka) : null, + } as Partial; + + await Users.update(user.id, updates); + + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: true, + }); + + // Publish meUpdated event + publishMainStream(user.id, "meUpdated", iObj); + + if (user.isLocked === false) { + acceptAllFollowRequests(user); + } + + publishToFollowers(user.id); + + return iObj; +}); diff --git a/packages/backend/src/server/api/endpoints/i/move.ts b/packages/backend/src/server/api/endpoints/i/move.ts new file mode 100644 index 0000000..d972aaf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/move.ts @@ -0,0 +1,166 @@ +import type { User } from "@/models/entities/user.js"; +import { resolveUser } from "@/remote/resolve-user.js"; +import { DAY } from "@/const.js"; +import DeliverManager from "@/remote/activitypub/deliver-manager.js"; +import { renderActivity } from "@/remote/activitypub/renderer/index.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { apiLogger } from "../../logger.js"; +import deleteFollowing from "@/services/following/delete.js"; +import create from "@/services/following/create.js"; +import { getUser } from "@/server/api/common/getters.js"; +import { Followings, Users } from "@/models/index.js"; +import config from "@/config/index.js"; +import { publishMainStream } from "@/services/stream.js"; +import { parse } from "@/misc/acct.js"; + +export const meta = { + tags: ["users"], + + secure: true, + requireCredential: true, + + limit: { + duration: DAY, + max: 5, + }, + + errors: { + noSuchMoveTarget: { + message: "No such move target.", + code: "NO_SUCH_MOVE_TARGET", + id: "b5c90186-4ab0-49c8-9bba-a1f76c202ba4", + }, + remoteAccountForbids: { + message: + "Remote account doesn't have proper 'Known As' alias. Did you remember to set it?", + code: "REMOTE_ACCOUNT_FORBIDS", + id: "b5c90186-4ab0-49c8-9bba-a1f766282ba4", + }, + notRemote: { + message: "User is not remote. You can only migrate to other instances.", + code: "NOT_REMOTE", + id: "4362f8dc-731f-4ad8-a694-be2a88922a24", + }, + adminForbidden: { + message: "Admins cant migrate.", + code: "NOT_ADMIN_FORBIDDEN", + id: "4362e8dc-731f-4ad8-a694-be2a88922a24", + }, + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "fcd2eef9-a9b2-4c4f-8624-038099e90aa5", + }, + uriNull: { + message: "User ActivityPup URI is null.", + code: "URI_NULL", + id: "bf326f31-d430-4f97-9933-5d61e4d48a23", + }, + localUriNull: { + message: "Local User ActivityPup URI is null.", + code: "URI_NULL", + id: "95ba11b9-90e8-43a5-ba16-7acc1ab32e71", + }, + alreadyMoved: { + message: "Account was already moved to another account.", + code: "ALREADY_MOVED", + id: "b234a14e-9ebe-4581-8000-074b3c215962", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + moveToAccount: { type: "string" }, + }, + required: ["moveToAccount"], +} as const; + +function moveActivity(toUrl: string, fromUrl: string) { + const activity = { + id: null, + actor: fromUrl, + type: "Move", + object: fromUrl, + target: toUrl, + } as any; + + return renderActivity(activity); +} + +export default define(meta, paramDef, async (ps, user) => { + if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchMoveTarget); + if (user.isAdmin) throw new ApiError(meta.errors.adminForbidden); + if (user.movedToUri) throw new ApiError(meta.errors.alreadyMoved); + + const { username, host } = parse(ps.moveToAccount); + if (!host) throw new ApiError(meta.errors.notRemote); + + const moveTo: User = await resolveUser(username, host).catch((e) => { + apiLogger.warn(`failed to resolve remote user: ${e}`); + throw new ApiError(meta.errors.noSuchMoveTarget); + }); + let fromUrl: string | null = user.uri; + if (!fromUrl) { + fromUrl = `${config.url}/users/${user.id}`; + } + + let toUrl: string | null = moveTo.uri; + if (!toUrl) { + throw new ApiError(meta.errors.uriNull); + } + + let allowed = false; + + moveTo.alsoKnownAs?.forEach((element) => { + if (fromUrl!.includes(element)) allowed = true; + }); + + if (!(allowed && toUrl && fromUrl)) + throw new ApiError(meta.errors.remoteAccountForbids); + + const updates = {} as Partial; + + if (!toUrl) toUrl = ""; + updates.movedToUri = toUrl; + updates.alsoKnownAs = user.alsoKnownAs?.concat(toUrl) ?? [toUrl]; + + await Users.update(user.id, updates); + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: true, + }); + + const moveAct = moveActivity(toUrl, fromUrl); + const dm = new DeliverManager(user, moveAct); + dm.addFollowersRecipe(); + dm.execute(); + + // Publish meUpdated event + publishMainStream(user.id, "meUpdated", iObj); + + const followings = await Followings.findBy({ + followeeId: user.id, + }); + + followings.forEach(async (following) => { + //if follower is local + if (!following.followerHost) { + const follower = await getUser(following.followerId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + await deleteFollowing(follower!, user); + try { + await create(follower!, moveTo); + } catch (e) { + /* empty */ + } + } + }); + + return iObj; +}); diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts new file mode 100644 index 0000000..87177c0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -0,0 +1,178 @@ +import { Brackets } from "typeorm"; +import { + Notifications, + Followings, + Mutings, + Users, + UserProfiles, +} from "@/models/index.js"; +import { notificationTypes } from "@/types.js"; +import read from "@/services/note/read.js"; +import { readNotification } from "../../common/read-notification.js"; +import define from "../../define.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["account", "notifications"], + + requireCredential: true, + + limit: { + duration: 60000, + max: 15, + }, + + kind: "read:notifications", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Notification", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + following: { type: "boolean", default: false }, + unreadOnly: { type: "boolean", default: false }, + markAsRead: { type: "boolean", default: true }, + includeTypes: { + type: "array", + items: { + type: "string", + enum: notificationTypes, + }, + }, + excludeTypes: { + type: "array", + items: { + type: "string", + enum: notificationTypes, + }, + }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // includeTypes が空の場合はクエリしない + if (ps.includeTypes && ps.includeTypes.length === 0) { + return []; + } + // excludeTypes に全指定されている場合はクエリしない + if (notificationTypes.every((type) => ps.excludeTypes?.includes(type))) { + return []; + } + const followingQuery = Followings.createQueryBuilder("following") + .select("following.followeeId") + .where("following.followerId = :followerId", { followerId: user.id }); + + const mutingQuery = Mutings.createQueryBuilder("muting") + .select("muting.muteeId") + .where("muting.muterId = :muterId", { muterId: user.id }); + + const mutingInstanceQuery = UserProfiles.createQueryBuilder("user_profile") + .select("user_profile.mutedInstances") + .where("user_profile.userId = :muterId", { muterId: user.id }); + + const suspendedQuery = Users.createQueryBuilder("users") + .select("users.id") + .where("users.isSuspended = TRUE"); + + const query = makePaginationQuery( + Notifications.createQueryBuilder("notification"), + ps.sinceId, + ps.untilId, + ) + .andWhere("notification.notifieeId = :meId", { meId: user.id }) + .leftJoinAndSelect("notification.notifier", "notifier") + .leftJoinAndSelect("notification.note", "note") + .leftJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser"); + + // muted users + query.andWhere( + new Brackets((qb) => { + qb.where( + `notification.notifierId NOT IN (${mutingQuery.getQuery()})`, + ).orWhere("notification.notifierId IS NULL"); + }), + ); + query.setParameters(mutingQuery.getParameters()); + + // muted instances + query.andWhere( + new Brackets((qb) => { + qb.andWhere("notifier.host IS NULL").orWhere( + `NOT (( ${mutingInstanceQuery.getQuery()} )::jsonb ? notifier.host)`, + ); + }), + ); + query.setParameters(mutingInstanceQuery.getParameters()); + + // suspended users + query.andWhere( + new Brackets((qb) => { + qb.where( + `notification.notifierId NOT IN (${suspendedQuery.getQuery()})`, + ).orWhere("notification.notifierId IS NULL"); + }), + ); + + if (ps.following) { + query.andWhere( + `((notification.notifierId IN (${followingQuery.getQuery()})) OR (notification.notifierId = :meId))`, + { meId: user.id }, + ); + query.setParameters(followingQuery.getParameters()); + } + + if (ps.includeTypes && ps.includeTypes.length > 0) { + query.andWhere("notification.type IN (:...includeTypes)", { + includeTypes: ps.includeTypes, + }); + } else if (ps.excludeTypes && ps.excludeTypes.length > 0) { + query.andWhere("notification.type NOT IN (:...excludeTypes)", { + excludeTypes: ps.excludeTypes, + }); + } + + if (ps.unreadOnly) { + query.andWhere("notification.isRead = false"); + } + + const notifications = await query.take(ps.limit).getMany(); + + // Mark all as read + if (notifications.length > 0 && ps.markAsRead) { + readNotification( + user.id, + notifications.map((x) => x.id), + ); + } + + const notes = notifications + .filter((notification) => + ["mention", "reply", "quote"].includes(notification.type), + ) + .map((notification) => notification.note!); + + if (notes.length > 0) { + read(user.id, notes); + } + + return await Notifications.packMany(notifications, user.id); +}); diff --git a/packages/backend/src/server/api/endpoints/i/page-likes.ts b/packages/backend/src/server/api/endpoints/i/page-likes.ts new file mode 100644 index 0000000..1be783a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/page-likes.ts @@ -0,0 +1,58 @@ +import { PageLikes } from "@/models/index.js"; +import define from "../../define.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["account", "pages"], + + requireCredential: true, + + kind: "read:page-likes", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + page: { + type: "object", + optional: false, + nullable: false, + ref: "Page", + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + PageLikes.createQueryBuilder("like"), + ps.sinceId, + ps.untilId, + ) + .andWhere("like.userId = :meId", { meId: user.id }) + .leftJoinAndSelect("like.page", "page"); + + const likes = await query.take(ps.limit).getMany(); + + return PageLikes.packMany(likes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/i/pages.ts b/packages/backend/src/server/api/endpoints/i/pages.ts new file mode 100644 index 0000000..78b72e3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/pages.ts @@ -0,0 +1,45 @@ +import { Pages } from "@/models/index.js"; +import define from "../../define.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["account", "pages"], + + requireCredential: true, + + kind: "read:pages", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Page", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + Pages.createQueryBuilder("page"), + ps.sinceId, + ps.untilId, + ).andWhere("page.userId = :meId", { meId: user.id }); + + const pages = await query.take(ps.limit).getMany(); + + return await Pages.packMany(pages); +}); diff --git a/packages/backend/src/server/api/endpoints/i/pin.ts b/packages/backend/src/server/api/endpoints/i/pin.ts new file mode 100644 index 0000000..40aa579 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/pin.ts @@ -0,0 +1,63 @@ +import { addPinned } from "@/services/i/pin.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { Users } from "@/models/index.js"; + +export const meta = { + tags: ["account", "notes"], + + requireCredential: true, + + kind: "write:account", + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "56734f8b-3928-431e-bf80-6ff87df40cb3", + }, + + pinLimitExceeded: { + message: "You can not pin notes any more.", + code: "PIN_LIMIT_EXCEEDED", + id: "72dab508-c64d-498f-8740-a8eec1ba385a", + }, + + alreadyPinned: { + message: "That note has already been pinned.", + code: "ALREADY_PINNED", + id: "8b18c2b7-68fe-4edb-9892-c0cbaeb6c913", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "MeDetailed", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + await addPinned(user, ps.noteId).catch((e) => { + if (e.id === "70c4e51f-5bea-449c-a030-53bee3cce202") + throw new ApiError(meta.errors.noSuchNote); + if (e.id === "15a018eb-58e5-4da1-93be-330fcc5e4e1a") + throw new ApiError(meta.errors.pinLimitExceeded); + if (e.id === "23f0cf4e-59a3-4276-a91d-61a5891c1514") + throw new ApiError(meta.errors.alreadyPinned); + throw e; + }); + + return await Users.pack(user.id, user, { + detail: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts b/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts new file mode 100644 index 0000000..0333677 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts @@ -0,0 +1,48 @@ +import { publishMainStream } from "@/services/stream.js"; +import define from "../../define.js"; +import { MessagingMessages, UserGroupJoinings } from "@/models/index.js"; + +export const meta = { + tags: ["account", "messaging"], + + requireCredential: true, + + kind: "write:account", +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Update documents + await MessagingMessages.update( + { + recipientId: user.id, + isRead: false, + }, + { + isRead: true, + }, + ); + + const joinings = await UserGroupJoinings.findBy({ userId: user.id }); + + await Promise.all( + joinings.map((j) => + MessagingMessages.createQueryBuilder() + .update() + .set({ + reads: (() => `array_append("reads", '${user.id}')`) as any, + }) + .where("groupId = :groupId", { groupId: j.userGroupId }) + .andWhere("userId != :userId", { userId: user.id }) + .andWhere("NOT (:userId = ANY(reads))", { userId: user.id }) + .execute(), + ), + ); + + publishMainStream(user.id, "readAllMessagingMessages"); +}); diff --git a/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts b/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts new file mode 100644 index 0000000..8a8857c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/read-all-unread-notes.ts @@ -0,0 +1,28 @@ +import { publishMainStream } from "@/services/stream.js"; +import define from "../../define.js"; +import { NoteUnreads } from "@/models/index.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + kind: "write:account", +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Remove documents + await NoteUnreads.delete({ + userId: user.id, + }); + + // 全て既読になったイベントを発行 + publishMainStream(user.id, "readAllUnreadMentions"); + publishMainStream(user.id, "readAllUnreadSpecifiedNotes"); +}); diff --git a/packages/backend/src/server/api/endpoints/i/read-announcement.ts b/packages/backend/src/server/api/endpoints/i/read-announcement.ts new file mode 100644 index 0000000..d0dfa66 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/read-announcement.ts @@ -0,0 +1,64 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { genId } from "@/misc/gen-id.js"; +import { AnnouncementReads, Announcements, Users } from "@/models/index.js"; +import { publishMainStream } from "@/services/stream.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + kind: "write:account", + + errors: { + noSuchAnnouncement: { + message: "No such announcement.", + code: "NO_SUCH_ANNOUNCEMENT", + id: "184663db-df88-4bc2-8b52-fb85f0681939", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + announcementId: { type: "string", format: "misskey:id" }, + }, + required: ["announcementId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Check if announcement exists + const exist = await Announcements.exist({ + where: { id: ps.announcementId }, + }); + + if (!exist) { + throw new ApiError(meta.errors.noSuchAnnouncement); + } + + // Check if already read + const read = await AnnouncementReads.exist({ + where: { + announcementId: ps.announcementId, + userId: user.id, + }, + }); + + if (read) { + return; + } + + // Create read + await AnnouncementReads.insert({ + id: genId(), + createdAt: new Date(), + announcementId: ps.announcementId, + userId: user.id, + }); + + if (!(await Users.getHasUnreadAnnouncement(user.id))) { + publishMainStream(user.id, "readAllAnnouncements"); + } +}); diff --git a/packages/backend/src/server/api/endpoints/i/regenerate-token.ts b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts new file mode 100644 index 0000000..b5b34c0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/regenerate-token.ts @@ -0,0 +1,56 @@ +import { + publishInternalEvent, + publishMainStream, + publishUserEvent, +} from "@/services/stream.js"; +import generateUserToken from "../../common/generate-native-user-token.js"; +import define from "../../define.js"; +import { Users, UserProfiles } from "@/models/index.js"; +import { comparePassword } from "@/misc/password.js"; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + password: { type: "string" }, + }, + required: ["password"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const freshUser = await Users.findOneByOrFail({ id: user.id }); + const oldToken = freshUser.token; + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + // Compare password + const same = await comparePassword(ps.password, profile.password!); + + if (!same) { + throw new Error("incorrect password"); + } + + const newToken = generateUserToken(); + + await Users.update(user.id, { + token: newToken, + }); + + // Publish event + publishInternalEvent("userTokenRegenerated", { + id: user.id, + oldToken, + newToken, + }); + publishMainStream(user.id, "myTokenRegenerated"); + + // Terminate streaming + setTimeout(() => { + publishUserEvent(user.id, "terminate", {}); + }, 5000); +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-all.ts b/packages/backend/src/server/api/endpoints/i/registry/get-all.ts new file mode 100644 index 0000000..ee9fe7e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/get-all.ts @@ -0,0 +1,40 @@ +import define from "../../../define.js"; +import { RegistryItems } from "@/models/index.js"; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + scope: { + type: "array", + default: [], + items: { + type: "string", + pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), + }, + }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = RegistryItems.createQueryBuilder("item") + .where("item.domain IS NULL") + .andWhere("item.userId = :userId", { userId: user.id }) + .andWhere("item.scope = :scope", { scope: ps.scope }); + + const items = await query.getMany(); + + const res = {} as Record; + + for (const item of items) { + res[item.key] = item.value; + } + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts b/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts new file mode 100644 index 0000000..85900bd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/get-detail.ts @@ -0,0 +1,52 @@ +import define from "../../../define.js"; +import { RegistryItems } from "@/models/index.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + requireCredential: true, + + secure: true, + + errors: { + noSuchKey: { + message: "No such key.", + code: "NO_SUCH_KEY", + id: "97a1e8e7-c0f7-47d2-957a-92e61256e01a", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + key: { type: "string" }, + scope: { + type: "array", + default: [], + items: { + type: "string", + pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), + }, + }, + }, + required: ["key"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = RegistryItems.createQueryBuilder("item") + .where("item.domain IS NULL") + .andWhere("item.userId = :userId", { userId: user.id }) + .andWhere("item.key = :key", { key: ps.key }) + .andWhere("item.scope = :scope", { scope: ps.scope }); + + const item = await query.getOne(); + + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + return { + updatedAt: item.updatedAt, + value: item.value, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/get.ts b/packages/backend/src/server/api/endpoints/i/registry/get.ts new file mode 100644 index 0000000..b143b72 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/get.ts @@ -0,0 +1,49 @@ +import define from "../../../define.js"; +import { RegistryItems } from "@/models/index.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + requireCredential: true, + + secure: true, + + errors: { + noSuchKey: { + message: "No such key.", + code: "NO_SUCH_KEY", + id: "ac3ed68a-62f0-422b-a7bc-d5e09e8f6a6a", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + key: { type: "string" }, + scope: { + type: "array", + default: [], + items: { + type: "string", + pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), + }, + }, + }, + required: ["key"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = RegistryItems.createQueryBuilder("item") + .where("item.domain IS NULL") + .andWhere("item.userId = :userId", { userId: user.id }) + .andWhere("item.key = :key", { key: ps.key }) + .andWhere("item.scope = :scope", { scope: ps.scope }); + + const item = await query.getOne(); + + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + return item.value; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts b/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts new file mode 100644 index 0000000..23698dc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/keys-with-type.ts @@ -0,0 +1,54 @@ +import define from "../../../define.js"; +import { RegistryItems } from "@/models/index.js"; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + scope: { + type: "array", + default: [], + items: { + type: "string", + pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), + }, + }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = RegistryItems.createQueryBuilder("item") + .where("item.domain IS NULL") + .andWhere("item.userId = :userId", { userId: user.id }) + .andWhere("item.scope = :scope", { scope: ps.scope }); + + const items = await query.getMany(); + + const res = {} as Record; + + for (const item of items) { + const type = typeof item.value; + res[item.key] = + item.value === null + ? "null" + : Array.isArray(item.value) + ? "array" + : type === "number" + ? "number" + : type === "string" + ? "string" + : type === "boolean" + ? "boolean" + : type === "object" + ? "object" + : (null as never); + } + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/keys.ts b/packages/backend/src/server/api/endpoints/i/registry/keys.ts new file mode 100644 index 0000000..ad7d08c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/keys.ts @@ -0,0 +1,35 @@ +import define from "../../../define.js"; +import { RegistryItems } from "@/models/index.js"; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + scope: { + type: "array", + default: [], + items: { + type: "string", + pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), + }, + }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = RegistryItems.createQueryBuilder("item") + .select("item.key") + .where("item.domain IS NULL") + .andWhere("item.userId = :userId", { userId: user.id }) + .andWhere("item.scope = :scope", { scope: ps.scope }); + + const items = await query.getMany(); + + return items.map((x) => x.key); +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/remove.ts b/packages/backend/src/server/api/endpoints/i/registry/remove.ts new file mode 100644 index 0000000..d3793b0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/remove.ts @@ -0,0 +1,49 @@ +import define from "../../../define.js"; +import { RegistryItems } from "@/models/index.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + requireCredential: true, + + secure: true, + + errors: { + noSuchKey: { + message: "No such key.", + code: "NO_SUCH_KEY", + id: "1fac4e8a-a6cd-4e39-a4a5-3a7e11f1b019", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + key: { type: "string" }, + scope: { + type: "array", + default: [], + items: { + type: "string", + pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), + }, + }, + }, + required: ["key"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = RegistryItems.createQueryBuilder("item") + .where("item.domain IS NULL") + .andWhere("item.userId = :userId", { userId: user.id }) + .andWhere("item.key = :key", { key: ps.key }) + .andWhere("item.scope = :scope", { scope: ps.scope }); + + const item = await query.getOne(); + + if (item == null) { + throw new ApiError(meta.errors.noSuchKey); + } + + await RegistryItems.remove(item); +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/scopes.ts b/packages/backend/src/server/api/endpoints/i/registry/scopes.ts new file mode 100644 index 0000000..3d66359 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/scopes.ts @@ -0,0 +1,32 @@ +import define from "../../../define.js"; +import { RegistryItems } from "@/models/index.js"; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = RegistryItems.createQueryBuilder("item") + .select("item.scope") + .where("item.domain IS NULL") + .andWhere("item.userId = :userId", { userId: user.id }); + + const items = await query.getMany(); + + const res = [] as string[][]; + + for (const item of items) { + if (res.some((scope) => scope.join(".") === item.scope.join("."))) continue; + res.push(item.scope); + } + + return res; +}); diff --git a/packages/backend/src/server/api/endpoints/i/registry/set.ts b/packages/backend/src/server/api/endpoints/i/registry/set.ts new file mode 100644 index 0000000..7f9eebd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/registry/set.ts @@ -0,0 +1,62 @@ +import { publishMainStream } from "@/services/stream.js"; +import define from "../../../define.js"; +import { RegistryItems } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + key: { type: "string", minLength: 1 }, + value: {}, + scope: { + type: "array", + default: [], + items: { + type: "string", + pattern: /^[a-zA-Z0-9_]+$/.toString().slice(1, -1), + }, + }, + }, + required: ["key", "value"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = RegistryItems.createQueryBuilder("item") + .where("item.domain IS NULL") + .andWhere("item.userId = :userId", { userId: user.id }) + .andWhere("item.key = :key", { key: ps.key }) + .andWhere("item.scope = :scope", { scope: ps.scope }); + + const existingItem = await query.getOne(); + + if (existingItem) { + await RegistryItems.update(existingItem.id, { + updatedAt: new Date(), + value: ps.value, + }); + } else { + await RegistryItems.insert({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + userId: user.id, + domain: null, + scope: ps.scope, + key: ps.key, + value: ps.value, + }); + } + + // TODO: サードパーティアプリが傍受出来てしまうのでどうにかする + publishMainStream(user.id, "registryUpdated", { + scope: ps.scope, + key: ps.key, + value: ps.value, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/request-verified-badge.ts b/packages/backend/src/server/api/endpoints/i/request-verified-badge.ts new file mode 100644 index 0000000..f8b07ca --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/request-verified-badge.ts @@ -0,0 +1,48 @@ +import define from "../../define.js"; +import { VerifiedBadgeRequests, Users } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + kind: "write:account", +} as const; + +export const paramDef = { + type: "object", + properties: { + comment: { type: "string", maxLength: 2048, default: "" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const me = await Users.findOneByOrFail({ id: user.id }); + + if (me.isVerified) { + throw new Error("already verified"); + } + + const existing = await VerifiedBadgeRequests.findOneBy({ + userId: user.id, + status: "pending", + }); + + if (existing != null) { + return await VerifiedBadgeRequests.pack(existing); + } + + const request = await VerifiedBadgeRequests.insert({ + id: genId(), + createdAt: new Date(), + resolvedAt: null, + userId: user.id, + resolverId: null, + status: "pending", + comment: ps.comment ?? "", + }).then((x) => VerifiedBadgeRequests.findOneByOrFail(x.identifiers[0])); + + return await VerifiedBadgeRequests.pack(request); +}); diff --git a/packages/backend/src/server/api/endpoints/i/revoke-token.ts b/packages/backend/src/server/api/endpoints/i/revoke-token.ts new file mode 100644 index 0000000..3a410fa --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/revoke-token.ts @@ -0,0 +1,31 @@ +import define from "../../define.js"; +import { AccessTokens } from "@/models/index.js"; +import { publishUserEvent } from "@/services/stream.js"; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + tokenId: { type: "string", format: "misskey:id" }, + }, + required: ["tokenId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const exist = await AccessTokens.exist({ where: { id: ps.tokenId } }); + + if (exist) { + await AccessTokens.delete({ + id: ps.tokenId, + userId: user.id, + }); + + // Terminate streaming + publishUserEvent(user.id, "terminate"); + } +}); diff --git a/packages/backend/src/server/api/endpoints/i/signin-history.ts b/packages/backend/src/server/api/endpoints/i/signin-history.ts new file mode 100644 index 0000000..288b750 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/signin-history.ts @@ -0,0 +1,31 @@ +import define from "../../define.js"; +import { Signins } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + requireCredential: true, + + secure: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + Signins.createQueryBuilder("signin"), + ps.sinceId, + ps.untilId, + ).andWhere("signin.userId = :meId", { meId: user.id }); + + const history = await query.take(ps.limit).getMany(); + + return await Promise.all(history.map((record) => Signins.pack(record))); +}); diff --git a/packages/backend/src/server/api/endpoints/i/unpin.ts b/packages/backend/src/server/api/endpoints/i/unpin.ts new file mode 100644 index 0000000..c248eb3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/unpin.ts @@ -0,0 +1,47 @@ +import { removePinned } from "@/services/i/pin.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { Users } from "@/models/index.js"; + +export const meta = { + tags: ["account", "notes"], + + requireCredential: true, + + kind: "write:account", + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "454170ce-9d63-4a43-9da1-ea10afe81e21", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "MeDetailed", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + await removePinned(user, ps.noteId).catch((e) => { + if (e.id === "b302d4cf-c050-400a-bbb3-be208681f40c") + throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + return await Users.pack(user.id, user, { + detail: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/update-email.ts b/packages/backend/src/server/api/endpoints/i/update-email.ts new file mode 100644 index 0000000..94ad6b3 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/update-email.ts @@ -0,0 +1,95 @@ +import { publishMainStream } from "@/services/stream.js"; +import define from "../../define.js"; +import rndstr from "rndstr"; +import config from "@/config/index.js"; +import { Users, UserProfiles } from "@/models/index.js"; +import { sendEmail } from "@/services/send-email.js"; +import { ApiError } from "../../error.js"; +import { validateEmailForAccount } from "@/services/validate-email-for-account.js"; +import { HOUR } from "@/const.js"; +import { comparePassword } from "@/misc/password.js"; + +export const meta = { + requireCredential: true, + + secure: true, + + limit: { + duration: HOUR, + max: 3, + }, + + errors: { + incorrectPassword: { + message: "Incorrect password.", + code: "INCORRECT_PASSWORD", + id: "e54c1d7e-e7d6-4103-86b6-0a95069b4ad3", + }, + + unavailable: { + message: "Unavailable email address.", + code: "UNAVAILABLE", + id: "a2defefb-f220-8849-0af6-17f816099323", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + password: { type: "string" }, + email: { type: "string", nullable: true }, + }, + required: ["password"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + // Compare password + const same = await comparePassword(ps.password, profile.password!); + + if (!same) { + throw new ApiError(meta.errors.incorrectPassword); + } + + if (ps.email != null) { + const available = await validateEmailForAccount(ps.email); + if (!available) { + throw new ApiError(meta.errors.unavailable); + } + } + + await UserProfiles.update(user.id, { + email: ps.email, + emailVerified: false, + emailVerifyCode: null, + }); + + const iObj = await Users.pack(user.id, user, { + detail: true, + includeSecrets: true, + }); + + // Publish meUpdated event + publishMainStream(user.id, "meUpdated", iObj); + + if (ps.email != null) { + const code = rndstr("a-z0-9", 16); + + await UserProfiles.update(user.id, { + emailVerifyCode: code, + }); + + const link = `${config.url}/verify-email/${code}`; + + sendEmail( + ps.email, + "Email verification", + `To verify email, please click this link:
${link}`, + `To verify email, please click this link: ${link}`, + ); + } + + return iObj; +}); diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts new file mode 100644 index 0000000..0870208 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -0,0 +1,316 @@ +import RE2 from "re2"; +import * as mfm from "mfm-js"; +import { publishMainStream, publishUserEvent } from "@/services/stream.js"; +import acceptAllFollowRequests from "@/services/following/requests/accept-all.js"; +import { publishToFollowers, updateUserProfileData } from "@/services/i/update.js"; +import { extractCustomEmojisFromMfm } from "@/misc/extract-custom-emojis-from-mfm.js"; +import { extractHashtags } from "@/misc/extract-hashtags.js"; +import { updateUsertags } from "@/services/update-hashtag.js"; +import { Users, DriveFiles, UserProfiles, Pages } from "@/models/index.js"; +import type { User } from "@/models/entities/user.js"; +import type { UserProfile } from "@/models/entities/user-profile.js"; +import { notificationTypes } from "@/types.js"; +import { normalizeForSearch } from "@/misc/normalize-for-search.js"; +import { langmap } from "@/misc/langmap.js"; +import { verifyLink } from "@/services/fetch-rel-me.js"; +import { ApiError } from "../../error.js"; +import config from "@/config/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + kind: "write:account", + + errors: { + noSuchAvatar: { + message: "No such avatar file.", + code: "NO_SUCH_AVATAR", + id: "539f3a45-f215-4f81-a9a8-31293640207f", + }, + + noSuchBanner: { + message: "No such banner file.", + code: "NO_SUCH_BANNER", + id: "0d8f5629-f210-41c2-9433-735831a58595", + }, + + avatarNotAnImage: { + message: "The file specified as an avatar is not an image.", + code: "AVATAR_NOT_AN_IMAGE", + id: "f419f9f8-2f4d-46b1-9fb4-49d3a2fd7191", + }, + + bannerNotAnImage: { + message: "The file specified as a banner is not an image.", + code: "BANNER_NOT_AN_IMAGE", + id: "75aedb19-2afd-4e6d-87fc-67941256fa60", + }, + + noSuchPage: { + message: "No such page.", + code: "NO_SUCH_PAGE", + id: "8e01b590-7eb9-431b-a239-860e086c408e", + }, + + invalidRegexp: { + message: "Invalid Regular Expression.", + code: "INVALID_REGEXP", + id: "0d786918-10df-41cd-8f33-8dec7d9a89a5", + }, + + invalidFieldName: { + message: "Invalid field name.", + code: "INVALID_FIELD_NAME", + id: "8f81972e-8b53-4d30-b0d2-efb026dda673", + }, + + invalidFieldValue: { + message: "Invalid field value.", + code: "INVALID_FIELD_VALUE", + id: "aede7444-244b-11ee-be56-0242ac120002", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "MeDetailed", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + name: { ...Users.nameSchema, nullable: true }, + description: { ...Users.descriptionSchema, nullable: true }, + location: { ...Users.locationSchema, nullable: true }, + birthday: { ...Users.birthdaySchema, nullable: true }, + lang: { + type: "string", + enum: [null, ...Object.keys(langmap)], + nullable: true, + }, + avatarId: { type: "string", format: "misskey:id", nullable: true }, + bannerId: { type: "string", format: "misskey:id", nullable: true }, + fields: { + type: "array", + minItems: 0, + maxItems: 16, + items: { + type: "object", + properties: { + name: { type: "string" }, + value: { type: "string" }, + }, + required: ["name", "value"], + }, + }, + isLocked: { type: "boolean" }, + isExplorable: { type: "boolean" }, + hideOnlineStatus: { type: "boolean" }, + publicReactions: { type: "boolean" }, + allowCalls: { type: "boolean" }, + symbolFileId: { type: "string", format: "misskey:id", nullable: true }, + carefulBot: { type: "boolean" }, + autoAcceptFollowed: { type: "boolean" }, + noCrawle: { type: "boolean" }, + preventAiLearning: { type: "boolean" }, + isBot: { type: "boolean" }, + isCat: { type: "boolean" }, + speakAsCat: { type: "boolean" }, + minorBadges: { + type: "array", + uniqueItems: true, + maxItems: 1, + items: { + type: "string", + enum: ["K", "T", "E"], + }, + }, + injectFeaturedNote: { type: "boolean" }, + receiveAnnouncementEmail: { type: "boolean" }, + alwaysMarkNsfw: { type: "boolean" }, + ffVisibility: { type: "string", enum: ["public", "followers", "private"] }, + pinnedPageId: { type: "string", format: "misskey:id", nullable: true }, + mutedWords: { type: "array" }, + mutedInstances: { + type: "array", + items: { + type: "string", + }, + }, + mutingNotificationTypes: { + type: "array", + items: { + type: "string", + enum: notificationTypes, + }, + }, + emailNotificationTypes: { + type: "array", + items: { + type: "string", + }, + }, + pronouns: { type: "object", nullable: true }, + canBite: { + type: "string", + enum: ["anyone", "followers", "nobody"], + nullable: true, + }, + }, +} as const; + +export default define(meta, paramDef, async (ps, _user, token) => { + const user = await Users.findOneByOrFail({ id: _user.id }); + const isSecure = token == null; + + const updates = {} as Partial; + const profileUpdates = {} as Partial; + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + if (ps.name !== undefined) updates.name = ps.name; + if (ps.description !== undefined) profileUpdates.description = ps.description; + if (ps.lang !== undefined) profileUpdates.lang = ps.lang; + if (ps.location !== undefined) profileUpdates.location = ps.location; + if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; + if (ps.ffVisibility !== undefined) + profileUpdates.ffVisibility = ps.ffVisibility; + if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; + if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; + if (ps.mutedWords !== undefined) { + // validate regular expression syntax + ps.mutedWords + .filter((x) => !Array.isArray(x)) + .forEach((x) => { + const regexp = x.match(/^\/(.+)\/(.*)$/); + if (!regexp) throw new ApiError(meta.errors.invalidRegexp); + + try { + new RE2(regexp[1], regexp[2]); + } catch (err) { + throw new ApiError(meta.errors.invalidRegexp); + } + }); + + profileUpdates.mutedWords = ps.mutedWords; + profileUpdates.enableWordMute = ps.mutedWords.length > 0; + } + if (ps.mutedInstances !== undefined) + profileUpdates.mutedInstances = ps.mutedInstances; + if (ps.mutingNotificationTypes !== undefined) + profileUpdates.mutingNotificationTypes = + ps.mutingNotificationTypes as typeof notificationTypes[number][]; + if (typeof ps.isLocked === "boolean") updates.isLocked = ps.isLocked; + if (typeof ps.isExplorable === "boolean") + updates.isExplorable = ps.isExplorable; + if (typeof ps.hideOnlineStatus === "boolean") + updates.hideOnlineStatus = ps.hideOnlineStatus; + if (typeof ps.publicReactions === "boolean") + profileUpdates.publicReactions = ps.publicReactions; + if (typeof ps.allowCalls === "boolean") + profileUpdates.allowCalls = ps.allowCalls; + if (ps.symbolFileId !== undefined) + profileUpdates.symbolFileId = ps.symbolFileId; + if (typeof ps.isBot === "boolean") updates.isBot = ps.isBot; + if (ps.minorBadges !== undefined) updates.minorBadges = ps.minorBadges.slice(0, 1); + if (typeof ps.carefulBot === "boolean") + profileUpdates.carefulBot = ps.carefulBot; + if (typeof ps.autoAcceptFollowed === "boolean") + profileUpdates.autoAcceptFollowed = ps.autoAcceptFollowed; + if (typeof ps.noCrawle === "boolean") profileUpdates.noCrawle = ps.noCrawle; + if (typeof ps.preventAiLearning === "boolean") + profileUpdates.preventAiLearning = ps.preventAiLearning; + if (typeof ps.isCat === "boolean") updates.isCat = ps.isCat; + if (typeof ps.speakAsCat === "boolean") updates.speakAsCat = ps.speakAsCat; + if (typeof ps.injectFeaturedNote === "boolean") + profileUpdates.injectFeaturedNote = ps.injectFeaturedNote; + if (typeof ps.receiveAnnouncementEmail === "boolean") + profileUpdates.receiveAnnouncementEmail = ps.receiveAnnouncementEmail; + if (typeof ps.alwaysMarkNsfw === "boolean") + profileUpdates.alwaysMarkNsfw = ps.alwaysMarkNsfw; + if (ps.emailNotificationTypes !== undefined) + profileUpdates.emailNotificationTypes = ps.emailNotificationTypes; + if (typeof ps.pronouns === "object") { + if (ps.pronouns === null) + ps.pronouns = {}; + for (const key of Object.keys(ps.pronouns)) { + if (key.length !== 2 || typeof ps.pronouns[key] !== "string" || ps.pronouns[key].length === 0) { + delete ps.pronouns[key]; + } + } + profileUpdates.pronouns = ps.pronouns; + } + + const avatar = ps.avatarId ? await DriveFiles.findOneBy({ id: ps.avatarId }) : null; + const banner = ps.bannerId ? await DriveFiles.findOneBy({ id: ps.bannerId }) : null; + + if (ps.avatarId) { + if (avatar == null || avatar.userId !== user.id) + throw new ApiError(meta.errors.noSuchAvatar); + if (!avatar.type.startsWith("image/")) + throw new ApiError(meta.errors.avatarNotAnImage); + + updates.avatarUrl = DriveFiles.getDatabasePrefetchUrl(avatar, true); + updates.avatarBlurhash = avatar.blurhash; + } + + if (ps.bannerId) { + if (banner == null || banner.userId !== user.id) + throw new ApiError(meta.errors.noSuchBanner); + if (!banner.type.startsWith("image/")) + throw new ApiError(meta.errors.bannerNotAnImage); + + updates.bannerUrl = DriveFiles.getDatabasePrefetchUrl(banner, false); + updates.bannerBlurhash = banner.blurhash; + } + + if (ps.pinnedPageId) { + const page = await Pages.findOneBy({ id: ps.pinnedPageId }); + + if (page == null || page.userId !== user.id) + throw new ApiError(meta.errors.noSuchPage); + + profileUpdates.pinnedPageId = page.id; + } else if (ps.pinnedPageId === null) { + profileUpdates.pinnedPageId = null; + } + + if (ps.fields) { + for (const field of ps.fields) { + if (!field || field.name === "" || field.value === "") { + continue; + } + if (typeof field.name !== "string" || field.name === "") { + throw new ApiError(meta.errors.invalidFieldName); + } + if (typeof field.value !== "string" || field.value === "") { + throw new ApiError(meta.errors.invalidFieldValue); + } + if (field.value.startsWith("http")) { + field.verified = await verifyLink(field.value, user.username); + } + } + + profileUpdates.fields = ps.fields + .filter((x) => Object.keys(x).length !== 0) + .map((x) => { + return { + name: x.name, + value: x.value, + verified: x.verified, + }; + }); + } + + if (ps.canBite) { + updates.canBite = ps.canBite; + } + + return updateUserProfileData(user, profile, updates, profileUpdates, isSecure); +}); diff --git a/packages/backend/src/server/api/endpoints/i/user-emojis/create.ts b/packages/backend/src/server/api/endpoints/i/user-emojis/create.ts new file mode 100644 index 0000000..bc3fe63 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/user-emojis/create.ts @@ -0,0 +1,83 @@ +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { DriveFiles, UserEmojis } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { getEmojiSize } from "@/misc/emoji-meta.js"; +import { clearUserEmojiCache } from "@/misc/populate-emojis.js"; + +function normalizeMimeType(type: string | null | undefined): string | null { + const mime = type?.split(";")[0]?.trim().toLowerCase(); + return mime && mime.length <= 64 ? mime : null; +} + +export const meta = { + tags: ["account"], + requireCredential: true, + kind: "write:account", + errors: { + noSuchFile: { + message: "No such file.", + code: "NO_SUCH_FILE", + id: "7be656a7-cb71-41a8-9904-e8b6481e61dd", + }, + notImage: { + message: "The file is not an image.", + code: "NOT_IMAGE", + id: "64cfbb24-2628-4217-873d-09e22957cf49", + }, + alreadyExists: { + message: "User emoji already exists.", + code: "ALREADY_EXISTS", + id: "259b6fdb-c603-4244-81cb-7218b967ab62", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + name: { type: "string", pattern: "^[a-z0-9_]{1,64}$" }, + fileId: { type: "string", format: "misskey:id" }, + glyph: { type: "boolean", default: false }, + }, + required: ["name", "fileId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const file = await DriveFiles.findOneBy({ id: ps.fileId, userId: me.id }); + if (!file) throw new ApiError(meta.errors.noSuchFile); + if (!file.type.startsWith("image/")) throw new ApiError(meta.errors.notImage); + + const exists = await UserEmojis.findOneBy({ name: ps.name, userId: me.id }); + if (exists) throw new ApiError(meta.errors.alreadyExists); + + const size = await getEmojiSize(file.url).catch(() => ({ + width: null, + height: null, + })); + const type = normalizeMimeType(file.webpublicType ?? file.type); + const emoji = await UserEmojis.insert({ + id: genId(), + createdAt: new Date(), + name: ps.name, + userId: me.id, + userGroupId: null, + originalUrl: file.url, + publicUrl: file.webpublicUrl ?? file.url, + type, + glyph: ps.glyph, + width: size.width || null, + height: size.height || null, + }).then((x) => UserEmojis.findOneByOrFail(x.identifiers[0])); + await clearUserEmojiCache(emoji.name, me.username, me.host); + + return { + id: emoji.id, + name: emoji.name, + url: emoji.publicUrl || emoji.originalUrl, + glyph: emoji.glyph, + glyphUrl: emoji.glyph ? emoji.originalUrl : null, + width: emoji.width, + height: emoji.height, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/i/user-emojis/delete.ts b/packages/backend/src/server/api/endpoints/i/user-emojis/delete.ts new file mode 100644 index 0000000..c500e2c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/user-emojis/delete.ts @@ -0,0 +1,33 @@ +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { UserEmojis } from "@/models/index.js"; +import { clearUserEmojiCache } from "@/misc/populate-emojis.js"; + +export const meta = { + tags: ["account"], + requireCredential: true, + kind: "write:account", + errors: { + noSuchEmoji: { + message: "No such user emoji.", + code: "NO_SUCH_USER_EMOJI", + id: "a44ed453-7bec-4ae0-940e-2eb3e80e7b62", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + id: { type: "string", format: "misskey:id" }, + }, + required: ["id"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const emoji = await UserEmojis.findOneBy({ id: ps.id, userId: me.id }); + if (!emoji) throw new ApiError(meta.errors.noSuchEmoji); + + await UserEmojis.delete(emoji.id); + await clearUserEmojiCache(emoji.name, me.username, me.host); +}); diff --git a/packages/backend/src/server/api/endpoints/i/user-emojis/list.ts b/packages/backend/src/server/api/endpoints/i/user-emojis/list.ts new file mode 100644 index 0000000..9dfdcd4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/user-emojis/list.ts @@ -0,0 +1,31 @@ +import define from "../../../define.js"; +import { UserEmojis } from "@/models/index.js"; + +export const meta = { + tags: ["account"], + requireCredential: true, + kind: "read:account", +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (_ps, me) => { + const emojis = await UserEmojis.find({ + where: { userId: me.id }, + order: { createdAt: "DESC" }, + }); + + return emojis.map((emoji) => ({ + id: emoji.id, + name: emoji.name, + url: emoji.publicUrl || emoji.originalUrl, + glyph: emoji.glyph, + glyphUrl: emoji.glyph ? emoji.originalUrl : null, + width: emoji.width, + height: emoji.height, + })); +}); diff --git a/packages/backend/src/server/api/endpoints/i/user-group-invites.ts b/packages/backend/src/server/api/endpoints/i/user-group-invites.ts new file mode 100644 index 0000000..d0c6caf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/user-group-invites.ts @@ -0,0 +1,60 @@ +import define from "../../define.js"; +import { UserGroupInvitations } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["account", "groups"], + + requireCredential: true, + + kind: "read:user-groups", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + group: { + type: "object", + optional: false, + nullable: false, + ref: "UserGroup", + }, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + UserGroupInvitations.createQueryBuilder("invitation"), + ps.sinceId, + ps.untilId, + ) + .andWhere("invitation.userId = :meId", { meId: user.id }) + .leftJoinAndSelect("invitation.userGroup", "user_group"); + + const invitations = await query.take(ps.limit).getMany(); + + return await UserGroupInvitations.packMany(invitations); +}); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/create.ts b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts new file mode 100644 index 0000000..2b0f178 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/webhooks/create.ts @@ -0,0 +1,46 @@ +import define from "../../../define.js"; +import { genId } from "@/misc/gen-id.js"; +import { Webhooks } from "@/models/index.js"; +import { publishInternalEvent } from "@/services/stream.js"; +import { webhookEventTypes } from "@/models/entities/webhook.js"; + +export const meta = { + tags: ["webhooks"], + + requireCredential: true, + + kind: "write:account", +} as const; + +export const paramDef = { + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 100 }, + url: { type: "string", minLength: 1, maxLength: 1024 }, + secret: { type: "string", minLength: 1, maxLength: 1024 }, + on: { + type: "array", + items: { + type: "string", + enum: webhookEventTypes, + }, + }, + }, + required: ["name", "url", "secret", "on"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const webhook = await Webhooks.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + url: ps.url, + secret: ps.secret, + on: ps.on, + }).then((x) => Webhooks.findOneByOrFail(x.identifiers[0])); + + publishInternalEvent("webhookCreated", webhook); + + return webhook; +}); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/delete.ts b/packages/backend/src/server/api/endpoints/i/webhooks/delete.ts new file mode 100644 index 0000000..4a2c3d8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/webhooks/delete.ts @@ -0,0 +1,43 @@ +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { Webhooks } from "@/models/index.js"; +import { publishInternalEvent } from "@/services/stream.js"; + +export const meta = { + tags: ["webhooks"], + + requireCredential: true, + + kind: "write:account", + + errors: { + noSuchWebhook: { + message: "No such webhook.", + code: "NO_SUCH_WEBHOOK", + id: "bae73e5a-5522-4965-ae19-3a8688e71d82", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + webhookId: { type: "string", format: "misskey:id" }, + }, + required: ["webhookId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const webhook = await Webhooks.findOneBy({ + id: ps.webhookId, + userId: user.id, + }); + + if (webhook == null) { + throw new ApiError(meta.errors.noSuchWebhook); + } + + await Webhooks.delete(webhook.id); + + publishInternalEvent("webhookDeleted", webhook); +}); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/list.ts b/packages/backend/src/server/api/endpoints/i/webhooks/list.ts new file mode 100644 index 0000000..3afead5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/webhooks/list.ts @@ -0,0 +1,24 @@ +import define from "../../../define.js"; +import { Webhooks } from "@/models/index.js"; + +export const meta = { + tags: ["webhooks", "account"], + + requireCredential: true, + + kind: "read:account", +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const webhooks = await Webhooks.findBy({ + userId: me.id, + }); + + return webhooks; +}); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/show.ts b/packages/backend/src/server/api/endpoints/i/webhooks/show.ts new file mode 100644 index 0000000..96c0457 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/webhooks/show.ts @@ -0,0 +1,40 @@ +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { Webhooks } from "@/models/index.js"; + +export const meta = { + tags: ["webhooks"], + + requireCredential: true, + + kind: "read:account", + + errors: { + noSuchWebhook: { + message: "No such webhook.", + code: "NO_SUCH_WEBHOOK", + id: "50f614d9-3047-4f7e-90d8-ad6b2d5fb098", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + webhookId: { type: "string", format: "misskey:id" }, + }, + required: ["webhookId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const webhook = await Webhooks.findOneBy({ + id: ps.webhookId, + userId: user.id, + }); + + if (webhook == null) { + throw new ApiError(meta.errors.noSuchWebhook); + } + + return webhook; +}); diff --git a/packages/backend/src/server/api/endpoints/i/webhooks/update.ts b/packages/backend/src/server/api/endpoints/i/webhooks/update.ts new file mode 100644 index 0000000..161d705 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/webhooks/update.ts @@ -0,0 +1,61 @@ +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { Webhooks } from "@/models/index.js"; +import { publishInternalEvent } from "@/services/stream.js"; +import { webhookEventTypes } from "@/models/entities/webhook.js"; + +export const meta = { + tags: ["webhooks"], + + requireCredential: true, + + kind: "write:account", + + errors: { + noSuchWebhook: { + message: "No such webhook.", + code: "NO_SUCH_WEBHOOK", + id: "fb0fea69-da18-45b1-828d-bd4fd1612518", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + webhookId: { type: "string", format: "misskey:id" }, + name: { type: "string", minLength: 1, maxLength: 100 }, + url: { type: "string", minLength: 1, maxLength: 1024 }, + secret: { type: "string", minLength: 1, maxLength: 1024 }, + on: { + type: "array", + items: { + type: "string", + enum: webhookEventTypes, + }, + }, + active: { type: "boolean" }, + }, + required: ["webhookId", "name", "url", "secret", "on", "active"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const webhook = await Webhooks.findOneBy({ + id: ps.webhookId, + userId: user.id, + }); + + if (webhook == null) { + throw new ApiError(meta.errors.noSuchWebhook); + } + + await Webhooks.update(webhook.id, { + name: ps.name, + url: ps.url, + secret: ps.secret, + on: ps.on, + active: ps.active, + }); + + publishInternalEvent("webhookUpdated", webhook); +}); diff --git a/packages/backend/src/server/api/endpoints/latest-version.ts b/packages/backend/src/server/api/endpoints/latest-version.ts new file mode 100644 index 0000000..cc16fa7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/latest-version.ts @@ -0,0 +1,35 @@ +import define from "../define.js"; +import config from "@/config/index.js"; + +export const meta = { + tags: ["meta"], + + requireCredential: false, + requireCredentialPrivateMode: true, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +const LATEST_VERSION_TIMEOUT = 3000; + +export default define(meta, paramDef, async () => { + let tag_name: string | null = null; + try { + const response = await fetch( + `https://iceshrimp.dev/api/v1/repos/iceshrimp/iceshrimp/releases?draft=false&pre-release=${config.version.includes("-pre")}&page=1&limit=1`, + { signal: AbortSignal.timeout(LATEST_VERSION_TIMEOUT) }, + ); + if (response.ok) { + const data = await response.json(); + tag_name = Array.isArray(data) ? data[0]?.tag_name ?? null : null; + } + } catch {} + + return { + tag_name, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/memoriet/create.ts b/packages/backend/src/server/api/endpoints/memoriet/create.ts new file mode 100644 index 0000000..ddbf022 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/memoriet/create.ts @@ -0,0 +1,188 @@ +import { Notes, Memoriets } from "@/models/index.js"; +import { MAX_NOTE_TEXT_LENGTH } from "@/const.js"; +import { noteVisibilities } from "../../../../types.js"; +import { ApiError } from "../../error.js"; +import define from "../../define.js"; +import { HOUR } from "@/const.js"; +import { createNoteFromApiData } from "@/services/note/create-from-api.js"; +import { genId } from "@/misc/gen-id.js"; +import type { MemorietTextLayer } from "@/models/entities/memoriet.js"; + +export const meta = { + tags: ["memoriet"], + + requireCredential: true, + + limit: { + duration: HOUR, + max: 300, + }, + + kind: "write:notes", + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + memoriet: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { type: "string", optional: false, nullable: false }, + expiresAt: { type: "string", optional: false, nullable: true }, + textLayers: { + type: "array", + optional: false, + nullable: false, + items: { type: "object", optional: false, nullable: false }, + }, + note: { type: "object", optional: false, nullable: false, ref: "Note" }, + }, + }, + }, + }, + + errors: { + cannotExpireToPast: { + message: "Expiration time must be in the future.", + code: "CANNOT_EXPIRE_TO_PAST", + id: "4b86af67-c80f-46ef-a166-440c4e3cf83a", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + visibility: { type: "string", enum: noteVisibilities, default: "home" }, + visibleUserIds: { + type: "array", + uniqueItems: true, + items: { + type: "string", + format: "misskey:id", + }, + }, + text: { type: "string", maxLength: MAX_NOTE_TEXT_LENGTH, nullable: true }, + cw: { type: "string", nullable: true, maxLength: 100 }, + fileIds: { + type: "array", + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: "string", format: "misskey:id" }, + }, + textLayers: { + type: "array", + maxItems: 32, + default: [], + items: { + type: "object", + properties: { + id: { type: "string", maxLength: 64 }, + text: { type: "string", minLength: 1, maxLength: 200 }, + color: { type: "string", maxLength: 32 }, + backgroundColor: { type: "string", maxLength: 32, nullable: true }, + backgroundWidth: { type: "number", minimum: 0, maximum: 100, nullable: true }, + backgroundHeight: { type: "number", minimum: 0, maximum: 100, nullable: true }, + fontSize: { type: "number", minimum: 8, maximum: 96 }, + x: { type: "number", minimum: 0, maximum: 100 }, + y: { type: "number", minimum: 0, maximum: 100 }, + rotate: { type: "number", minimum: -180, maximum: 180 }, + }, + required: ["text"], + additionalProperties: false, + }, + }, + expiresAt: { type: "integer", nullable: true }, + }, + anyOf: [ + { + properties: { + text: { + type: "string", + minLength: 1, + maxLength: MAX_NOTE_TEXT_LENGTH, + nullable: false, + }, + }, + required: ["text"], + }, + { + required: ["fileIds"], + }, + ], +} as const; + +const colorPattern = /^#([0-9a-f]{3}|[0-9a-f]{6}|[0-9a-f]{8})$/i; + +function clamp(value: unknown, min: number, max: number, fallback: number): number { + const num = typeof value === "number" && Number.isFinite(value) ? value : fallback; + return Math.min(max, Math.max(min, num)); +} + +function normalizeColor(value: unknown, fallback: string): string { + return typeof value === "string" && colorPattern.test(value) ? value : fallback; +} + +function normalizeTextLayers(layers: unknown): MemorietTextLayer[] { + if (!Array.isArray(layers)) return []; + return layers.slice(0, 32).flatMap((layer, index) => { + if (layer == null || typeof layer !== "object") return []; + const input = layer as Record; + const text = typeof input.text === "string" ? input.text.trim().slice(0, 200) : ""; + if (text.length === 0) return []; + const backgroundColor = input.backgroundColor == null || input.backgroundColor === "transparent" + ? null + : normalizeColor(input.backgroundColor, "#000000cc"); + return [{ + id: typeof input.id === "string" && input.id.length > 0 ? input.id.slice(0, 64) : genId(), + text, + color: normalizeColor(input.color, "#ffffff"), + backgroundColor, + backgroundWidth: input.backgroundWidth == null ? null : clamp(input.backgroundWidth, 0, 100, 0), + backgroundHeight: input.backgroundHeight == null ? null : clamp(input.backgroundHeight, 0, 100, 0), + fontSize: clamp(input.fontSize, 8, 96, 28), + x: clamp(input.x, 0, 100, 50), + y: clamp(input.y, 0, 100, 50 + index * 6), + rotate: clamp(input.rotate, -180, 180, 0), + }]; + }); +} + +export default define(meta, paramDef, async (ps, user) => { + const expiresAt = typeof ps.expiresAt === "number" ? new Date(ps.expiresAt) : null; + if (expiresAt != null && expiresAt.getTime() <= Date.now()) { + throw new ApiError(meta.errors.cannotExpireToPast); + } + const textLayers = normalizeTextLayers(ps.textLayers); + + const note = await createNoteFromApiData(user, { + text: ps.text ? `${ps.text.trim()}\n#Memoriet` : "#Memoriet", + cw: ps.cw, + fileIds: ps.fileIds, + visibility: ps.visibility, + visibleUserIds: ps.visibleUserIds, + localOnly: true, + }, new Date()); + + const memoriet = await Memoriets.save({ + id: genId(), + createdAt: new Date(), + userId: user.id, + noteId: note.id, + expiresAt, + textLayers, + }); + + return { + memoriet: { + id: memoriet.id, + expiresAt: memoriet.expiresAt?.toISOString() ?? null, + textLayers: memoriet.textLayers, + note: await Notes.pack(note, user), + }, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/memoriet/deleted-list.ts b/packages/backend/src/server/api/endpoints/memoriet/deleted-list.ts new file mode 100644 index 0000000..e94ac52 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/memoriet/deleted-list.ts @@ -0,0 +1,59 @@ +import { DriveFiles, MemorietArchives } from "@/models/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["memoriet"], + requireCredential: true, + kind: "read:account", + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { type: "string", optional: false, nullable: false }, + createdAt: { type: "string", optional: false, nullable: false }, + deletedAt: { type: "string", optional: false, nullable: false }, + text: { type: "string", optional: false, nullable: true }, + cw: { type: "string", optional: false, nullable: true }, + fileIds: { type: "array", optional: false, nullable: false, items: { type: "string" } }, + files: { type: "array", optional: false, nullable: false, items: { type: "object", ref: "DriveFile" } }, + textLayers: { type: "array", optional: false, nullable: false, items: { type: "object", optional: false, nullable: false } }, + visibility: { type: "string", optional: false, nullable: false }, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 50, default: 20 }, + offset: { type: "integer", minimum: 0, default: 0 }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const archives = await MemorietArchives.find({ + where: { userId: user.id }, + order: { deletedAt: "DESC" }, + skip: ps.offset, + take: ps.limit, + }); + const files = await Promise.all(archives.map((archive) => DriveFiles.packMany(archive.fileIds))); + return archives.map((archive, index) => ({ + id: archive.id, + createdAt: archive.createdAt.toISOString(), + deletedAt: archive.deletedAt.toISOString(), + text: archive.text, + cw: archive.cw, + fileIds: archive.fileIds, + files: files[index], + textLayers: archive.textLayers, + visibility: archive.visibility, + })); +}); diff --git a/packages/backend/src/server/api/endpoints/memoriet/list.ts b/packages/backend/src/server/api/endpoints/memoriet/list.ts new file mode 100644 index 0000000..42ab694 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/memoriet/list.ts @@ -0,0 +1,112 @@ +import { Brackets } from "typeorm"; +import { Notes, MemorietViews } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import define from "../../define.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; +import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; + +export const meta = { + tags: ["memoriet"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { type: "string", optional: false, nullable: false }, + expiresAt: { type: "string", optional: false, nullable: true }, + textLayers: { + type: "array", + optional: false, + nullable: false, + items: { type: "object", optional: false, nullable: false }, + }, + viewerCount: { type: "number", optional: false, nullable: true }, + note: { type: "object", optional: false, nullable: false, ref: "Note" }, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 50, default: 20 }, + offset: { type: "integer", minimum: 0, default: 0 }, + userId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = Notes.createQueryBuilder("note") + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser") + .innerJoin("memoriet", "memoriet", "memoriet.noteId = note.id") + .addSelect("memoriet.id", "memoriet_id") + .addSelect("memoriet.userId", "memoriet_userId") + .addSelect("memoriet.createdAt", "memoriet_createdAt") + .addSelect("memoriet.expiresAt", "memoriet_expiresAt") + .addSelect("memoriet.textLayers", "memoriet_textLayers") + .andWhere( + new Brackets((qb) => { + qb.where("memoriet.expiresAt IS NULL").orWhere("memoriet.expiresAt > :now", { now: new Date() }); + }), + ); + + if (ps.userId) query.andWhere("note.userId = :userId", { userId: ps.userId }); + generateVisibilityQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + const { entities, raw } = await query + .orderBy("memoriet.createdAt", "DESC") + .skip(ps.offset) + .take(ps.limit) + .getRawAndEntities(); + + if (user) { + const now = new Date(); + for (const row of raw) { + if (row.memoriet_userId === user.id) continue; + await MemorietViews.query( + `INSERT INTO "memoriet_view" ("id", "createdAt", "viewedAt", "memorietId", "viewerId") VALUES ($1, $2, $3, $4, $5) ON CONFLICT ("memorietId", "viewerId") DO UPDATE SET "viewedAt" = EXCLUDED."viewedAt"`, + [genId(), now, now, row.memoriet_id, user.id], + ); + } + } + + const memorietIds = raw.map((row) => row.memoriet_id); + const viewerCounts = new Map(); + if (user && memorietIds.length > 0) { + const counts = await MemorietViews.createQueryBuilder("view") + .select("view.memorietId", "memorietId") + .addSelect("COUNT(*)", "count") + .where("view.memorietId IN (:...memorietIds)", { memorietIds }) + .groupBy("view.memorietId") + .getRawMany(); + for (const count of counts) { + viewerCounts.set(count.memorietId, Number(count.count)); + } + } + + const packed = await Notes.packMany(entities, user); + return packed.map((note, index) => ({ + id: raw[index].memoriet_id, + expiresAt: raw[index].memoriet_expiresAt?.toISOString?.() ?? raw[index].memoriet_expiresAt ?? null, + textLayers: raw[index].memoriet_textLayers ?? [], + viewerCount: user && raw[index].memoriet_userId === user.id ? viewerCounts.get(raw[index].memoriet_id) ?? 0 : null, + note, + })); +}); diff --git a/packages/backend/src/server/api/endpoints/memoriet/repost.ts b/packages/backend/src/server/api/endpoints/memoriet/repost.ts new file mode 100644 index 0000000..dfcdf29 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/memoriet/repost.ts @@ -0,0 +1,93 @@ +import { MemorietArchives, Memoriets, Notes } from "@/models/index.js"; +import { noteVisibilities } from "../../../../types.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { HOUR } from "@/const.js"; +import { createNoteFromApiData } from "@/services/note/create-from-api.js"; +import { genId } from "@/misc/gen-id.js"; + +export const meta = { + tags: ["memoriet"], + requireCredential: true, + limit: { + duration: HOUR, + max: 300, + }, + kind: "write:notes", + res: { + type: "object", + optional: false, + nullable: false, + properties: { + memoriet: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { type: "string", optional: false, nullable: false }, + expiresAt: { type: "string", optional: false, nullable: true }, + textLayers: { type: "array", optional: false, nullable: false, items: { type: "object", optional: false, nullable: false } }, + note: { type: "object", optional: false, nullable: false, ref: "Note" }, + }, + }, + }, + }, + errors: { + noSuchArchive: { + message: "No such Memoriet archive.", + code: "NO_SUCH_MEMORIET_ARCHIVE", + id: "53e0b18f-4f1d-49c0-ad79-ad2944cbe139", + httpStatusCode: 404, + }, + cannotExpireToPast: { + message: "Expiration time must be in the future.", + code: "CANNOT_EXPIRE_TO_PAST", + id: "12ff555a-fb0c-40ed-b253-1aa5d6ed2461", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + archiveId: { type: "string", format: "misskey:id" }, + visibility: { type: "string", enum: noteVisibilities }, + expiresAt: { type: "integer", nullable: true }, + }, + required: ["archiveId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const archive = await MemorietArchives.findOneBy({ id: ps.archiveId, userId: user.id }); + if (!archive) throw new ApiError(meta.errors.noSuchArchive); + const expiresAt = typeof ps.expiresAt === "number" ? new Date(ps.expiresAt) : null; + if (expiresAt != null && expiresAt.getTime() <= Date.now()) { + throw new ApiError(meta.errors.cannotExpireToPast); + } + + const note = await createNoteFromApiData(user, { + text: archive.text ? `${archive.text.trim()}\n#Memoriet` : "#Memoriet", + cw: archive.cw, + fileIds: archive.fileIds, + visibility: ps.visibility ?? archive.visibility, + localOnly: true, + }, new Date()); + + const memoriet = await Memoriets.save({ + id: genId(), + createdAt: new Date(), + userId: user.id, + noteId: note.id, + expiresAt, + textLayers: archive.textLayers, + }); + + return { + memoriet: { + id: memoriet.id, + expiresAt: memoriet.expiresAt?.toISOString() ?? null, + textLayers: memoriet.textLayers, + note: await Notes.pack(note, user), + }, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/memoriet/viewers.ts b/packages/backend/src/server/api/endpoints/memoriet/viewers.ts new file mode 100644 index 0000000..c759da0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/memoriet/viewers.ts @@ -0,0 +1,69 @@ +import { Memoriets, MemorietViews, Users } from "@/models/index.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; + +export const meta = { + tags: ["memoriet"], + + requireCredential: true, + + kind: "read:account", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + viewedAt: { type: "string", optional: false, nullable: false }, + user: { type: "object", optional: false, nullable: false, ref: "User" }, + }, + }, + }, + + errors: { + noSuchMemoriet: { + message: "No such Memoriet.", + code: "NO_SUCH_MEMORIET", + id: "1470626e-d318-4e01-9294-fbfa1260e6c4", + httpStatusCode: 404, + }, + accessDenied: { + message: "You cannot see viewers of this Memoriet.", + code: "ACCESS_DENIED", + id: "d705b05b-23c0-4b93-a5bb-2c303c66db2b", + httpStatusCode: 403, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + memorietId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 50 }, + offset: { type: "integer", minimum: 0, default: 0 }, + }, + required: ["memorietId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const memoriet = await Memoriets.findOneBy({ id: ps.memorietId }); + if (!memoriet) throw new ApiError(meta.errors.noSuchMemoriet); + if (memoriet.userId !== user.id) throw new ApiError(meta.errors.accessDenied); + + const views = await MemorietViews.find({ + where: { memorietId: memoriet.id }, + order: { viewedAt: "DESC" }, + skip: ps.offset, + take: ps.limit, + }); + const users = await Users.packMany(views.map((view) => view.viewerId), user, { detail: false }); + return views.map((view, index) => ({ + viewedAt: view.viewedAt.toISOString(), + user: users[index], + })); +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/history.ts b/packages/backend/src/server/api/endpoints/messaging/history.ts new file mode 100644 index 0000000..7d1df69 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/history.ts @@ -0,0 +1,112 @@ +import { Brackets } from "typeorm"; +import type { MessagingMessage } from "@/models/entities/messaging-message.js"; +import { + MessagingMessages, + Mutings, + UserGroupJoinings, +} from "@/models/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["messaging"], + + requireCredential: true, + + kind: "read:messaging", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "MessagingMessage", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + group: { type: "boolean", default: false }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const mute = await Mutings.findBy({ + muterId: user.id, + }); + + const groups = ps.group + ? await UserGroupJoinings.findBy({ + userId: user.id, + }).then((xs) => xs.map((x) => x.userGroupId)) + : []; + + if (ps.group && groups.length === 0) { + return []; + } + + const history: MessagingMessage[] = []; + + for (let i = 0; i < ps.limit; i++) { + const found = ps.group + ? history.map((m) => m.groupId!) + : history.map((m) => (m.userId === user.id ? m.recipientId! : m.userId!)); + + const query = MessagingMessages.createQueryBuilder("message").orderBy( + "message.createdAt", + "DESC", + ); + + if (ps.group) { + query.where("message.groupId IN (:...groups)", { groups: groups }); + + if (found.length > 0) { + query.andWhere("message.groupId NOT IN (:...found)", { found: found }); + } + } else { + query.where( + new Brackets((qb) => { + qb.where("message.userId = :userId", { userId: user.id }).orWhere( + "message.recipientId = :userId", + { userId: user.id }, + ); + }), + ); + query.andWhere("message.groupId IS NULL"); + + if (found.length > 0) { + query.andWhere("message.userId NOT IN (:...found)", { found: found }); + query.andWhere("message.recipientId NOT IN (:...found)", { + found: found, + }); + } + + if (mute.length > 0) { + query.andWhere("message.userId NOT IN (:...mute)", { + mute: mute.map((m) => m.muteeId), + }); + query.andWhere("message.recipientId NOT IN (:...mute)", { + mute: mute.map((m) => m.muteeId), + }); + } + } + + const message = await query.getOne(); + + if (message) { + history.push(message); + } else { + break; + } + } + + return await Promise.all( + history.map((h) => MessagingMessages.pack(h.id, user)), + ); +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/messages.ts b/packages/backend/src/server/api/endpoints/messaging/messages.ts new file mode 100644 index 0000000..4b54403 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages.ts @@ -0,0 +1,182 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getUser } from "../../common/getters.js"; +import { + MessagingMessages, + UserGroups, + UserGroupJoinings, + Users, +} from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { Brackets } from "typeorm"; +import { + readUserMessagingMessage, + readGroupMessagingMessage, + deliverReadActivity, +} from "../../common/read-messaging-message.js"; + +export const meta = { + tags: ["messaging"], + + requireCredential: true, + + kind: "read:messaging", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "MessagingMessage", + }, + }, + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "11795c64-40ea-4198-b06e-3c873ed9039d", + }, + + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "c4d9f88c-9270-4632-b032-6ed8cee36f7f", + }, + + groupAccessDenied: { + message: "You can not read messages of groups that you have not joined.", + code: "GROUP_ACCESS_DENIED", + id: "a053a8dd-a491-4718-8f87-50775aad9284", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + markAsRead: { type: "boolean", default: true }, + }, + anyOf: [ + { + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], + }, + { + properties: { + groupId: { type: "string", format: "misskey:id" }, + }, + required: ["groupId"], + }, + ], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + if (ps.userId != null) { + // Fetch recipient (user) + const recipient = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const query = makePaginationQuery( + MessagingMessages.createQueryBuilder("message"), + ps.sinceId, + ps.untilId, + ) + .andWhere( + new Brackets((qb) => { + qb.where( + new Brackets((qb) => { + qb.where("message.userId = :meId").andWhere( + "message.recipientId = :recipientId", + ); + }), + ).orWhere( + new Brackets((qb) => { + qb.where("message.userId = :recipientId").andWhere( + "message.recipientId = :meId", + ); + }), + ); + }), + ) + .setParameter("meId", user.id) + .setParameter("recipientId", recipient.id); + + const messages = await query.take(ps.limit).getMany(); + + // Mark all as read + if (ps.markAsRead) { + readUserMessagingMessage( + user.id, + recipient.id, + messages.filter((m) => m.recipientId === user.id).map((x) => x.id), + ); + + // リモートユーザーとのメッセージだったら既読配信 + if (Users.isLocalUser(user) && Users.isRemoteUser(recipient)) { + deliverReadActivity(user, recipient, messages); + } + } + + return await Promise.all( + messages.map((message) => + MessagingMessages.pack(message, user, { + populateRecipient: false, + }), + ), + ); + } else if (ps.groupId != null) { + // Fetch recipient (group) + const recipientGroup = await UserGroups.findOneBy({ id: ps.groupId }); + + if (recipientGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // check joined + const joining = await UserGroupJoinings.findOneBy({ + userId: user.id, + userGroupId: recipientGroup.id, + }); + + if (joining == null) { + throw new ApiError(meta.errors.groupAccessDenied); + } + + const query = makePaginationQuery( + MessagingMessages.createQueryBuilder("message"), + ps.sinceId, + ps.untilId, + ).andWhere("message.groupId = :groupId", { groupId: recipientGroup.id }); + + const messages = await query.take(ps.limit).getMany(); + + // Mark all as read + if (ps.markAsRead) { + readGroupMessagingMessage( + user.id, + recipientGroup.id, + messages.map((x) => x.id), + ); + } + + return await Promise.all( + messages.map((message) => + MessagingMessages.pack(message, user, { + populateGroup: false, + }), + ), + ); + } +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/create.ts b/packages/backend/src/server/api/endpoints/messaging/messages/create.ts new file mode 100644 index 0000000..ed9ae16 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages/create.ts @@ -0,0 +1,165 @@ +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { getUser } from "../../../common/getters.js"; +import { + MessagingMessages, + DriveFiles, + UserGroups, + UserGroupJoinings, + Blockings, +} from "@/models/index.js"; +import type { User } from "@/models/entities/user.js"; +import type { UserGroup } from "@/models/entities/user-group.js"; +import { createMessage } from "@/services/messages/create.js"; + +export const meta = { + tags: ["messaging"], + + requireCredential: true, + + kind: "write:messaging", + + res: { + type: "object", + optional: false, + nullable: false, + ref: "MessagingMessage", + }, + + errors: { + recipientIsYourself: { + message: "You can not send a message to yourself.", + code: "RECIPIENT_IS_YOURSELF", + id: "17e2ba79-e22a-4cbc-bf91-d327643f4a7e", + }, + + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "11795c64-40ea-4198-b06e-3c873ed9039d", + }, + + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "c94e2a5d-06aa-4914-8fa6-6a42e73d6537", + }, + + groupAccessDenied: { + message: "You can not send messages to groups that you have not joined.", + code: "GROUP_ACCESS_DENIED", + id: "d96b3cca-5ad1-438b-ad8b-02f931308fbd", + }, + + noSuchFile: { + message: "No such file.", + code: "NO_SUCH_FILE", + id: "4372b8e2-185d-4146-8749-2f68864a3e5f", + }, + + contentRequired: { + message: "Content required. You need to set text or fileId.", + code: "CONTENT_REQUIRED", + id: "25587321-b0e6-449c-9239-f8925092942c", + }, + + youHaveBeenBlocked: { + message: + "You cannot send a message because you have been blocked by this user.", + code: "YOU_HAVE_BEEN_BLOCKED", + id: "c15a5199-7422-4968-941a-2a462c478f7d", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + text: { type: "string", nullable: true, maxLength: 3000 }, + fileId: { type: "string", format: "misskey:id" }, + }, + anyOf: [ + { + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], + }, + { + properties: { + groupId: { type: "string", format: "misskey:id" }, + }, + required: ["groupId"], + }, + ], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + let recipientUser: User | null; + let recipientGroup: UserGroup | null; + + if (ps.userId != null) { + // Myself + if (ps.userId === user.id) { + throw new ApiError(meta.errors.recipientIsYourself); + } + + // Fetch recipient (user) + recipientUser = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check blocking + const block = await Blockings.findOneBy({ + blockerId: recipientUser.id, + blockeeId: user.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } else if (ps.groupId != null) { + // Fetch recipient (group) + recipientGroup = await UserGroups.findOneBy({ id: ps.groupId! }); + + if (recipientGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // check joined + const joining = await UserGroupJoinings.findOneBy({ + userId: user.id, + userGroupId: recipientGroup.id, + }); + + if (joining == null) { + throw new ApiError(meta.errors.groupAccessDenied); + } + } + + let file = null; + if (ps.fileId != null) { + file = await DriveFiles.findOneBy({ + id: ps.fileId, + userId: user.id, + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if ((ps.text == null || ps.text.trim() === "") && file == null) { + throw new ApiError(meta.errors.contentRequired); + } + + return await createMessage( + user, + recipientUser, + recipientGroup, + ps.text, + file, + ); +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts b/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts new file mode 100644 index 0000000..42ff050 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts @@ -0,0 +1,48 @@ +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { MessagingMessages } from "@/models/index.js"; +import { deleteMessage } from "@/services/messages/delete.js"; +import { SECOND, HOUR } from "@/const.js"; + +export const meta = { + tags: ["messaging"], + + requireCredential: true, + + kind: "write:messaging", + + limit: { + duration: HOUR, + max: 300, + minInterval: SECOND, + }, + + errors: { + noSuchMessage: { + message: "No such message.", + code: "NO_SUCH_MESSAGE", + id: "54b5b326-7925-42cf-8019-130fda8b56af", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + messageId: { type: "string", format: "misskey:id" }, + }, + required: ["messageId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const message = await MessagingMessages.findOneBy({ + id: ps.messageId, + userId: user.id, + }); + + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + + await deleteMessage(message); +}); diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/read.ts b/packages/backend/src/server/api/endpoints/messaging/messages/read.ts new file mode 100644 index 0000000..0ef013b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages/read.ts @@ -0,0 +1,57 @@ +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { MessagingMessages } from "@/models/index.js"; +import { + readUserMessagingMessage, + readGroupMessagingMessage, +} from "../../../common/read-messaging-message.js"; + +export const meta = { + tags: ["messaging"], + + requireCredential: true, + + kind: "write:messaging", + + errors: { + noSuchMessage: { + message: "No such message.", + code: "NO_SUCH_MESSAGE", + id: "86d56a2f-a9c3-4afb-b13c-3e9bfef9aa14", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + messageId: { type: "string", format: "misskey:id" }, + }, + required: ["messageId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const message = await MessagingMessages.findOneBy({ id: ps.messageId }); + + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + + if (message.recipientId) { + await readUserMessagingMessage(user.id, message.userId, [message.id]).catch( + (e) => { + if (e.id === "e140a4bf-49ce-4fb6-b67c-b78dadf6b52f") + throw new ApiError(meta.errors.noSuchMessage); + throw e; + }, + ); + } else if (message.groupId) { + await readGroupMessagingMessage(user.id, message.groupId, [ + message.id, + ]).catch((e) => { + if (e.id === "930a270c-714a-46b2-b776-ad27276dc569") + throw new ApiError(meta.errors.noSuchMessage); + throw e; + }); + } +}); diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts new file mode 100644 index 0000000..94c5534 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/meta.ts @@ -0,0 +1,495 @@ +import JSON5 from "json5"; +import { IsNull, MoreThan } from "typeorm"; +import config from "@/config/index.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { Emojis, Users } from "@/models/index.js"; +import { MAX_NOTE_TEXT_LENGTH, MAX_CAPTION_TEXT_LENGTH } from "@/const.js"; +import define from "../define.js"; + +export const meta = { + tags: ["meta"], + + requireCredential: false, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + maintainerName: { + type: "string", + optional: false, + nullable: true, + }, + maintainerEmail: { + type: "string", + optional: false, + nullable: true, + }, + version: { + type: "string", + optional: false, + nullable: false, + example: config.version, + }, + name: { + type: "string", + optional: false, + nullable: false, + }, + uri: { + type: "string", + optional: false, + nullable: false, + format: "url", + example: "https://iceshrimp.example.com", + }, + domain: { + type: "string", + optional: false, + nullable: false, + format: "domain", + example: "example.com", + }, + description: { + type: "string", + optional: false, + nullable: true, + }, + langs: { + type: "array", + optional: false, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + tosUrl: { + type: "string", + optional: false, + nullable: true, + }, + repositoryUrl: { + type: "string", + optional: false, + nullable: false, + default: "https://iceshrimp.dev/iceshrimp/iceshrimp", + }, + feedbackUrl: { + type: "string", + optional: false, + nullable: false, + default: "https://iceshrimp.dev/iceshrimp/iceshrimp/issues", + }, + defaultDarkTheme: { + type: "string", + optional: false, + nullable: true, + }, + defaultLightTheme: { + type: "string", + optional: false, + nullable: true, + }, + disableRegistration: { + type: "boolean", + optional: false, + nullable: false, + }, + disableLocalTimeline: { + type: "boolean", + optional: false, + nullable: false, + }, + disableRecommendedTimeline: { + type: "boolean", + optional: false, + nullable: false, + }, + disableGlobalTimeline: { + type: "boolean", + optional: false, + nullable: false, + }, + driveCapacityPerLocalUserMb: { + type: "number", + optional: false, + nullable: false, + }, + driveCapacityPerRemoteUserMb: { + type: "number", + optional: false, + nullable: false, + }, + lua4frozenDatabaseCapacityMb: { + type: "number", + optional: false, + nullable: false, + }, + cacheRemoteFiles: { + type: "boolean", + optional: false, + nullable: false, + }, + emailRequiredForSignup: { + type: "boolean", + optional: false, + nullable: false, + }, + enableHcaptcha: { + type: "boolean", + optional: false, + nullable: false, + }, + hcaptchaSiteKey: { + type: "string", + optional: false, + nullable: true, + }, + enableRecaptcha: { + type: "boolean", + optional: false, + nullable: false, + }, + recaptchaSiteKey: { + type: "string", + optional: false, + nullable: true, + }, + swPublickey: { + type: "string", + optional: false, + nullable: true, + }, + mascotImageUrl: { + type: "string", + optional: false, + nullable: false, + default: "/twemoji/1f440.svg", + }, + bannerUrl: { + type: "string", + optional: false, + nullable: false, + }, + errorImageUrl: { + type: "string", + optional: false, + nullable: false, + default: "/twemoji/1f480.svg", + }, + iconUrl: { + type: "string", + optional: false, + nullable: true, + }, + maxNoteTextLength: { + type: "number", + optional: false, + nullable: false, + }, + maxCaptionTextLength: { + type: "number", + optional: false, + nullable: false, + }, + searchEngine: { + type: "string", + optional: false, + nullable: false, + }, + emojis: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + aliases: { + type: "array", + optional: false, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, + category: { + type: "string", + optional: false, + nullable: true, + }, + host: { + type: "string", + optional: false, + nullable: true, + description: "The local host is represented with `null`.", + }, + url: { + type: "string", + optional: false, + nullable: false, + format: "url", + }, + }, + }, + }, + requireSetup: { + type: "boolean", + optional: false, + nullable: false, + example: false, + }, + enableEmail: { + type: "boolean", + optional: false, + nullable: false, + }, + enableGithubIntegration: { + type: "boolean", + optional: false, + nullable: false, + }, + enableDiscordIntegration: { + type: "boolean", + optional: false, + nullable: false, + }, + translatorAvailable: { + type: "boolean", + optional: false, + nullable: false, + }, + images: { + type: 'object', + optional: false, nullable: false, + properties: { + info: { type: 'string' }, + notFound: { type: 'string' }, + error: { type: 'string' }, + }, + }, + features: { + type: "object", + optional: true, + nullable: false, + properties: { + registration: { + type: "boolean", + optional: false, + nullable: false, + }, + localTimeLine: { + type: "boolean", + optional: false, + nullable: false, + }, + recommendedTimeLine: { + type: "boolean", + optional: false, + nullable: false, + }, + globalTimeLine: { + type: "boolean", + optional: false, + nullable: false, + }, + hcaptcha: { + type: "boolean", + optional: false, + nullable: false, + }, + recaptcha: { + type: "boolean", + optional: false, + nullable: false, + }, + objectStorage: { + type: "boolean", + optional: false, + nullable: false, + }, + github: { + type: "boolean", + optional: false, + nullable: false, + }, + discord: { + type: "boolean", + optional: false, + nullable: false, + }, + serviceWorker: { + type: "boolean", + optional: false, + nullable: false, + }, + miauth: { + type: "boolean", + optional: true, + nullable: false, + default: true, + }, + }, + }, + secureMode: { + type: "boolean", + optional: true, + nullable: false, + default: false, + }, + privateMode: { + type: "boolean", + optional: true, + nullable: false, + default: false, + }, + defaultReaction: { + type: "string", + optional: false, + nullable: false, + default: "⭐", + }, + donationLink: { + type: "string", + optional: true, + nullable: true, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + detail: { type: "boolean", default: true }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const instance = await fetchMeta(true); + + const emojis = await Emojis.find({ + where: { + host: IsNull(), + }, + order: { + category: "ASC", + name: "ASC", + }, + cache: { + id: "meta_emojis", + milliseconds: 3600000, // 1 hour + }, + }); + + const response: any = { + maintainerName: instance.maintainerName, + maintainerEmail: instance.maintainerEmail, + + version: config.version, + + name: instance.name, + uri: config.url, + domain: config.domain, + description: instance.description, + langs: instance.langs, + tosUrl: instance.ToSUrl, + repositoryUrl: instance.repositoryUrl, + feedbackUrl: instance.feedbackUrl, + + secureMode: instance.secureMode, + privateMode: instance.privateMode, + + disableRegistration: instance.disableRegistration, + disableLocalTimeline: instance.disableLocalTimeline, + disableRecommendedTimeline: instance.disableRecommendedTimeline, + disableGlobalTimeline: instance.disableGlobalTimeline, + driveCapacityPerLocalUserMb: instance.localDriveCapacityMb, + driveCapacityPerRemoteUserMb: instance.remoteDriveCapacityMb, + lua4frozenDatabaseCapacityMb: instance.lua4frozenDatabaseCapacityMb, + emailRequiredForSignup: instance.emailRequiredForSignup, + enableHcaptcha: instance.enableHcaptcha, + hcaptchaSiteKey: instance.hcaptchaSiteKey, + enableRecaptcha: instance.enableRecaptcha, + recaptchaSiteKey: instance.recaptchaSiteKey, + swPublickey: instance.swPublicKey, + themeColor: instance.themeColor, + mascotImageUrl: instance.mascotImageUrl, + bannerUrl: instance.bannerUrl, + errorImageUrl: instance.errorImageUrl, + iconUrl: instance.iconUrl, + backgroundImageUrl: instance.backgroundImageUrl, + logoImageUrl: instance.logoImageUrl, + maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, // 後方互換性のため + maxCaptionTextLength: MAX_CAPTION_TEXT_LENGTH, + searchEngine: config.searchEngine, + emojis: instance.privateMode && !me ? [] : await Emojis.packMany(emojis), + // クライアントの手間を減らすためあらかじめJSONに変換しておく + defaultLightTheme: instance.defaultLightTheme + ? JSON.stringify(JSON5.parse(instance.defaultLightTheme)) + : null, + defaultDarkTheme: instance.defaultDarkTheme + ? JSON.stringify(JSON5.parse(instance.defaultDarkTheme)) + : null, + + images: config.images, + + enableEmail: instance.enableEmail, + + enableGithubIntegration: instance.enableGithubIntegration, + enableDiscordIntegration: instance.enableDiscordIntegration, + + translatorAvailable: + instance.deeplAuthKey != null || instance.libreTranslateApiUrl != null, + defaultReaction: instance.defaultReaction, + donationLink: instance.donationLink, + + ...(ps.detail + ? { + pinnedPages: instance.privateMode && !me ? [] : instance.pinnedPages, + pinnedClipId: + instance.privateMode && !me ? [] : instance.pinnedClipId, + cacheRemoteFiles: instance.cacheRemoteFiles, + requireSetup: + (await Users.countBy({ + host: IsNull(), + isAdmin: true, + })) === 0, + } + : {}), + }; + + if (ps.detail) { + response.features = { + registration: !instance.disableRegistration, + localTimeLine: !instance.disableLocalTimeline, + recommendedTimeline: !instance.disableRecommendedTimeline, + globalTimeLine: !instance.disableGlobalTimeline, + emailRequiredForSignup: instance.emailRequiredForSignup, + hcaptcha: instance.enableHcaptcha, + recaptcha: instance.enableRecaptcha, + objectStorage: instance.useObjectStorage, + github: instance.enableGithubIntegration, + discord: instance.enableDiscordIntegration, + serviceWorker: true, + postEditing: true, + postImports: instance.experimentalFeatures?.postImports || false, + miauth: true, + }; + } + + return response; +}); diff --git a/packages/backend/src/server/api/endpoints/miauth/gen-token.ts b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts new file mode 100644 index 0000000..721c402 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/miauth/gen-token.ts @@ -0,0 +1,69 @@ +import define from "../../define.js"; +import { AccessTokens } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { secureRndstr } from "@/misc/secure-rndstr.js"; + +export const meta = { + tags: ["auth"], + + requireCredential: true, + + secure: true, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + token: { + type: "string", + optional: false, + nullable: false, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + session: { type: "string", nullable: true }, + name: { type: "string", nullable: true }, + description: { type: "string", nullable: true }, + iconUrl: { type: "string", nullable: true }, + permission: { + type: "array", + uniqueItems: true, + items: { + type: "string", + }, + }, + }, + required: ["session", "permission"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Generate access token + const accessToken = secureRndstr(32); + + const now = new Date(); + + // Insert access token doc + await AccessTokens.insert({ + id: genId(), + createdAt: now, + lastUsedAt: now, + session: ps.session, + userId: user.id, + token: accessToken, + hash: accessToken, + name: ps.name, + description: ps.description, + iconUrl: ps.iconUrl, + permission: ps.permission, + }); + + return { + token: accessToken, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/mute/create.ts b/packages/backend/src/server/api/endpoints/mute/create.ts new file mode 100644 index 0000000..15479ee --- /dev/null +++ b/packages/backend/src/server/api/endpoints/mute/create.ts @@ -0,0 +1,110 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getUser } from "../../common/getters.js"; +import { genId } from "@/misc/gen-id.js"; +import { Mutings, NoteWatchings } from "@/models/index.js"; +import type { Muting } from "@/models/entities/muting.js"; +import { publishUserEvent } from "@/services/stream.js"; +import { getGroupActor } from "../../common/get-group-actor.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + kind: "write:mutes", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "6fef56f3-e765-4957-88e5-c6f65329b8a5", + }, + + muteeIsYourself: { + message: "Mutee is yourself.", + code: "MUTEE_IS_YOURSELF", + id: "a4619cb2-5f23-484b-9301-94c903074e10", + }, + + alreadyMuting: { + message: "You are already muting that user.", + code: "ALREADY_MUTING", + id: "7e7359cb-160c-4956-b08f-4d1c653cd007", + }, + + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "83dd6d0c-c979-49d8-95d4-0ae273ab21af", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + expiresAt: { + type: "integer", + nullable: true, + description: + "A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute.", + }, + groupId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const muter = user; + const group = await getGroupActor(ps.groupId, user); + if (ps.groupId != null && group == null) throw new ApiError(meta.errors.noSuchGroup); + + // 自分自身 + if (group == null && user.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check if already muting + const exist = await Mutings.exist({ + where: { + muteeId: mutee.id, + ...(group ? { groupId: group.id } : { muterId: muter.id, groupId: null }), + }, + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyMuting); + } + + if (ps.expiresAt && ps.expiresAt <= Date.now()) { + return; + } + + // Create mute + await Mutings.insert({ + id: genId(), + createdAt: new Date(), + expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, + muterId: muter.id, + muteeId: mutee.id, + groupId: group?.id ?? null, + } as Muting); + + if (group == null) publishUserEvent(user.id, "mute", mutee); + + if (group == null) { + NoteWatchings.delete({ + userId: muter.id, + noteUserId: mutee.id, + }); + } +}); diff --git a/packages/backend/src/server/api/endpoints/mute/delete.ts b/packages/backend/src/server/api/endpoints/mute/delete.ts new file mode 100644 index 0000000..3db04ba --- /dev/null +++ b/packages/backend/src/server/api/endpoints/mute/delete.ts @@ -0,0 +1,84 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getUser } from "../../common/getters.js"; +import { Mutings } from "@/models/index.js"; +import { publishUserEvent } from "@/services/stream.js"; +import { getGroupActor } from "../../common/get-group-actor.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + kind: "write:mutes", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "b851d00b-8ab1-4a56-8b1b-e24187cb48ef", + }, + + muteeIsYourself: { + message: "Mutee is yourself.", + code: "MUTEE_IS_YOURSELF", + id: "f428b029-6b39-4d48-a1d2-cc1ae6dd5cf9", + }, + + notMuting: { + message: "You are not muting that user.", + code: "NOT_MUTING", + id: "5467d020-daa9-4553-81e1-135c0c35a96d", + }, + + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "124512ed-89b5-4ccf-b044-cd9d0c7f70c9", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + groupId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const muter = user; + const group = await getGroupActor(ps.groupId, user); + if (ps.groupId != null && group == null) throw new ApiError(meta.errors.noSuchGroup); + + // Check if the mutee is yourself + if (group == null && user.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check not muting + const muting = await Mutings.findOneBy({ + muteeId: mutee.id, + ...(group ? { groupId: group.id } : { muterId: muter.id, groupId: null }), + }); + + if (muting == null) { + throw new ApiError(meta.errors.notMuting); + } + + // Delete mute + await Mutings.delete({ + id: muting.id, + }); + + if (group == null) publishUserEvent(user.id, "unmute", mutee); +}); diff --git a/packages/backend/src/server/api/endpoints/mute/list.ts b/packages/backend/src/server/api/endpoints/mute/list.ts new file mode 100644 index 0000000..229107c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/mute/list.ts @@ -0,0 +1,66 @@ +import define from "../../define.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { Mutings } from "@/models/index.js"; +import { ApiError } from "../../error.js"; +import { getGroupActor } from "../../common/get-group-actor.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + kind: "read:mutes", + + errors: { + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "d31b97bc-b1f9-4d7f-9402-52161e9d416f", + }, + }, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Muting", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + groupId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const group = await getGroupActor(ps.groupId, me); + if (ps.groupId != null && group == null) throw new ApiError(meta.errors.noSuchGroup); + + const query = makePaginationQuery( + Mutings.createQueryBuilder("muting"), + ps.sinceId, + ps.untilId, + ); + + if (group) { + query.andWhere("muting.groupId = :groupId", { groupId: group.id }); + } else { + query.andWhere("muting.muterId = :meId", { meId: me.id }); + query.andWhere("muting.groupId IS NULL"); + } + + const mutings = await query.take(ps.limit).getMany(); + + return await Mutings.packMany(mutings, me); +}); diff --git a/packages/backend/src/server/api/endpoints/my/apps.ts b/packages/backend/src/server/api/endpoints/my/apps.ts new file mode 100644 index 0000000..8a097c8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/my/apps.ts @@ -0,0 +1,49 @@ +import define from "../../define.js"; +import { Apps } from "@/models/index.js"; + +export const meta = { + tags: ["account", "app"], + + requireCredential: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "App", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + offset: { type: "integer", default: 0 }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = { + userId: user.id, + }; + + const apps = await Apps.find({ + where: query, + take: ps.limit, + skip: ps.offset, + }); + + return await Promise.all( + apps.map((app) => + Apps.pack(app, user, { + detail: true, + }), + ), + ); +}); diff --git a/packages/backend/src/server/api/endpoints/notes.ts b/packages/backend/src/server/api/endpoints/notes.ts new file mode 100644 index 0000000..1f91858 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes.ts @@ -0,0 +1,85 @@ +import { Notes } from "@/models/index.js"; +import define from "../define.js"; +import { makePaginationQuery } from "../common/make-pagination-query.js"; + +export const meta = { + tags: ["notes"], + + requireCredentialPrivateMode: true, + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + local: { type: "boolean", default: false }, + reply: { type: "boolean" }, + renote: { type: "boolean" }, + withFiles: { type: "boolean" }, + poll: { type: "boolean" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps) => { + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ) + .andWhere("note.visibility = 'public'") + .andWhere("note.localOnly = FALSE") + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser"); + + if (ps.local) { + query.andWhere("note.userHost IS NULL"); + } + + if (ps.reply !== undefined) { + query.andWhere( + ps.reply ? "note.replyId IS NOT NULL" : "note.replyId IS NULL", + ); + } + + if (ps.renote !== undefined) { + query.andWhere( + ps.renote ? "note.renoteId IS NOT NULL" : "note.renoteId IS NULL", + ); + } + + if (ps.withFiles !== undefined) { + query.andWhere( + ps.withFiles ? "note.fileIds != '{}'" : "note.fileIds = '{}'", + ); + } + + if (ps.poll !== undefined) { + query.andWhere(ps.poll ? "note.hasPoll = TRUE" : "note.hasPoll = FALSE"); + } + + // TODO + //if (bot != undefined) { + // query.isBot = bot; + //} + + const notes = await query.take(ps.limit).getMany(); + + return await Notes.packMany(notes); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts new file mode 100644 index 0000000..f0f0256 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -0,0 +1,61 @@ +import { Notes } from "@/models/index.js"; +import define from "../../define.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: false, + requireCredentialPrivateMode: true, + description: "Get threaded/chained replies to a note", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + depth: { type: "integer", minimum: 1, maximum: 100, default: 12 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ) + .andWhere( + "note.id IN (SELECT id FROM note_replies(:noteId, :depth, :limit))", + { noteId: ps.noteId, depth: ps.depth, limit: ps.limit }, + ) + .innerJoinAndSelect("note.user", "user"); + + generateVisibilityQuery(query, user); + if (user) { + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + } + + const notes = await query.getMany(); + + return await Notes.packMany(notes, user, { detail: false }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/clips.ts b/packages/backend/src/server/api/endpoints/notes/clips.ts new file mode 100644 index 0000000..8cbf52f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/clips.ts @@ -0,0 +1,59 @@ +import { In } from "typeorm"; +import { ClipNotes, Clips } from "@/models/index.js"; +import define from "../../define.js"; +import { getNote } from "../../common/getters.js"; +import { ApiError } from "../../error.js"; + +export const meta = { + tags: ["clips", "notes"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Clip", + }, + }, + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "47db1a1c-b0af-458d-8fb4-986e4efafe1e", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const note = await getNote(ps.noteId, me, { allowAdservice: true }).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + const clipNotes = await ClipNotes.findBy({ + noteId: note.id, + }); + + const clips = await Clips.findBy({ + id: In(clipNotes.map((x) => x.clipId)), + isPublic: true, + }); + + return await Promise.all(clips.map((x) => Clips.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/conversation.ts b/packages/backend/src/server/api/endpoints/notes/conversation.ts new file mode 100644 index 0000000..c74da2e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/conversation.ts @@ -0,0 +1,86 @@ +import type { Note } from "@/models/entities/note.js"; +import { Notes } from "@/models/index.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getNote } from "../../common/getters.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: false, + requireCredentialPrivateMode: true, + description: "Get conversation of a note thread/chain by a reply", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "e1035875-9551-45ec-afa8-1ded1fcb53c8", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { + type: "string", + format: "misskey:id", + description: "Should be a reply", + }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + offset: { type: "integer", default: 0 }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId, user).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + const conversation: Note[] = []; + let i = 0; + + async function get(id: string) { + i++; + const p = await getNote(id, user).catch((e) => { + if (e.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") return null; + throw e; + }); + + if (p == null) return; + + if (i > ps.offset) { + conversation.push(p); + } + + if (conversation.length === ps.limit) { + return; + } + + if (p.replyId) { + await get(p.replyId); + } + } + + if (note.replyId) { + await get(note.replyId); + } + + return await Notes.packMany(conversation, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/create.ts b/packages/backend/src/server/api/endpoints/notes/create.ts new file mode 100644 index 0000000..ce942bd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/create.ts @@ -0,0 +1,226 @@ +import { Notes } from "@/models/index.js"; +import { MAX_NOTE_TEXT_LENGTH } from "@/const.js"; +import { noteVisibilities } from "../../../../types.js"; +import { ApiError } from "../../error.js"; +import define from "../../define.js"; +import { HOUR } from "@/const.js"; +import { createNoteFromApiData } from "@/services/note/create-from-api.js"; +import { scheduleNote } from "@/services/note/scheduled.js"; +import { enqueueScheduledNote } from "@/queue/queues/system/scheduled-note.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + + limit: { + duration: HOUR, + max: 300, + }, + + kind: "write:notes", + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + createdNote: { + type: "object", + optional: true, + nullable: false, + ref: "Note", + }, + scheduledNote: { + type: "object", + optional: true, + nullable: false, + properties: { + id: { type: "string", optional: false, nullable: false }, + scheduledAt: { type: "string", optional: false, nullable: false }, + }, + }, + }, + }, + + errors: { + noSuchRenoteTarget: { + message: "No such renote target.", + code: "NO_SUCH_RENOTE_TARGET", + id: "b5c90186-4ab0-49c8-9bba-a1f76c282ba4", + }, + + cannotReRenote: { + message: "You can not Renote a pure Renote.", + code: "CANNOT_RENOTE_TO_A_PURE_RENOTE", + id: "fd4cc33e-2a37-48dd-99cc-9b806eb2031a", + }, + + noSuchReplyTarget: { + message: "No such reply target.", + code: "NO_SUCH_REPLY_TARGET", + id: "749ee0f6-d3da-459a-bf02-282e2da4292c", + }, + + cannotReplyToPureRenote: { + message: "You can not reply to a pure Renote.", + code: "CANNOT_REPLY_TO_A_PURE_RENOTE", + id: "3ac74a84-8fd5-4bb0-870f-01804f82ce15", + }, + + cannotCreateAlreadyExpiredPoll: { + message: "Poll is already expired.", + code: "CANNOT_CREATE_ALREADY_EXPIRED_POLL", + id: "04da457d-b083-4055-9082-955525eda5a5", + }, + + noSuchChannel: { + message: "No such channel.", + code: "NO_SUCH_CHANNEL", + id: "b1653923-5453-4edc-b786-7c4f39bb0bbb", + }, + + youHaveBeenBlocked: { + message: "You have been blocked by this user.", + code: "YOU_HAVE_BEEN_BLOCKED", + id: "b390d7e1-8a5e-46ed-b625-06271cafd3d3", + }, + + accountLocked: { + message: "You migrated. Your account is now locked.", + code: "ACCOUNT_LOCKED", + id: "d390d7e1-8a5e-46ed-b625-06271cafd3d3", + }, + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "7a42ce9c-bdbd-4dcc-8a5d-daf2f520bfaa", + }, + + cannotScheduleToPast: { + message: "Scheduled time must be in the future.", + code: "CANNOT_SCHEDULE_TO_PAST", + id: "181f385d-1f59-42c8-b3a2-b52253a92057", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + visibility: { type: "string", enum: noteVisibilities, default: "public" }, + visibleUserIds: { + type: "array", + uniqueItems: true, + items: { + type: "string", + format: "misskey:id", + }, + }, + text: { type: "string", maxLength: MAX_NOTE_TEXT_LENGTH, nullable: true }, + cw: { type: "string", nullable: true, maxLength: 100 }, + localOnly: { type: "boolean", default: false }, + noExtractMentions: { type: "boolean", default: false }, + noExtractHashtags: { type: "boolean", default: false }, + noExtractEmojis: { type: "boolean", default: false }, + fileIds: { + type: "array", + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: "string", format: "misskey:id" }, + }, + mediaIds: { + deprecated: true, + description: + "Use `fileIds` instead. If both are specified, this property is discarded.", + type: "array", + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: "string", format: "misskey:id" }, + }, + replyId: { type: "string", format: "misskey:id", nullable: true }, + renoteId: { type: "string", format: "misskey:id", nullable: true }, + channelId: { type: "string", format: "misskey:id", nullable: true }, + groupId: { type: "string", format: "misskey:id", nullable: true }, + scheduledAt: { type: "integer", nullable: true }, + poll: { + type: "object", + nullable: true, + properties: { + choices: { + type: "array", + uniqueItems: true, + minItems: 2, + maxItems: 10, + items: { type: "string", minLength: 1, maxLength: 50 }, + }, + multiple: { type: "boolean", default: false }, + expiresAt: { type: "integer", nullable: true }, + expiredAfter: { type: "integer", nullable: true, minimum: 1 }, + }, + required: ["choices"], + }, + }, + anyOf: [ + { + // (re)note with text, files and poll are optional + properties: { + text: { + type: "string", + minLength: 1, + maxLength: MAX_NOTE_TEXT_LENGTH, + nullable: false, + }, + }, + required: ["text"], + }, + { + // (re)note with files, text and poll are optional + required: ["fileIds"], + }, + { + // (re)note with files, text and poll are optional + required: ["mediaIds"], + }, + { + // (re)note with poll, text and files are optional + properties: { + poll: { type: "object", nullable: false }, + }, + required: ["poll"], + }, + { + // pure renote + required: ["renoteId"], + }, + ], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + if (user.movedToUri != null) throw new ApiError(meta.errors.accountLocked); + + if (typeof ps.scheduledAt === "number") { + const scheduledAt = new Date(ps.scheduledAt); + if (scheduledAt.getTime() <= Date.now()) { + throw new ApiError(meta.errors.cannotScheduleToPast); + } + const scheduledNote = await scheduleNote(user, ps, scheduledAt); + await enqueueScheduledNote(scheduledNote).catch((err) => { + console.error("Failed to enqueue scheduled note", err); + }); + return { + scheduledNote: { + id: scheduledNote.id, + scheduledAt: scheduledNote.scheduledAt.toISOString(), + }, + }; + } + + const note = await createNoteFromApiData(user, ps, new Date()); + + return { + createdNote: await Notes.pack(note, user), + }; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/delete.ts b/packages/backend/src/server/api/endpoints/notes/delete.ts new file mode 100644 index 0000000..8afed27 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/delete.ts @@ -0,0 +1,71 @@ +import deleteNote from "@/services/note/delete.js"; +import { Users } from "@/models/index.js"; +import define from "../../define.js"; +import { getNote } from "../../common/getters.js"; +import { ApiError } from "../../error.js"; +import { SECOND, HOUR } from "@/const.js"; +import { getGroupActor } from "../../common/get-group-actor.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + + kind: "write:notes", + + limit: { + duration: HOUR, + max: 300, + minInterval: SECOND, + }, + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "490be23f-8c1f-4796-819f-94cb4f9d1630", + }, + + accessDenied: { + message: "Access denied.", + code: "ACCESS_DENIED", + id: "fe8d7103-0ea8-4ec3-814d-f8b401dc69e9", + }, + + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "4dc5f42e-ae8b-4b9c-88df-4f3bc0d87a9a", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + groupId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId, user).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + const group = await getGroupActor(ps.groupId, user); + if (ps.groupId != null && group == null) throw new ApiError(meta.errors.noSuchGroup); + + if ( + !(user.isAdmin || user.isModerator) && + (group == null ? note.userId !== user.id : note.groupId !== group.id) + ) { + throw new ApiError(meta.errors.accessDenied); + } + + // この操作を行うのが投稿者とは限らない(例えばモデレーター)ため + await deleteNote(await Users.findOneByOrFail({ id: note.userId }), note); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/edit.ts b/packages/backend/src/server/api/endpoints/notes/edit.ts new file mode 100644 index 0000000..1e025a6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/edit.ts @@ -0,0 +1,200 @@ +import { Users, DriveFiles, Notes } from "@/models/index.js"; +import type { DriveFile } from "@/models/entities/drive-file.js"; +import { MAX_NOTE_TEXT_LENGTH } from "@/const.js"; +import { ApiError } from "../../error.js"; +import define from "../../define.js"; +import { HOUR } from "@/const.js"; +import editNote from "@/services/note/edit.js" +import { Packed } from "@/misc/schema.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + + limit: { + duration: HOUR, + max: 300, + }, + + kind: "write:notes", + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Note" + }, + + errors: { + cannotCreateAlreadyExpiredPoll: { + message: "Poll is already expired.", + code: "CANNOT_CREATE_ALREADY_EXPIRED_POLL", + id: "04da457d-b083-4055-9082-955525eda5a5", + }, + + accountLocked: { + message: "You migrated. Your account is now locked.", + code: "ACCOUNT_LOCKED", + id: "d390d7e1-8a5e-46ed-b625-06271cafd3d3", + }, + + needsEditId: { + message: "You need to specify `editId`.", + code: "NEEDS_EDIT_ID", + id: "d697edc8-8c73-4de8-bded-35fd198b79e5", + }, + + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "eef6c173-3010-4a23-8674-7c4fcaeba719", + }, + + youAreNotTheAuthor: { + message: "You are not the author of this note.", + code: "YOU_ARE_NOT_THE_AUTHOR", + id: "c6e61685-411d-43d0-b90a-a448d2539001", + }, + + notLocalUser: { + message: "You are not a local user.", + code: "NOT_LOCAL_USER", + id: "b907f407-2aa0-4283-800b-a2c56290b822", + }, + + cannotAddPollToPost: { + message: "You cannot add a poll to a post.", + code: "CANNOT_ADD_POLL", + id: "e6a00055-21c8-4d69-a4d3-c26731cf6f18", + }, + + cannotRemovePollFromPost: { + message: "You cannot remove a poll from a post.", + code: "CANNOT_REMOVE_POLL", + id: "7c4889f1-cb50-4a36-a740-9d04f097479b", + }, + } +} as const; + +export const paramDef = { + type: "object", + properties: { + editId: { type: "string", format: "misskey:id" }, + text: { type: "string", maxLength: MAX_NOTE_TEXT_LENGTH, nullable: true }, + cw: { type: "string", nullable: true, maxLength: 250 }, + fileIds: { + type: "array", + uniqueItems: true, + minItems: 1, + maxItems: 16, + items: { type: "string", format: "misskey:id" }, + }, + poll: { + type: "object", + nullable: true, + properties: { + choices: { + type: "array", + uniqueItems: true, + minItems: 2, + maxItems: 10, + items: { type: "string", minLength: 1, maxLength: 50 }, + }, + multiple: { type: "boolean", default: false }, + expiresAt: { type: "integer", nullable: true }, + expiredAfter: { type: "integer", nullable: true, minimum: 1 }, + }, + required: ["choices"], + }, + }, + anyOf: [ + { + // note with text, files and poll are optional + properties: { + text: { + type: "string", + minLength: 1, + maxLength: MAX_NOTE_TEXT_LENGTH, + nullable: false, + }, + }, + required: ["text"], + }, + { + // note with files, text and poll are optional + required: ["fileIds"], + }, + { + // note with poll, text and files are optional + properties: { + poll: { type: "object", nullable: false }, + }, + required: ["poll"], + }, + ], +} as const; + +export default define(meta, paramDef, async (ps, user): Promise> => { + if (user.movedToUri != null) throw new ApiError(meta.errors.accountLocked); + + if (!Users.isLocalUser(user)) { + throw new ApiError(meta.errors.notLocalUser); + } + + if (!ps.editId) { + throw new ApiError(meta.errors.needsEditId); + } + + let note = await Notes.findOneBy({ + id: ps.editId, + }); + + if (!note) { + throw new ApiError(meta.errors.noSuchNote); + } + + if (note.userId !== user.id) { + throw new ApiError(meta.errors.youAreNotTheAuthor); + } + + if (ps.poll?.expiresAt && new Date(ps.poll.expiresAt).getTime() < new Date().getTime()) { + throw new ApiError(meta.errors.cannotCreateAlreadyExpiredPoll); + } + + if (!note.hasPoll && ps.poll) { + throw new ApiError(meta.errors.cannotAddPollToPost); + } + + if (note.hasPoll && !ps.poll) { + throw new ApiError(meta.errors.cannotRemovePollFromPost); + } + + let files: DriveFile[] = []; + const fileIds = ps.fileIds ?? null; + if (fileIds != null) { + files = await DriveFiles.createQueryBuilder("file") + .where("file.userId = :userId AND file.id IN (:...fileIds)", { + userId: user.id, + fileIds, + }) + .orderBy('array_position(ARRAY[:...fileIds], "id"::text)') + .setParameters({ fileIds }) + .getMany(); + } + + note = await editNote(user, note, { + text: ps.text, + cw: ps.cw, + poll: ps.poll + ? { + choices: ps.poll.choices, + multiple: ps.poll.multiple, + expiresAt: ps.poll.expiresAt ? new Date(ps.poll.expiresAt) : null, + } + : null, + files: files + }); + + return Notes.pack(note, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/create.ts b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts new file mode 100644 index 0000000..17adb15 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/favorites/create.ts @@ -0,0 +1,76 @@ +import { NoteFavorites } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { getNote } from "../../../common/getters.js"; +import { getGroupActor } from "../../../common/get-group-actor.js"; + +export const meta = { + tags: ["notes", "favorites"], + + requireCredential: true, + + kind: "write:favorites", + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "6dd26674-e060-4816-909a-45ba3f4da458", + }, + + alreadyFavorited: { + message: "The note has already been marked as a favorite.", + code: "ALREADY_FAVORITED", + id: "a402c12b-34dd-41d2-97d8-4d2ffd96a1a6", + }, + + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "02578569-44a6-4761-a112-d60dfc8764b3", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + groupId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Get favoritee + const note = await getNote(ps.noteId, user).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + const group = await getGroupActor(ps.groupId, user); + if (ps.groupId != null && group == null) throw new ApiError(meta.errors.noSuchGroup); + + // if already favorited + const exist = await NoteFavorites.exist({ + where: { + noteId: note.id, + ...(group ? { groupId: group.id } : { userId: user.id, groupId: null }), + }, + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyFavorited); + } + + // Create favorite + await NoteFavorites.insert({ + id: genId(), + createdAt: new Date(), + noteId: note.id, + userId: user.id, + groupId: group?.id ?? null, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts b/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts new file mode 100644 index 0000000..3ee86e1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/favorites/delete.ts @@ -0,0 +1,67 @@ +import { NoteFavorites } from "@/models/index.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { getNote } from "../../../common/getters.js"; +import { getGroupActor } from "../../../common/get-group-actor.js"; + +export const meta = { + tags: ["notes", "favorites"], + + requireCredential: true, + + kind: "write:favorites", + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "80848a2c-398f-4343-baa9-df1d57696c56", + }, + + notFavorited: { + message: "You have not marked that note a favorite.", + code: "NOT_FAVORITED", + id: "b625fc69-635e-45e9-86f4-dbefbef35af5", + }, + + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "76417d51-a8c7-47d5-b7e2-28d89f0d438e", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + groupId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Get favoritee + const note = await getNote(ps.noteId, user).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + const group = await getGroupActor(ps.groupId, user); + if (ps.groupId != null && group == null) throw new ApiError(meta.errors.noSuchGroup); + + // if already favorited + const favorite = await NoteFavorites.findOneBy({ + noteId: note.id, + ...(group ? { groupId: group.id } : { userId: user.id, groupId: null }), + }); + + if (favorite == null) { + throw new ApiError(meta.errors.notFavorited); + } + + // Delete favorite + await NoteFavorites.delete(favorite.id); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/featured.ts b/packages/backend/src/server/api/endpoints/notes/featured.ts new file mode 100644 index 0000000..04a36f9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/featured.ts @@ -0,0 +1,76 @@ +import { Notes } from "@/models/index.js"; +import define from "../../define.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + offset: { type: "integer", default: 0 }, + origin: { + type: "string", + enum: ["combined", "local", "remote"], + default: "local", + }, + days: { type: "integer", minimum: 1, maximum: 365, default: 3 }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const max = 30; + const day = 1000 * 60 * 60 * 24 * ps.days; + + const query = Notes.createQueryBuilder("note") + .addSelect("note.score") + .andWhere("note.score > 0") + .andWhere("note.createdAt > :date", { date: new Date(Date.now() - day) }) + .andWhere("note.visibility = 'public'") + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser"); + + switch (ps.origin) { + case "local": + query.andWhere("note.userHost IS NULL"); + break; + case "remote": + query.andWhere("note.userHost IS NOT NULL"); + break; + } + + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + let notes = await query.orderBy("note.score", "DESC").take(max).getMany(); + + notes.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + notes = notes.slice(ps.offset, ps.offset + ps.limit); + + return await Notes.packMany(notes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts new file mode 100644 index 0000000..33b4ffb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -0,0 +1,118 @@ +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { Notes } from "@/models/index.js"; +import { activeUsersChart } from "@/services/chart/index.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { generateRepliesQuery } from "../../common/generate-replies-query.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; +import { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js"; +import { generateExcludeMemorietQuery } from "@/server/api/common/generate-exclude-memoriet-query.js"; +import { injectPromo } from "../../common/inject-promo.js"; + +export const meta = { + tags: ["notes"], + + requireCredentialPrivateMode: true, + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, + + errors: { + gtlDisabled: { + message: "Global timeline has been disabled.", + code: "GTL_DISABLED", + id: "0332fc13-6ab2-4427-ae80-a9fadffd1a6b", + }, + queryError: { + message: "Please follow more users.", + code: "QUERY_ERROR", + id: "620763f4-f621-4533-ab33-0577a1a3c343", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + withFiles: { + type: "boolean", + default: false, + description: "Only show notes that have attached files.", + }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + sinceDate: { type: "integer" }, + untilDate: { type: "integer" }, + withReplies: { + type: "boolean", + default: false, + description: "Show replies in the timeline", + }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const m = await fetchMeta(); + if (m.disableGlobalTimeline) { + if (user == null || !(user.isAdmin || user.isModerator)) { + throw new ApiError(meta.errors.gtlDisabled); + } + } + + //#region Construct query + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ps.sinceDate, + ps.untilDate, + ) + .andWhere("note.visibility = 'public'") + .andWhere("note.channelId IS NULL") + .andWhere(`NOT ('adservice' = ANY(note.tags))`) + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser"); + + generateRepliesQuery(query, ps.withReplies, user); + if (user) { + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + generateMutedUserRenotesQueryForNotes(query, user); + } + generateExcludeMemorietQuery(query); + + if (ps.withFiles) { + query.andWhere("note.fileIds != '{}'"); + } + query.andWhere("note.visibility != 'hidden'"); + //#endregion + + process.nextTick(() => { + if (user) { + activeUsersChart.read(user); + } + }); + + try { + const notes = await query.take(ps.limit).getMany(); + await injectPromo(notes, user); + return await Notes.packMany(notes, user); + } catch (error) { + throw new ApiError(meta.errors.queryError); + } +}); diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts new file mode 100644 index 0000000..bf517f0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -0,0 +1,176 @@ +import { Brackets } from "typeorm"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { Followings, Notes } from "@/models/index.js"; +import { activeUsersChart } from "@/services/chart/index.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { generateRepliesQuery } from "../../common/generate-replies-query.js"; +import { generateChannelQuery } from "../../common/generate-channel-query.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; +import { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js"; +import { generateListQuery } from "@/server/api/common/generate-list-query.js"; +import { generateExcludeMemorietQuery } from "@/server/api/common/generate-exclude-memoriet-query.js"; +import { injectPromo } from "../../common/inject-promo.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, + + errors: { + stlDisabled: { + message: "Hybrid timeline has been disabled.", + code: "STL_DISABLED", + id: "620763f4-f621-4533-ab33-0577a1a3c342", + }, + queryError: { + message: "Please follow more users.", + code: "QUERY_ERROR", + id: "620763f4-f621-4533-ab33-0577a1a3c343", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + sinceDate: { type: "integer" }, + untilDate: { type: "integer" }, + includeMyRenotes: { type: "boolean", default: true }, + includeRenotedMyNotes: { type: "boolean", default: true }, + includeLocalRenotes: { type: "boolean", default: true }, + withFiles: { + type: "boolean", + default: false, + description: "Only show notes that have attached files.", + }, + withReplies: { + type: "boolean", + default: false, + description: "Show replies in the timeline", + }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const m = await fetchMeta(); + if (m.disableLocalTimeline && !user.isAdmin && !user.isModerator) { + throw new ApiError(meta.errors.stlDisabled); + } + + //#region Construct query + const followingQuery = Followings.createQueryBuilder("following") + .select("following.followeeId") + .where("following.followerId = :followerId", { followerId: user.id }); + + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ps.sinceDate, + ps.untilDate, + ) + .andWhere( + new Brackets((qb) => { + qb.where( + `((note.userId IN (${followingQuery.getQuery()})) OR (note.userId = :meId))`, + { meId: user.id }, + ).orWhere("(note.visibility = 'public') AND (note.userHost IS NULL)"); + }), + ) + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser") + .setParameters(followingQuery.getParameters()); + + generateListQuery(query, user); + generateChannelQuery(query, user); + generateRepliesQuery(query, ps.withReplies, user); + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + generateMutedUserRenotesQueryForNotes(query, user); + generateExcludeMemorietQuery(query); + + if (ps.includeMyRenotes === false) { + query.andWhere( + new Brackets((qb) => { + qb.orWhere("note.userId != :meId", { meId: user.id }); + qb.orWhere("note.renoteId IS NULL"); + qb.orWhere("note.text IS NOT NULL"); + qb.orWhere("note.fileIds != '{}'"); + qb.orWhere( + '0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)', + ); + }), + ); + } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere( + new Brackets((qb) => { + qb.orWhere("note.renoteUserId != :meId", { meId: user.id }); + qb.orWhere("note.renoteId IS NULL"); + qb.orWhere("note.text IS NOT NULL"); + qb.orWhere("note.fileIds != '{}'"); + qb.orWhere( + '0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)', + ); + }), + ); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere( + new Brackets((qb) => { + qb.orWhere("note.renoteUserHost IS NOT NULL"); + qb.orWhere("note.renoteId IS NULL"); + qb.orWhere("note.text IS NOT NULL"); + qb.orWhere("note.fileIds != '{}'"); + qb.orWhere( + '0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)', + ); + }), + ); + } + + if (ps.withFiles) { + query.andWhere("note.fileIds != '{}'"); + } + + query.andWhere("note.visibility != 'hidden'"); + //#endregion + + process.nextTick(() => { + activeUsersChart.read(user); + }); + + try { + const notes = await query.take(ps.limit).getMany(); + await injectPromo(notes, user); + return await Notes.packMany(notes, user); + } catch (error) { + throw new ApiError(meta.errors.queryError); + } +}); diff --git a/packages/backend/src/server/api/endpoints/notes/increment-service-view.ts b/packages/backend/src/server/api/endpoints/notes/increment-service-view.ts new file mode 100644 index 0000000..14374ef --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/increment-service-view.ts @@ -0,0 +1,70 @@ +import { Notes } from "@/models/index.js"; +import { MINUTE } from "@/const.js"; +import { getIpHash } from "@/misc/get-ip-hash.js"; +import define from "../../define.js"; +import { getNote } from "../../common/getters.js"; +import { ApiError } from "../../error.js"; +import { limiter } from "../../limiter.js"; + +const serviceTags = { + video: "videoservice", + audio: "audioservice", + lua4frozen: "lua4frozen", + karaoke: "karaokeservice", +} as const; + +export const meta = { + tags: ["notes"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "438ff599-8b8b-45d1-a72d-7e94d3476bd6", + httpStatusCode: 404, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + service: { + type: "string", + enum: ["video", "audio", "lua4frozen", "karaoke"], + }, + }, + required: ["noteId", "service"], +} as const; + +export default define(meta, paramDef, async (ps, user, token, file, cleanup, ip) => { + const note = await getNote(ps.noteId, user, { allowAdservice: true }).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + if (!note.tags.includes(serviceTags[ps.service])) { + throw new ApiError(meta.errors.noSuchNote); + } + + const actor = user ? user.id : getIpHash(ip ?? ""); + const counted = await limiter({ + key: `service-view:${ps.service}:${note.id}`, + duration: MINUTE, + max: 1, + }, actor).then( + () => true, + () => false, + ); + + if (counted) { + await Notes.increment({ id: note.id }, "viewCount", 1); + } + + return { counted }; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/karaoke-service-search.ts b/packages/backend/src/server/api/endpoints/notes/karaoke-service-search.ts new file mode 100644 index 0000000..7d9f368 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/karaoke-service-search.ts @@ -0,0 +1,186 @@ +import { Brackets } from "typeorm"; +import { Notes } from "@/models/index.js"; +import define from "../../define.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; + +type KaraokeRole = + | "accompaniment" + | "lyrics" + | "pitch" + | "scoring" + | "video" + | "info" + | null; + +type FileLike = { + name: string; + type: string; + comment?: string | null; +}; + +function hasTag(note: { tags?: string[] }, tag: string): boolean { + return note.tags?.some((x) => x.toLowerCase() === tag.toLowerCase()) ?? false; +} + +function searchableText(file: FileLike): string { + return `${file.name} ${file.comment ?? ""}`.toLowerCase(); +} + +function isMidiFile(file: FileLike): boolean { + const text = searchableText(file); + const type = file.type.toLowerCase(); + return ( + text.endsWith(".mid") || + text.endsWith(".midi") || + type === "audio/midi" || + type === "audio/mid" || + type === "audio/x-midi" || + type === "audio/x-mid" || + type === "application/midi" || + type === "application/x-midi" + ); +} + +export function classifyKaraokeFile(file: FileLike): KaraokeRole { + const text = searchableText(file); + if (file.type.startsWith("video/")) return "video"; + if (isMidiFile(file)) return "pitch"; + if (text.includes("score") || text.includes("scoring") || text.includes("採点")) return "scoring"; + if ( + text.includes("pitch") || + text.includes("tempo") || + text.includes("melody") || + text.includes("part") || + text.includes("音程") || + text.includes("テンポ") || + text.includes("パート") + ) return "pitch"; + if ( + text.includes("lyrics") || + text.includes("lyric") || + text.endsWith(".lrc") || + text.includes("歌詞") + ) return "lyrics"; + if (file.type.startsWith("audio/")) return "accompaniment"; + if ( + file.type.startsWith("text/") && + (text.includes("info") || + text.includes("metadata") || + text.includes("song") || + text.includes("楽曲情報")) + ) return "info"; + return null; +} + +function isKaraokeServiceNote(note: { + visibility: string; + tags?: string[]; + files?: FileLike[]; +}): boolean { + if (!["public", "home", "followers"].includes(note.visibility)) return false; + if (!hasTag(note, "KaraokeService")) return false; + + const counts: Record, number> = { + accompaniment: 0, + lyrics: 0, + pitch: 0, + scoring: 0, + video: 0, + info: 0, + }; + + const fallbackDataFiles: FileLike[] = []; + for (const file of note.files ?? []) { + const role = classifyKaraokeFile(file); + if (role == null) { + fallbackDataFiles.push(file); + continue; + } + counts[role]++; + } + + for (const role of ["lyrics", "pitch", "scoring"] as const) { + if (counts[role] === 0 && fallbackDataFiles.length > 0) { + fallbackDataFiles.shift(); + counts[role]++; + } + } + + return ( + counts.accompaniment === 1 && + counts.lyrics === 1 && + counts.pitch === 1 && + counts.scoring === 1 && + counts.video <= 1 && + counts.info <= 1 && + fallbackDataFiles.length === 0 + ); +} + +export const meta = { + tags: ["notes"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + query: { type: "string", default: "" }, + limit: { type: "integer", minimum: 1, maximum: 50, default: 20 }, + offset: { type: "integer", minimum: 0, default: 0 }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const queryText = ps.query.trim(); + + const query = Notes.createQueryBuilder("note") + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser") + .where(`'{"karaokeservice"}' <@ note.tags`) + .andWhere("note.fileIds != '{}'"); + + generateVisibilityQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + if (queryText.length > 0) { + query.andWhere( + new Brackets((qb) => { + qb.where("note.text ILIKE :query", { query: `%${queryText}%` }).orWhere( + "user.usernameLower ILIKE :query", + { query: `%${queryText.toLowerCase()}%` }, + ); + }), + ); + } + + const notes = await query + .orderBy("note.createdAt", "DESC") + .skip(ps.offset) + .take(ps.limit * 4) + .getMany(); + + const packed = await Notes.packMany(notes, user); + return packed.filter((note) => isKaraokeServiceNote(note)).slice(0, ps.limit); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts new file mode 100644 index 0000000..c369d24 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -0,0 +1,146 @@ +import { Brackets } from "typeorm"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { Notes, Users } from "@/models/index.js"; +import { activeUsersChart } from "@/services/chart/index.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { generateRepliesQuery } from "../../common/generate-replies-query.js"; +import { generateChannelQuery } from "../../common/generate-channel-query.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; +import { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js"; +import { generateExcludeMemorietQuery } from "@/server/api/common/generate-exclude-memoriet-query.js"; +import { injectPromo } from "../../common/inject-promo.js"; + +export const meta = { + tags: ["notes"], + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, + + errors: { + ltlDisabled: { + message: "Local timeline has been disabled.", + code: "LTL_DISABLED", + id: "45a6eb02-7695-4393-b023-dd3be9aaaefd", + }, + queryError: { + message: "Please follow more users.", + code: "QUERY_ERROR", + id: "620763f4-f621-4533-ab33-0577a1a3c343", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + withFiles: { + type: "boolean", + default: false, + description: "Only show notes that have attached files.", + }, + fileType: { + type: "array", + items: { + type: "string", + }, + }, + excludeNsfw: { type: "boolean", default: false }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + sinceDate: { type: "integer" }, + untilDate: { type: "integer" }, + withReplies: { + type: "boolean", + default: false, + description: "Show replies in the timeline", + }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const m = await fetchMeta(); + if (m.disableLocalTimeline) { + if (user == null || !(user.isAdmin || user.isModerator)) { + throw new ApiError(meta.errors.ltlDisabled); + } + } + + //#region Construct query + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ps.sinceDate, + ps.untilDate, + ) + .andWhere("note.visibility = 'public'") + .andWhere("note.userHost IS NULL") + .andWhere(`NOT ('adservice' = ANY(note.tags))`) + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser"); + + generateChannelQuery(query, user); + generateRepliesQuery(query, ps.withReplies, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + if (user) generateMutedUserRenotesQueryForNotes(query, user); + generateExcludeMemorietQuery(query); + + if (ps.withFiles) { + query.andWhere("note.fileIds != '{}'"); + } + + if (ps.fileType != null) { + query.andWhere("note.fileIds != '{}'"); + query.andWhere( + new Brackets((qb) => { + for (const type of ps.fileType!) { + const i = ps.fileType!.indexOf(type); + qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { + [`type${i}`]: type, + }); + } + }), + ); + + if (ps.excludeNsfw) { + query.andWhere("note.cw IS NULL"); + query.andWhere( + '0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)', + ); + } + } + //#endregion + + process.nextTick(() => { + if (user) { + activeUsersChart.read(user); + } + }); + + try { + const notes = await query.take(ps.limit).getMany(); + await injectPromo(notes, user); + return await Notes.packMany(notes, user); + } catch (error) { + throw new ApiError(meta.errors.queryError); + } +}); diff --git a/packages/backend/src/server/api/endpoints/notes/lua4frozen-search.ts b/packages/backend/src/server/api/endpoints/notes/lua4frozen-search.ts new file mode 100644 index 0000000..d4a9df1 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/lua4frozen-search.ts @@ -0,0 +1,67 @@ +import { Brackets, In } from "typeorm"; +import { Notes } from "@/models/index.js"; +import define from "../../define.js"; + +function hasLuaFile(note: { files?: { name: string }[] }): boolean { + return note.files?.some((file) => file.name.toLowerCase().endsWith(".lua")) ?? false; +} + +export const meta = { + tags: ["notes"], + requireCredential: false, + requireCredentialPrivateMode: true, + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + query: { type: "string", default: "" }, + limit: { type: "integer", minimum: 1, maximum: 50, default: 20 }, + offset: { type: "integer", minimum: 0, default: 0 }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const queryText = ps.query.trim(); + const query = Notes.createQueryBuilder("note") + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser") + .where({ visibility: In(["public", "home"]) }) + .andWhere("'{\"lua4frozen\"}' <@ note.tags") + .andWhere("note.fileIds != '{}'"); + + if (queryText.length > 0) { + query.andWhere( + new Brackets((qb) => { + qb.where("note.text ILIKE :query", { query: `%${queryText}%` }).orWhere( + "user.usernameLower ILIKE :query", + { query: `%${queryText.toLowerCase()}%` }, + ); + }), + ); + } + + const notes = await query + .orderBy("note.createdAt", "DESC") + .skip(ps.offset) + .take(ps.limit * 3) + .getMany(); + + const packed = await Notes.packMany(notes, user); + return packed.filter((note) => hasLuaFile(note)).slice(0, ps.limit); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/media-service-search.ts b/packages/backend/src/server/api/endpoints/notes/media-service-search.ts new file mode 100644 index 0000000..09cc42a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/media-service-search.ts @@ -0,0 +1,117 @@ +import { Brackets } from "typeorm"; +import { Notes } from "@/models/index.js"; +import define from "../../define.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; + +function hasTag(note: { tags?: string[] }, tag: string): boolean { + return note.tags?.some((x) => x.toLowerCase() === tag.toLowerCase()) ?? false; +} + +function mediaServiceMatches( + note: { visibility: string; tags?: string[]; files?: { type: string }[] }, + kind: "video" | "audio" | "image", +): boolean { + const serviceTag = + kind === "video" + ? "VideoService" + : kind === "audio" + ? "AudioService" + : "ImageService"; + return ( + ["public", "home", "followers"].includes(note.visibility) && + hasTag(note, serviceTag) && + hasValidMediaFiles(note.files ?? [], kind) + ); +} + +function hasValidMediaFiles( + files: { type: string }[], + kind: "video" | "audio" | "image", +): boolean { + const mediaFiles = files.filter((file) => file.type.startsWith(`${kind}/`)); + if (kind === "image") { + return mediaFiles.length >= 1 && files.every((file) => file.type.startsWith("image/")); + } + + return ( + mediaFiles.length >= 1 && + files.every( + (file) => file.type.startsWith(`${kind}/`) || file.type.startsWith("image/"), + ) + ); +} + +export const meta = { + tags: ["notes"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + kind: { type: "string", enum: ["video", "audio", "image"] }, + query: { type: "string", default: "" }, + limit: { type: "integer", minimum: 1, maximum: 50, default: 20 }, + offset: { type: "integer", minimum: 0, default: 0 }, + }, + required: ["kind"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const serviceTag = + ps.kind === "video" + ? "videoservice" + : ps.kind === "audio" + ? "audioservice" + : "imageservice"; + const queryText = ps.query.trim(); + + const query = Notes.createQueryBuilder("note") + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser") + .where(`'{"${serviceTag}"}' <@ note.tags`) + .andWhere("note.fileIds != '{}'"); + + generateVisibilityQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + if (queryText.length > 0) { + query.andWhere( + new Brackets((qb) => { + qb.where("note.text ILIKE :query", { query: `%${queryText}%` }).orWhere( + "user.usernameLower ILIKE :query", + { query: `%${queryText.toLowerCase()}%` }, + ); + }), + ); + } + + const notes = await query + .orderBy("note.createdAt", "DESC") + .skip(ps.offset) + .take(ps.limit * 3) + .getMany(); + + const packed = await Notes.packMany(notes, user); + return packed.filter((note) => mediaServiceMatches(note, ps.kind)).slice(0, ps.limit); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts new file mode 100644 index 0000000..2ccafd2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -0,0 +1,89 @@ +import { Brackets } from "typeorm"; +import read from "@/services/note/read.js"; +import { Notes, Followings } from "@/models/index.js"; +import define from "../../define.js"; +import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; +import { generateMutedNoteThreadQuery } from "../../common/generate-muted-note-thread-query.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + following: { type: "boolean", default: false }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + visibility: { type: "string" }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const followingQuery = Followings.createQueryBuilder("following") + .select("following.followeeId") + .where("following.followerId = :followerId", { followerId: user.id }); + + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ) + .andWhere( + new Brackets((qb) => { + qb.where(`'{"${user.id}"}' <@ note.mentions`).orWhere( + `'{"${user.id}"}' <@ note.visibleUserIds`, + ); + }), + ) + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser"); + + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateMutedNoteThreadQuery(query, user); + generateBlockedUserQuery(query, user); + + if (ps.visibility) { + query.andWhere("note.visibility = :visibility", { + visibility: ps.visibility, + }); + } + + if (ps.following) { + query.andWhere( + `((note.userId IN (${followingQuery.getQuery()})) OR (note.userId = :meId))`, + { meId: user.id }, + ); + query.setParameters(followingQuery.getParameters()); + } + + const notes = await query.take(ps.limit).getMany(); + const packed = await Notes.packMany(notes, user); + + read(user.id, packed); + + return packed; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts new file mode 100644 index 0000000..fcd24db --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts @@ -0,0 +1,85 @@ +import { Brackets, In } from "typeorm"; +import { Polls, Mutings, Notes, PollVotes } from "@/models/index.js"; +import define from "../../../define.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + offset: { type: "integer", default: 0 }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = Polls.createQueryBuilder("poll") + .where("poll.userHost IS NULL") + .andWhere("poll.userId != :meId", { meId: user.id }) + .andWhere("poll.noteVisibility = 'public'") + .andWhere( + new Brackets((qb) => { + qb.where("poll.expiresAt IS NULL").orWhere("poll.expiresAt > :now", { + now: new Date(), + }); + }), + ); + + //#region exclude arleady voted polls + const votedQuery = PollVotes.createQueryBuilder("vote") + .select("vote.noteId") + .where("vote.userId = :meId", { meId: user.id }); + + query.andWhere(`poll.noteId NOT IN (${votedQuery.getQuery()})`); + + query.setParameters(votedQuery.getParameters()); + //#endregion + + //#region mute + const mutingQuery = Mutings.createQueryBuilder("muting") + .select("muting.muteeId") + .where("muting.muterId = :muterId", { muterId: user.id }); + + query.andWhere(`poll.userId NOT IN (${mutingQuery.getQuery()})`); + + query.setParameters(mutingQuery.getParameters()); + //#endregion + + const polls = await query + .orderBy("poll.noteId", "DESC") + .take(ps.limit) + .skip(ps.offset) + .getMany(); + + if (polls.length === 0) return []; + + const notes = await Notes.find({ + where: { + id: In(polls.map((poll) => poll.noteId)), + }, + order: { + createdAt: "DESC", + }, + }); + + return await Notes.packMany(notes, user, { + detail: true, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/polls/vote.ts b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts new file mode 100644 index 0000000..8097160 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/polls/vote.ts @@ -0,0 +1,182 @@ +import { Not } from "typeorm"; +import { publishNoteStream } from "@/services/stream.js"; +import { createNotification } from "@/services/create-notification.js"; +import { deliver } from "@/queue/index.js"; +import { renderActivity } from "@/remote/activitypub/renderer/index.js"; +import renderVote from "@/remote/activitypub/renderer/vote.js"; +import { + PollVotes, + NoteWatchings, + Users, + Polls, + Blockings, +} from "@/models/index.js"; +import type { IRemoteUser } from "@/models/entities/user.js"; +import { genId } from "@/misc/gen-id.js"; +import { getNote } from "../../../common/getters.js"; +import { ApiError } from "../../../error.js"; +import define from "../../../define.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + + kind: "write:votes", + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "ecafbd2e-c283-4d6d-aecb-1a0a33b75396", + }, + + noPoll: { + message: "The note does not attach a poll.", + code: "NO_POLL", + id: "5f979967-52d9-4314-a911-1c673727f92f", + }, + + invalidChoice: { + message: "Choice ID is invalid.", + code: "INVALID_CHOICE", + id: "e0cc9a04-f2e8-41e4-a5f1-4127293260cc", + }, + + alreadyVoted: { + message: "You have already voted.", + code: "ALREADY_VOTED", + id: "0963fc77-efac-419b-9424-b391608dc6d8", + }, + + alreadyExpired: { + message: "The poll is already expired.", + code: "ALREADY_EXPIRED", + id: "1022a357-b085-4054-9083-8f8de358337e", + }, + + youHaveBeenBlocked: { + message: + "You cannot vote this poll because you have been blocked by this user.", + code: "YOU_HAVE_BEEN_BLOCKED", + id: "85a5377e-b1e9-4617-b0b9-5bea73331e49", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + choice: { type: "integer" }, + }, + required: ["noteId", "choice"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const createdAt = new Date(); + + // Get votee + const note = await getNote(ps.noteId, user).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + if (!note.hasPoll) { + throw new ApiError(meta.errors.noPoll); + } + + // Check blocking + if (note.userId !== user.id) { + const blocked = await Blockings.exist({ + where: [ + { blockerId: note.userId, blockeeId: user.id, groupId: null }, + ...(note.groupId ? [{ groupId: note.groupId, blockeeId: user.id }] : []), + ], + }); + if (blocked) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } + + const poll = await Polls.findOneByOrFail({ noteId: note.id }); + + if (poll.expiresAt && poll.expiresAt < createdAt) { + throw new ApiError(meta.errors.alreadyExpired); + } + + if (poll.choices[ps.choice] == null) { + throw new ApiError(meta.errors.invalidChoice); + } + + // if already voted + const exist = await PollVotes.findBy({ + noteId: note.id, + userId: user.id, + }); + + if (exist.length) { + if (poll.multiple) { + if (exist.some((x) => x.choice === ps.choice)) { + throw new ApiError(meta.errors.alreadyVoted); + } + } else { + throw new ApiError(meta.errors.alreadyVoted); + } + } + + // Create vote + const vote = await PollVotes.insert({ + id: genId(), + createdAt, + noteId: note.id, + userId: user.id, + choice: ps.choice, + }).then((x) => PollVotes.findOneByOrFail(x.identifiers[0])); + + // Increment votes count + const index = ps.choice + 1; // In SQL, array index is 1 based + await Polls.query( + `UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`, + ); + + publishNoteStream(note.id, "pollVoted", { + choice: ps.choice, + userId: user.id, + }); + + // Notify + createNotification(note.userId, "pollVote", { + notifierId: user.id, + noteId: note.id, + choice: ps.choice, + }); + + // Fetch watchers + NoteWatchings.findBy({ + noteId: note.id, + userId: Not(user.id), + }).then((watchers) => { + for (const watcher of watchers) { + createNotification(watcher.userId, "pollVote", { + notifierId: user.id, + noteId: note.id, + choice: ps.choice, + }); + } + }); + + // リモート投票の場合リプライ送信 + if (note.userHost != null) { + const pollOwner = (await Users.findOneByOrFail({ + id: note.userId, + })) as IRemoteUser; + + deliver( + user, + renderActivity(await renderVote(user, vote, note, poll, pollOwner)), + pollOwner.inbox, + ); + } +}); diff --git a/packages/backend/src/server/api/endpoints/notes/reactions.ts b/packages/backend/src/server/api/endpoints/notes/reactions.ts new file mode 100644 index 0000000..3c8af11 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/reactions.ts @@ -0,0 +1,85 @@ +import type { FindOptionsWhere } from "typeorm"; +import { DeepPartial } from "typeorm"; +import { NoteReactions } from "@/models/index.js"; +import type { NoteReaction } from "@/models/entities/note-reaction.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getNote } from "../../common/getters.js"; + +export const meta = { + tags: ["notes", "reactions"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + allowGet: true, + cacheSec: 60, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "NoteReaction", + }, + }, + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "263fff3d-d0e1-4af4-bea7-8408059b451a", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + type: { type: "string", nullable: true }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + offset: { type: "integer", default: 0 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // check note visibility + const note = await getNote(ps.noteId, user).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + const query = { + noteId: ps.noteId, + } as FindOptionsWhere; + + if (ps.type) { + // ローカルリアクションはホスト名が . とされているが + // DB 上ではそうではないので、必要に応じて変換 + const suffix = "@.:"; + const type = ps.type.endsWith(suffix) + ? `${ps.type.slice(0, ps.type.length - suffix.length)}:` + : ps.type; + query.reaction = type; + } + + const reactions = await NoteReactions.find({ + where: query, + take: ps.limit, + skip: ps.offset, + order: { + id: -1, + }, + relations: ["user", "user.avatar", "user.banner", "note"], + }); + + return await NoteReactions.packMany(reactions, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/create.ts b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts new file mode 100644 index 0000000..cec2601 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/reactions/create.ts @@ -0,0 +1,73 @@ +import createReaction from "@/services/note/reaction/create.js"; +import define from "../../../define.js"; +import { getNote } from "../../../common/getters.js"; +import { ApiError } from "../../../error.js"; +import { getGroupActor } from "../../../common/get-group-actor.js"; + +export const meta = { + tags: ["reactions", "notes"], + + requireCredential: true, + + kind: "write:reactions", + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "033d0620-5bfe-4027-965d-980b0c85a3ea", + }, + + alreadyReacted: { + message: "You are already reacting to that note.", + code: "ALREADY_REACTED", + id: "71efcf98-86d6-4e2b-b2ad-9d032369366b", + }, + + youHaveBeenBlocked: { + message: + "You cannot react this note because you have been blocked by this user.", + code: "YOU_HAVE_BEEN_BLOCKED", + id: "20ef5475-9f38-4e4c-bd33-de6d979498ec", + }, + accountLocked: { + message: "You migrated. Your account is now locked.", + code: "ACCOUNT_LOCKED", + id: "d390d7e1-8a5e-46ed-b625-06271cafd3d3", + }, + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "8ad1dbe0-6498-45ac-9243-5864d80ed5c1", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + reaction: { type: "string" }, + groupId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: ["noteId", "reaction"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + if (user.movedToUri != null) throw new ApiError(meta.errors.accountLocked); + const note = await getNote(ps.noteId, user).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + const group = await getGroupActor(ps.groupId, user); + if (ps.groupId != null && group == null) throw new ApiError(meta.errors.noSuchGroup); + await createReaction({ ...user, groupId: group?.id ?? null }, note, ps.reaction).catch((e) => { + if (e.id === "51c42bb4-931a-456b-bff7-e5a8a70dd298") + throw new ApiError(meta.errors.alreadyReacted); + if (e.id === "e70412a4-7197-4726-8e74-f3e0deb92aa7") + throw new ApiError(meta.errors.youHaveBeenBlocked); + throw e; + }); + return; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts b/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts new file mode 100644 index 0000000..03d5d75 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/reactions/delete.ts @@ -0,0 +1,63 @@ +import deleteReaction from "@/services/note/reaction/delete.js"; +import define from "../../../define.js"; +import { getNote } from "../../../common/getters.js"; +import { ApiError } from "../../../error.js"; +import { SECOND, HOUR } from "@/const.js"; +import { getGroupActor } from "../../../common/get-group-actor.js"; + +export const meta = { + tags: ["reactions", "notes"], + + requireCredential: true, + + kind: "write:reactions", + + limit: { + duration: HOUR, + max: 60, + minInterval: 3 * SECOND, + }, + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "764d9fce-f9f2-4a0e-92b1-6ceac9a7ad37", + }, + + notReacted: { + message: "You are not reacting to that note.", + code: "NOT_REACTED", + id: "92f4426d-4196-4125-aa5b-02943e2ec8fc", + }, + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "04a4f3d8-773e-43e4-a645-71da6bb03a6f", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + groupId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId, user).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + const group = await getGroupActor(ps.groupId, user); + if (ps.groupId != null && group == null) throw new ApiError(meta.errors.noSuchGroup); + await deleteReaction({ ...user, groupId: group?.id ?? null }, note).catch((e) => { + if (e.id === "60527ec9-b4cb-4a88-a6bd-32d3ad26817d") + throw new ApiError(meta.errors.notReacted); + throw e; + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts b/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts new file mode 100644 index 0000000..075a291 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts @@ -0,0 +1,146 @@ +import { Brackets } from "typeorm"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { Notes } from "@/models/index.js"; +import { activeUsersChart } from "@/services/chart/index.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { generateRepliesQuery } from "../../common/generate-replies-query.js"; +import { generateChannelQuery } from "../../common/generate-channel-query.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; +import { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js"; +import { generateExcludeMemorietQuery } from "@/server/api/common/generate-exclude-memoriet-query.js"; +import { injectPromo } from "../../common/inject-promo.js"; + +export const meta = { + tags: ["notes"], + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, + + errors: { + rtlDisabled: { + message: "Recommended timeline has been disabled.", + code: "RTL_DISABLED", + id: "45a6eb02-7695-4393-b023-dd3be9aaaefe", + }, + queryError: { + message: "Please follow more users.", + code: "QUERY_ERROR", + id: "620763f4-f621-4533-ab33-0577a1a3c343", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + withFiles: { + type: "boolean", + default: false, + description: "Only show notes that have attached files.", + }, + fileType: { + type: "array", + items: { + type: "string", + }, + }, + excludeNsfw: { type: "boolean", default: false }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + sinceDate: { type: "integer" }, + untilDate: { type: "integer" }, + withReplies: { + type: "boolean", + default: false, + description: "Show replies in the timeline", + }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const m = await fetchMeta(); + if (m.disableRecommendedTimeline) { + if (user == null || !(user.isAdmin || user.isModerator)) { + throw new ApiError(meta.errors.rtlDisabled); + } + } + + //#region Construct query + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ps.sinceDate, + ps.untilDate, + ) + .andWhere(`note.userHost IN (:...instances)`, { instances: m.recommendedInstances }) + .andWhere("note.visibility = 'public'") + .andWhere(`NOT ('adservice' = ANY(note.tags))`) + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser"); + + generateChannelQuery(query, user); + generateRepliesQuery(query, ps.withReplies, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + if (user) generateMutedUserRenotesQueryForNotes(query, user); + generateExcludeMemorietQuery(query); + + if (ps.withFiles) { + query.andWhere("note.fileIds != '{}'"); + } + + if (ps.fileType != null) { + query.andWhere("note.fileIds != '{}'"); + query.andWhere( + new Brackets((qb) => { + for (const type of ps.fileType!) { + const i = ps.fileType!.indexOf(type); + qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { + [`type${i}`]: type, + }); + } + }), + ); + + if (ps.excludeNsfw) { + query.andWhere("note.cw IS NULL"); + query.andWhere( + '0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)', + ); + } + } + //#endregion + + process.nextTick(() => { + if (user) { + activeUsersChart.read(user); + } + }); + + try { + const notes = await query.take(ps.limit).getMany(); + await injectPromo(notes, user); + return await Notes.packMany(notes, user); + } catch (error) { + throw new ApiError(meta.errors.queryError); + } +}); diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts new file mode 100644 index 0000000..2cd20ab --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -0,0 +1,80 @@ +import { Notes } from "@/models/index.js"; +import define from "../../define.js"; +import { getNote } from "../../common/getters.js"; +import { ApiError } from "../../error.js"; +import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "12908022-2e21-46cd-ba6a-3edaf6093f46", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + userId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId, user).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + let query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ) + .andWhere("note.renoteId = :renoteId", { renoteId: note.id }) + .innerJoinAndSelect("note.user", "user"); + + if (ps.userId) { + query.andWhere("user.id = :userId", { userId: ps.userId }); + } + + query + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser"); + + generateVisibilityQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + const notes = await query.take(ps.limit).getMany(); + return await Notes.packMany(notes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts new file mode 100644 index 0000000..9921ebf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -0,0 +1,57 @@ +import { Notes } from "@/models/index.js"; +import define from "../../define.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ) + .andWhere("note.replyId = :replyId", { replyId: ps.noteId }) + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser"); + + generateVisibilityQuery(query, user); + if (user) generateMutedUserQuery(query, user); + if (user) generateBlockedUserQuery(query, user); + + const notes = await query.take(ps.limit).getMany(); + return await Notes.packMany(notes, user); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts new file mode 100644 index 0000000..1cbc889 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -0,0 +1,147 @@ +import { Brackets } from "typeorm"; +import { Notes } from "@/models/index.js"; +import { safeForSql } from "@/misc/safe-for-sql.js"; +import { normalizeForSearch } from "@/misc/normalize-for-search.js"; +import define from "../../define.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; +import { injectPromo } from "../../common/inject-promo.js"; + +export const meta = { + tags: ["notes", "hashtags"], + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + reply: { type: "boolean", nullable: true, default: null }, + renote: { type: "boolean", nullable: true, default: null }, + withFiles: { + type: "boolean", + default: false, + description: "Only show notes that have attached files.", + }, + poll: { type: "boolean", nullable: true, default: null }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + }, + anyOf: [ + { + properties: { + tag: { type: "string", minLength: 1 }, + }, + required: ["tag"], + }, + { + properties: { + query: { + type: "array", + description: + "The outer arrays are chained with OR, the inner arrays are chained with AND.", + items: { + type: "array", + items: { + type: "string", + minLength: 1, + }, + minItems: 1, + }, + minItems: 1, + }, + }, + required: ["query"], + }, + ], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ) + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser"); + + generateVisibilityQuery(query, me); + if (me) generateMutedUserQuery(query, me); + if (me) generateBlockedUserQuery(query, me); + + try { + if (ps.tag) { + if (!safeForSql(normalizeForSearch(ps.tag))) throw "Injection"; + query.andWhere(`'{"${normalizeForSearch(ps.tag)}"}' <@ note.tags`); + } else { + query.andWhere( + new Brackets((qb) => { + for (const tags of ps.query!) { + qb.orWhere( + new Brackets((qb) => { + for (const tag of tags) { + if (!safeForSql(normalizeForSearch(tag))) + throw "Injection"; + qb.andWhere(`'{"${normalizeForSearch(tag)}"}' <@ note.tags`); + } + }), + ); + } + }), + ); + } + } catch (e) { + if (e.message === "Injection") return []; + throw e; + } + + if (ps.reply != null) { + if (ps.reply) { + query.andWhere("note.replyId IS NOT NULL"); + } else { + query.andWhere("note.replyId IS NULL"); + } + } + + if (ps.renote != null) { + if (ps.renote) { + query.andWhere("note.renoteId IS NOT NULL"); + } else { + query.andWhere("note.renoteId IS NULL"); + } + } + + if (ps.withFiles) { + query.andWhere("note.fileIds != '{}'"); + } + + if (ps.poll != null) { + if (ps.poll) { + query.andWhere("note.hasPoll = TRUE"); + } else { + query.andWhere("note.hasPoll = FALSE"); + } + } + + const notes = await query.take(ps.limit).getMany(); + const preferredTag = ps.tag ?? ps.query?.flat()[0] ?? null; + await injectPromo(notes, me, preferredTag); + return await Notes.packMany(notes, me); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/search.ts b/packages/backend/src/server/api/endpoints/notes/search.ts new file mode 100644 index 0000000..7c26145 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/search.ts @@ -0,0 +1,90 @@ +import { Notes } from "@/models/index.js"; +import { Note } from "@/models/entities/note.js"; +import define from "../../define.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; +import { sqlLikeEscape } from "@/misc/sql-like-escape.js"; +import { generateFtsQuery } from "@/server/api/common/generate-fts-query.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, + + errors: {}, +} as const; + +export const paramDef = { + type: "object", + properties: { + query: { type: "string" }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + offset: { type: "integer", default: 0 }, + host: { + type: "string", + nullable: true, + description: "The local host is represented with `null`.", + }, + userId: { + type: "string", + format: "misskey:id", + nullable: true, + default: null, + }, + channelId: { + type: "string", + format: "misskey:id", + nullable: true, + default: null, + }, + }, + required: ["query"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ); + + if (ps.userId) { + query.andWhere("note.userId = :userId", { userId: ps.userId }); + } else if (ps.channelId) { + query.andWhere("note.channelId = :channelId", { + channelId: ps.channelId, + }); + } + + query + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser"); + + generateFtsQuery(query, ps.query); + generateVisibilityQuery(query, me); + generateMutedUserQuery(query, me); + generateBlockedUserQuery(query, me); + query.setParameter("meId", me.id); + + return await query.take(ps.limit).getMany().then(notes => Notes.packMany(notes, me)); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/show.ts b/packages/backend/src/server/api/endpoints/notes/show.ts new file mode 100644 index 0000000..1de8b78 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/show.ts @@ -0,0 +1,58 @@ +import { Notes } from "@/models/index.js"; +import define from "../../define.js"; +import { getNote } from "../../common/getters.js"; +import { ApiError } from "../../error.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "24fcbfc6-2e37-42b6-8388-c29b3861a08d", + httpStatusCode: 404, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId, user, { allowAdservice: true }).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + const serviceTags = ["videoservice", "audioservice", "lua4frozen", "karaokeservice"]; + if (!serviceTags.some((tag) => note.tags.includes(tag))) { + await Notes.increment({ id: note.id }, "viewCount", 1); + note.viewCount += 1; + } + + return await Notes.pack(note, user, { + // FIXME: packing with detail may throw an error if the reply or renote is not visible (#8774) + detail: true, + allowAdservice: true, + }).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/state.ts b/packages/backend/src/server/api/endpoints/notes/state.ts new file mode 100644 index 0000000..630b2a8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/state.ts @@ -0,0 +1,79 @@ +import { + NoteFavorites, + Notes, + NoteThreadMutings, + NoteWatchings, +} from "@/models/index.js"; +import { getNote } from "../../common/getters.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + isFavorited: { + type: "boolean", + optional: false, + nullable: false, + }, + isWatching: { + type: "boolean", + optional: false, + nullable: false, + }, + isMutedThread: { + type: "boolean", + optional: false, + nullable: false, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId, user); + + const [favorite, watching, threadMuting] = await Promise.all([ + NoteFavorites.count({ + where: { + userId: user.id, + noteId: note.id, + }, + take: 1, + }), + NoteWatchings.count({ + where: { + userId: user.id, + noteId: note.id, + }, + take: 1, + }), + NoteThreadMutings.count({ + where: { + userId: user.id, + threadId: note.threadId || note.id, + }, + take: 1, + }), + ]); + + return { + isFavorited: favorite !== 0, + isWatching: watching !== 0, + isMutedThread: threadMuting !== 0, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts new file mode 100644 index 0000000..e4803cc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/create.ts @@ -0,0 +1,58 @@ +import { Notes, NoteThreadMutings } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import readNote from "@/services/note/read.js"; +import define from "../../../define.js"; +import { getNote } from "../../../common/getters.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + + kind: "write:account", + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "5ff67ada-ed3b-2e71-8e87-a1a421e177d2", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId, user).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + const mutedNotes = await Notes.find({ + where: [ + { + id: note.threadId || note.id, + }, + { + threadId: note.threadId || note.id, + }, + ], + }); + + await readNote(user.id, mutedNotes); + + await NoteThreadMutings.insert({ + id: genId(), + createdAt: new Date(), + threadId: note.threadId || note.id, + userId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts b/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts new file mode 100644 index 0000000..c06fd59 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/thread-muting/delete.ts @@ -0,0 +1,41 @@ +import { NoteThreadMutings } from "@/models/index.js"; +import define from "../../../define.js"; +import { getNote } from "../../../common/getters.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + + kind: "write:account", + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "bddd57ac-ceb3-b29d-4334-86ea5fae481a", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId, user).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + await NoteThreadMutings.delete({ + threadId: note.threadId || note.id, + userId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts new file mode 100644 index 0000000..df81a15 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -0,0 +1,154 @@ +import { Brackets } from "typeorm"; +import { Notes, Followings } from "@/models/index.js"; +import { activeUsersChart } from "@/services/chart/index.js"; +import define from "../../define.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { generateRepliesQuery } from "../../common/generate-replies-query.js"; +import { generateChannelQuery } from "../../common/generate-channel-query.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; +import { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js"; +import { ApiError } from "../../error.js"; +import { generateListQuery } from "@/server/api/common/generate-list-query.js"; +import { generateFollowingQuery } from "@/server/api/common/generate-following-query.js"; +import { generateExcludeMemorietQuery } from "@/server/api/common/generate-exclude-memoriet-query.js"; +import { injectPromo } from "../../common/inject-promo.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, + + errors: { + queryError: { + message: "Please follow more users.", + code: "QUERY_ERROR", + id: "620763f4-f621-4533-ab33-0577a1a3c343", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + sinceDate: { type: "integer" }, + untilDate: { type: "integer" }, + includeMyRenotes: { type: "boolean", default: true }, + includeRenotedMyNotes: { type: "boolean", default: true }, + includeLocalRenotes: { type: "boolean", default: true }, + withFiles: { + type: "boolean", + default: false, + description: "Only show notes that have attached files.", + }, + withReplies: { + type: "boolean", + default: false, + description: "Show replies in the timeline", + }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + //#region Construct query + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ps.sinceDate, + ps.untilDate, + ) + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser"); + + await generateFollowingQuery(query, user); + generateListQuery(query, user); + generateChannelQuery(query, user); + generateRepliesQuery(query, ps.withReplies, user); + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + generateMutedUserRenotesQueryForNotes(query, user); + generateExcludeMemorietQuery(query); + + if (ps.includeMyRenotes === false) { + query.andWhere( + new Brackets((qb) => { + qb.orWhere("note.userId != :meId", { meId: user.id }); + qb.orWhere("note.renoteId IS NULL"); + qb.orWhere("note.text IS NOT NULL"); + qb.orWhere("note.fileIds != '{}'"); + qb.orWhere( + '0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)', + ); + }), + ); + } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere( + new Brackets((qb) => { + qb.orWhere("note.renoteUserId != :meId", { meId: user.id }); + qb.orWhere("note.renoteId IS NULL"); + qb.orWhere("note.text IS NOT NULL"); + qb.orWhere("note.fileIds != '{}'"); + qb.orWhere( + '0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)', + ); + }), + ); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere( + new Brackets((qb) => { + qb.orWhere("note.renoteUserHost IS NOT NULL"); + qb.orWhere("note.renoteId IS NULL"); + qb.orWhere("note.text IS NOT NULL"); + qb.orWhere("note.fileIds != '{}'"); + qb.orWhere( + '0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)', + ); + }), + ); + } + + if (ps.withFiles) { + query.andWhere("note.fileIds != '{}'"); + } + + query.andWhere("note.visibility != 'hidden'"); + //#endregion + + process.nextTick(() => { + activeUsersChart.read(user); + }); + + try { + const notes = await query.take(ps.limit).getMany(); + await injectPromo(notes, user); + return await Notes.packMany(notes, user); + } catch (error) { + throw new ApiError(meta.errors.queryError); + } +}); diff --git a/packages/backend/src/server/api/endpoints/notes/translate.ts b/packages/backend/src/server/api/endpoints/notes/translate.ts new file mode 100644 index 0000000..440fdfd --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/translate.ts @@ -0,0 +1,135 @@ +import { URLSearchParams } from "node:url"; +import fetch from "node-fetch"; +import config from "@/config/index.js"; +import { getAgentByUrl } from "@/misc/fetch.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { Notes } from "@/models/index.js"; +import { ApiError } from "../../error.js"; +import { getNote } from "../../common/getters.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "object", + optional: false, + nullable: false, + }, + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "bea9b03f-36e0-49c5-a4db-627a029f8971", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + targetLang: { type: "string" }, + }, + required: ["noteId", "targetLang"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId, user).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + if (note.text == null) { + return 204; + } + + const instance = await fetchMeta(); + + if (instance.deeplAuthKey == null && instance.libreTranslateApiUrl == null) { + return 204; // TODO: 良い感じのエラー返す + } + + let targetLang = ps.targetLang; + if (targetLang.includes("-")) targetLang = targetLang.split("-")[0]; + + if (instance.libreTranslateApiUrl != null) { + const jsonBody = { + q: note.text, + source: "auto", + target: targetLang, + format: "text", + api_key: instance.libreTranslateApiKey ?? "", + }; + + const url = new URL(instance.libreTranslateApiUrl); + if (url.pathname.endsWith("/")) { + url.pathname = url.pathname.slice(0, -1); + } + if (!url.pathname.endsWith("/translate")) { + url.pathname += "/translate"; + } + const res = await fetch(url.toString(), { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(jsonBody), + agent: getAgentByUrl, + size: 10 * 1024 * 1024 + }); + + const json = (await res.json()) as { + detectedLanguage?: { + confidence: number; + language: string; + }; + translatedText: string; + }; + + return { + sourceLang: json.detectedLanguage?.language, + text: json.translatedText, + }; + } + + const params = new URLSearchParams(); + params.append("auth_key", instance.deeplAuthKey ?? ""); + params.append("text", note.text); + params.append("target_lang", targetLang); + + const endpoint = instance.deeplIsPro + ? "https://api.deepl.com/v2/translate" + : "https://api-free.deepl.com/v2/translate"; + + const res = await fetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + "User-Agent": config.userAgent, + Accept: "application/json, */*", + }, + body: params, + size: 10 * 1024 * 1024, + // TODO + //timeout: 10000, + agent: getAgentByUrl, + }); + + const json = (await res.json()) as { + translations: { + detected_source_language: string; + text: string; + }[]; + }; + + return { + sourceLang: json.translations[0].detected_source_language, + text: json.translations[0].text, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/notes/unrenote.ts b/packages/backend/src/server/api/endpoints/notes/unrenote.ts new file mode 100644 index 0000000..87458cf --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/unrenote.ts @@ -0,0 +1,63 @@ +import deleteNote from "@/services/note/delete.js"; +import { Notes, Users } from "@/models/index.js"; +import define from "../../define.js"; +import { getNote } from "../../common/getters.js"; +import { ApiError } from "../../error.js"; +import { SECOND, HOUR } from "@/const.js"; +import { getGroupActor } from "../../common/get-group-actor.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + + kind: "write:notes", + + limit: { + duration: HOUR, + max: 300, + minInterval: SECOND, + }, + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "efd4a259-2442-496b-8dd7-b255aa1a160f", + }, + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "1e4d6ad6-bbc5-478a-9f4b-8a0f578c1e72", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + groupId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId, user).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + const group = await getGroupActor(ps.groupId, user); + if (ps.groupId != null && group == null) throw new ApiError(meta.errors.noSuchGroup); + + const renotes = await Notes.findBy({ + userId: user.id, + renoteId: note.id, + groupId: group?.id ?? null, + }); + + for (const note of renotes) { + deleteNote(await Users.findOneByOrFail({ id: user.id }), note); + } +}); diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts new file mode 100644 index 0000000..fe0918a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -0,0 +1,154 @@ +import { Brackets } from "typeorm"; +import { UserLists, UserListJoinings, Notes } from "@/models/index.js"; +import { activeUsersChart } from "@/services/chart/index.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; +import { generateExcludeMemorietQuery } from "@/server/api/common/generate-exclude-memoriet-query.js"; + +export const meta = { + tags: ["notes", "lists"], + + requireCredential: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, + + errors: { + noSuchList: { + message: "No such list.", + code: "NO_SUCH_LIST", + id: "8fb1fbd5-e476-4c37-9fb0-43d55b63a2ff", + }, + queryError: { + message: "Please follow more users.", + code: "QUERY_ERROR", + id: "620763f4-f621-4533-ab33-0577a1a3c343", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + listId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + sinceDate: { type: "integer" }, + untilDate: { type: "integer" }, + includeMyRenotes: { type: "boolean", default: true }, + includeRenotedMyNotes: { type: "boolean", default: true }, + includeLocalRenotes: { type: "boolean", default: true }, + withFiles: { + type: "boolean", + default: false, + description: "Only show notes that have attached files.", + }, + }, + required: ["listId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const list = await UserLists.findOneBy({ + id: ps.listId, + userId: user.id, + }); + + if (list == null) { + throw new ApiError(meta.errors.noSuchList); + } + + //#region Construct query + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ) + .innerJoin( + UserListJoinings.metadata.targetName, + "userListJoining", + "userListJoining.userId = note.userId", + ) + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser") + .andWhere("userListJoining.userListId = :userListId", { + userListId: list.id, + }); + + generateVisibilityQuery(query, user); + generateExcludeMemorietQuery(query); + + if (ps.includeMyRenotes === false) { + query.andWhere( + new Brackets((qb) => { + qb.orWhere("note.userId != :meId", { meId: user.id }); + qb.orWhere("note.renoteId IS NULL"); + qb.orWhere("note.text IS NOT NULL"); + qb.orWhere("note.fileIds != '{}'"); + qb.orWhere( + '0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)', + ); + }), + ); + } + + if (ps.includeRenotedMyNotes === false) { + query.andWhere( + new Brackets((qb) => { + qb.orWhere("note.renoteUserId != :meId", { meId: user.id }); + qb.orWhere("note.renoteId IS NULL"); + qb.orWhere("note.text IS NOT NULL"); + qb.orWhere("note.fileIds != '{}'"); + qb.orWhere( + '0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)', + ); + }), + ); + } + + if (ps.includeLocalRenotes === false) { + query.andWhere( + new Brackets((qb) => { + qb.orWhere("note.renoteUserHost IS NOT NULL"); + qb.orWhere("note.renoteId IS NULL"); + qb.orWhere("note.text IS NOT NULL"); + qb.orWhere("note.fileIds != '{}'"); + qb.orWhere( + '0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)', + ); + }), + ); + } + + if (ps.withFiles) { + query.andWhere("note.fileIds != '{}'"); + } + //#endregion + + process.nextTick(() => { + if (user) { + activeUsersChart.read(user); + } + }); + + try { + const notes = await query.take(ps.limit).getMany(); + return await Notes.packMany(notes, user); + } catch (error) { + throw new ApiError(meta.errors.queryError); + } +}); diff --git a/packages/backend/src/server/api/endpoints/notes/watching/create.ts b/packages/backend/src/server/api/endpoints/notes/watching/create.ts new file mode 100644 index 0000000..f892109 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/watching/create.ts @@ -0,0 +1,38 @@ +import watch from "@/services/note/watch.js"; +import define from "../../../define.js"; +import { getNote } from "../../../common/getters.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + + kind: "write:account", + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "ea0e37a6-90a3-4f58-ba6b-c328ca206fc7", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId, user).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + await watch(user.id, note); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/watching/delete.ts b/packages/backend/src/server/api/endpoints/notes/watching/delete.ts new file mode 100644 index 0000000..b441ad7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/watching/delete.ts @@ -0,0 +1,38 @@ +import unwatch from "@/services/note/unwatch.js"; +import define from "../../../define.js"; +import { getNote } from "../../../common/getters.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + + kind: "write:account", + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "09b3695c-f72c-4731-a428-7cff825fc82e", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const note = await getNote(ps.noteId, user).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }); + + await unwatch(user.id, note); +}); diff --git a/packages/backend/src/server/api/endpoints/notifications/create.ts b/packages/backend/src/server/api/endpoints/notifications/create.ts new file mode 100644 index 0000000..bc57233 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notifications/create.ts @@ -0,0 +1,31 @@ +import { createNotification } from "@/services/create-notification.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["notifications"], + + requireCredential: true, + + kind: "write:notifications", + + errors: {}, +} as const; + +export const paramDef = { + type: "object", + properties: { + body: { type: "string" }, + header: { type: "string", nullable: true }, + icon: { type: "string", nullable: true }, + }, + required: ["body"], +} as const; + +export default define(meta, paramDef, async (ps, user, token) => { + createNotification(user.id, "app", { + appAccessTokenId: token ? token.id : null, + customBody: ps.body, + customHeader: ps.header, + customIcon: ps.icon, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts new file mode 100644 index 0000000..e0888ad --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts @@ -0,0 +1,35 @@ +import { publishMainStream } from "@/services/stream.js"; +import { pushNotification } from "@/services/push-notification.js"; +import { Notifications } from "@/models/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["notifications", "account"], + + requireCredential: true, + + kind: "write:notifications", +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Update documents + await Notifications.update( + { + notifieeId: user.id, + isRead: false, + }, + { + isRead: true, + }, + ); + + // 全ての通知を読みましたよというイベントを発行 + publishMainStream(user.id, "readAllNotifications"); + pushNotification(user.id, "readAllNotifications", undefined); +}); diff --git a/packages/backend/src/server/api/endpoints/notifications/read.ts b/packages/backend/src/server/api/endpoints/notifications/read.ts new file mode 100644 index 0000000..9efb2fc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notifications/read.ts @@ -0,0 +1,49 @@ +import define from "../../define.js"; +import { readNotification } from "../../common/read-notification.js"; + +export const meta = { + tags: ["notifications", "account"], + + requireCredential: true, + + kind: "write:notifications", + + description: "Mark a notification as read.", + + errors: { + noSuchNotification: { + message: "No such notification.", + code: "NO_SUCH_NOTIFICATION", + id: "efa929d5-05b5-47d1-beec-e6a4dbed011e", + }, + }, +} as const; + +export const paramDef = { + oneOf: [ + { + type: "object", + properties: { + notificationId: { type: "string", format: "misskey:id" }, + }, + required: ["notificationId"], + }, + { + type: "object", + properties: { + notificationIds: { + type: "array", + items: { type: "string", format: "misskey:id" }, + maxItems: 100, + }, + }, + required: ["notificationIds"], + }, + ], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + if ("notificationId" in ps) + return readNotification(user.id, [ps.notificationId]); + return readNotification(user.id, ps.notificationIds); +}); diff --git a/packages/backend/src/server/api/endpoints/page-push.ts b/packages/backend/src/server/api/endpoints/page-push.ts new file mode 100644 index 0000000..a0f1e91 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/page-push.ts @@ -0,0 +1,48 @@ +import { publishMainStream } from "@/services/stream.js"; +import { Users, Pages } from "@/models/index.js"; +import define from "../define.js"; +import { ApiError } from "../error.js"; + +export const meta = { + requireCredential: true, + secure: true, + + errors: { + noSuchPage: { + message: "No such page.", + code: "NO_SUCH_PAGE", + id: "4a13ad31-6729-46b4-b9af-e86b265c2e74", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + pageId: { type: "string", format: "misskey:id" }, + event: { type: "string" }, + var: {}, + }, + required: ["pageId", "event"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const page = await Pages.findOneBy({ id: ps.pageId }); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + publishMainStream(page.userId, "pageEvent", { + pageId: ps.pageId, + event: ps.event, + var: ps.var, + userId: user.id, + user: await Users.pack( + user.id, + { id: page.userId }, + { + detail: true, + }, + ), + }); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/create.ts b/packages/backend/src/server/api/endpoints/pages/create.ts new file mode 100644 index 0000000..716d326 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/create.ts @@ -0,0 +1,123 @@ +import { Pages, DriveFiles } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { Page } from "@/models/entities/page.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { HOUR } from "@/const.js"; + +export const meta = { + tags: ["pages"], + + requireCredential: true, + + kind: "write:pages", + + limit: { + duration: HOUR, + max: 300, + }, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Page", + }, + + errors: { + noSuchFile: { + message: "No such file.", + code: "NO_SUCH_FILE", + id: "b7b97489-0f66-4b12-a5ff-b21bd63f6e1c", + }, + nameAlreadyExists: { + message: "Specified name already exists.", + code: "NAME_ALREADY_EXISTS", + id: "4650348e-301c-499a-83c9-6aa988c66bc1", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + title: { type: "string" }, + name: { type: "string", minLength: 1 }, + summary: { type: "string", nullable: true }, + content: { + type: "array", + items: { + type: "object", + additionalProperties: true, + }, + }, + variables: { + type: "array", + items: { + type: "object", + additionalProperties: true, + }, + }, + script: { type: "string" }, + eyeCatchingImageId: { + type: "string", + format: "misskey:id", + nullable: true, + }, + font: { + type: "string", + enum: ["serif", "sans-serif"], + default: "sans-serif", + }, + alignCenter: { type: "boolean", default: false }, + isPublic: { type: "boolean", default: true }, + hideTitleWhenPinned: { type: "boolean", default: false }, + }, + required: ["title", "name", "content", "variables", "script"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + let eyeCatchingImage = null; + if (ps.eyeCatchingImageId != null) { + eyeCatchingImage = await DriveFiles.findOneBy({ + id: ps.eyeCatchingImageId, + userId: user.id, + }); + + if (eyeCatchingImage == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + await Pages.findBy({ + userId: user.id, + name: ps.name, + }).then((result) => { + if (result.length > 0) { + throw new ApiError(meta.errors.nameAlreadyExists); + } + }); + + const page = await Pages.insert( + new Page({ + id: genId(), + createdAt: new Date(), + updatedAt: new Date(), + title: ps.title, + name: ps.name, + summary: ps.summary, + content: ps.content, + variables: ps.variables, + script: ps.script, + eyeCatchingImageId: eyeCatchingImage ? eyeCatchingImage.id : null, + userId: user.id, + visibility: "public", + alignCenter: ps.alignCenter, + hideTitleWhenPinned: ps.hideTitleWhenPinned, + font: ps.font, + isPublic: ps.isPublic, + }), + ).then((x) => Pages.findOneByOrFail(x.identifiers[0])); + + return await Pages.pack(page); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/delete.ts b/packages/backend/src/server/api/endpoints/pages/delete.ts new file mode 100644 index 0000000..98b035f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/delete.ts @@ -0,0 +1,45 @@ +import { Pages } from "@/models/index.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; + +export const meta = { + tags: ["pages"], + + requireCredential: true, + + kind: "write:pages", + + errors: { + noSuchPage: { + message: "No such page.", + code: "NO_SUCH_PAGE", + id: "eb0c6e1d-d519-4764-9486-52a7e1c6392a", + }, + + accessDenied: { + message: "Access denied.", + code: "ACCESS_DENIED", + id: "8b741b3e-2c22-44b3-a15f-29949aa1601e", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + pageId: { type: "string", format: "misskey:id" }, + }, + required: ["pageId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const page = await Pages.findOneBy({ id: ps.pageId }); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + if (page.userId !== user.id) { + throw new ApiError(meta.errors.accessDenied); + } + + await Pages.delete(page.id); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/featured.ts b/packages/backend/src/server/api/endpoints/pages/featured.ts new file mode 100644 index 0000000..a763465 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/featured.ts @@ -0,0 +1,38 @@ +import { Pages } from "@/models/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["pages"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Page", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = Pages.createQueryBuilder("page") + .where("page.visibility = 'public'") + .andWhere("page.likedCount > 0") + .orderBy("page.likedCount", "DESC"); + + const pages = await query.take(10).getMany(); + + return await Pages.packMany(pages, me); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/like.ts b/packages/backend/src/server/api/endpoints/pages/like.ts new file mode 100644 index 0000000..03482c9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/like.ts @@ -0,0 +1,63 @@ +import { Pages, PageLikes } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; + +export const meta = { + tags: ["pages"], + + requireCredential: true, + + kind: "write:page-likes", + + errors: { + noSuchPage: { + message: "No such page.", + code: "NO_SUCH_PAGE", + id: "cc98a8a2-0dc3-4123-b198-62c71df18ed3", + }, + + alreadyLiked: { + message: "The page has already been liked.", + code: "ALREADY_LIKED", + id: "cc98a8a2-0dc3-4123-b198-62c71df18ed3", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + pageId: { type: "string", format: "misskey:id" }, + }, + required: ["pageId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const page = await Pages.findOneBy({ id: ps.pageId }); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + // if already liked + const exist = await PageLikes.exist({ + where: { + pageId: page.id, + userId: user.id, + }, + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyLiked); + } + + // Create like + await PageLikes.insert({ + id: genId(), + createdAt: new Date(), + pageId: page.id, + userId: user.id, + }); + + Pages.increment({ id: page.id }, "likedCount", 1); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/show.ts b/packages/backend/src/server/api/endpoints/pages/show.ts new file mode 100644 index 0000000..a25eb30 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/show.ts @@ -0,0 +1,75 @@ +import { IsNull } from "typeorm"; +import { Pages, Users } from "@/models/index.js"; +import type { Page } from "@/models/entities/page.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; + +export const meta = { + tags: ["pages"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "object", + optional: false, + nullable: false, + ref: "Page", + }, + + errors: { + noSuchPage: { + message: "No such page.", + code: "NO_SUCH_PAGE", + id: "222120c0-3ead-4528-811b-b96f233388d7", + }, + }, +} as const; + +export const paramDef = { + type: "object", + anyOf: [ + { + properties: { + pageId: { type: "string", format: "misskey:id" }, + }, + required: ["pageId"], + }, + { + properties: { + name: { type: "string" }, + username: { type: "string" }, + }, + required: ["name", "username"], + }, + ], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + let page: Page | null = null; + + if (ps.pageId) { + page = await Pages.findOneBy({ id: ps.pageId }); + } else if (ps.name && ps.username) { + const author = await Users.findOneBy({ + host: IsNull(), + usernameLower: ps.username.toLowerCase(), + }); + if (author) { + page = await Pages.findOneBy({ + name: ps.name, + userId: author.id, + }); + } + } + + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + if (!page.isPublic && (user == null || page.userId !== user.id)) { + throw new ApiError(meta.errors.noSuchPage); + } + + return await Pages.pack(page, user); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/unlike.ts b/packages/backend/src/server/api/endpoints/pages/unlike.ts new file mode 100644 index 0000000..e607d7a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/unlike.ts @@ -0,0 +1,54 @@ +import { Pages, PageLikes } from "@/models/index.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; + +export const meta = { + tags: ["pages"], + + requireCredential: true, + + kind: "write:page-likes", + + errors: { + noSuchPage: { + message: "No such page.", + code: "NO_SUCH_PAGE", + id: "a0d41e20-1993-40bd-890e-f6e560ae648e", + }, + + notLiked: { + message: "You have not liked that page.", + code: "NOT_LIKED", + id: "f5e586b0-ce93-4050-b0e3-7f31af5259ee", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + pageId: { type: "string", format: "misskey:id" }, + }, + required: ["pageId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const page = await Pages.findOneBy({ id: ps.pageId }); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + + const like = await PageLikes.findOneBy({ + pageId: page.id, + userId: user.id, + }); + + if (like == null) { + throw new ApiError(meta.errors.notLiked); + } + + // Delete like + await PageLikes.delete(like.id); + + Pages.decrement({ id: page.id }, "likedCount", 1); +}); diff --git a/packages/backend/src/server/api/endpoints/pages/update.ts b/packages/backend/src/server/api/endpoints/pages/update.ts new file mode 100644 index 0000000..65e1b3b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pages/update.ts @@ -0,0 +1,134 @@ +import { Not } from "typeorm"; +import { Pages, DriveFiles } from "@/models/index.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { HOUR } from "@/const.js"; + +export const meta = { + tags: ["pages"], + + requireCredential: true, + + kind: "write:pages", + + limit: { + duration: HOUR, + max: 300, + }, + + errors: { + noSuchPage: { + message: "No such page.", + code: "NO_SUCH_PAGE", + id: "21149b9e-3616-4778-9592-c4ce89f5a864", + }, + + accessDenied: { + message: "Access denied.", + code: "ACCESS_DENIED", + id: "3c15cd52-3b4b-4274-967d-6456fc4f792b", + }, + + noSuchFile: { + message: "No such file.", + code: "NO_SUCH_FILE", + id: "cfc23c7c-3887-490e-af30-0ed576703c82", + }, + nameAlreadyExists: { + message: "Specified name already exists.", + code: "NAME_ALREADY_EXISTS", + id: "2298a392-d4a1-44c5-9ebb-ac1aeaa5a9ab", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + pageId: { type: "string", format: "misskey:id" }, + title: { type: "string" }, + name: { type: "string", minLength: 1 }, + summary: { type: "string", nullable: true }, + content: { + type: "array", + items: { + type: "object", + additionalProperties: true, + }, + }, + variables: { + type: "array", + items: { + type: "object", + additionalProperties: true, + }, + }, + script: { type: "string" }, + eyeCatchingImageId: { + type: "string", + format: "misskey:id", + nullable: true, + }, + font: { type: "string", enum: ["serif", "sans-serif"] }, + alignCenter: { type: "boolean" }, + hideTitleWhenPinned: { type: "boolean" }, + isPublic: { type: "boolean" }, + }, + required: ["pageId", "title", "name", "content", "variables", "script"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const page = await Pages.findOneBy({ id: ps.pageId }); + if (page == null) { + throw new ApiError(meta.errors.noSuchPage); + } + if (page.userId !== user.id) { + throw new ApiError(meta.errors.accessDenied); + } + + let eyeCatchingImage = null; + if (ps.eyeCatchingImageId != null) { + eyeCatchingImage = await DriveFiles.findOneBy({ + id: ps.eyeCatchingImageId, + userId: user.id, + }); + + if (eyeCatchingImage == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + await Pages.findBy({ + id: Not(ps.pageId), + userId: user.id, + name: ps.name, + }).then((result) => { + if (result.length > 0) { + throw new ApiError(meta.errors.nameAlreadyExists); + } + }); + + await Pages.update(page.id, { + updatedAt: new Date(), + title: ps.title, + name: ps.name === undefined ? page.name : ps.name, + summary: ps.name === undefined ? page.summary : ps.summary, + content: ps.content, + variables: ps.variables, + script: ps.script, + isPublic: ps.isPublic, + alignCenter: + ps.alignCenter === undefined ? page.alignCenter : ps.alignCenter, + hideTitleWhenPinned: + ps.hideTitleWhenPinned === undefined + ? page.hideTitleWhenPinned + : ps.hideTitleWhenPinned, + font: ps.font === undefined ? page.font : ps.font, + eyeCatchingImageId: + ps.eyeCatchingImageId === null + ? null + : ps.eyeCatchingImageId === undefined + ? page.eyeCatchingImageId + : eyeCatchingImage!.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/ping.ts b/packages/backend/src/server/api/endpoints/ping.ts new file mode 100644 index 0000000..c1f7e11 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/ping.ts @@ -0,0 +1,32 @@ +import define from "../define.js"; + +export const meta = { + requireCredential: false, + + tags: ["meta"], + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + pong: { + type: "number", + optional: false, + nullable: false, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async () => { + return { + pong: Date.now(), + }; +}); diff --git a/packages/backend/src/server/api/endpoints/pinned-users.ts b/packages/backend/src/server/api/endpoints/pinned-users.ts new file mode 100644 index 0000000..2202006 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/pinned-users.ts @@ -0,0 +1,52 @@ +import { IsNull } from "typeorm"; +import { Users } from "@/models/index.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import * as Acct from "@/misc/acct.js"; +import type { User } from "@/models/entities/user.js"; +import define from "../define.js"; + +export const meta = { + tags: ["users"], + + requireCredential: false, + requireCredentialPrivateMode: false, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "UserDetailed", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const meta = await fetchMeta(); + + const users = await Promise.all( + meta.pinnedUsers + .map((acct) => Acct.parse(acct)) + .map((acct) => + Users.findOneBy({ + usernameLower: acct.username.toLowerCase(), + host: acct.host ?? IsNull(), + }), + ), + ); + + return await Users.packMany( + users.filter((x) => x !== undefined) as User[], + me, + { detail: true }, + ); +}); diff --git a/packages/backend/src/server/api/endpoints/promo/read.ts b/packages/backend/src/server/api/endpoints/promo/read.ts new file mode 100644 index 0000000..5e26b45 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/promo/read.ts @@ -0,0 +1,49 @@ +import { PromoNotes } from "@/models/index.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getNote } from "../../common/getters.js"; +import { readPromo } from "../../common/read-promo.js"; + +export const meta = { + tags: ["notes"], + + requireCredential: true, + + errors: { + noSuchNote: { + message: "No such note.", + code: "NO_SUCH_NOTE", + id: "d785b897-fcd3-4fe9-8fc3-b85c26e6c932", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + noteId: { type: "string", format: "misskey:id" }, + }, + required: ["noteId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const [note, promo] = await Promise.all([ + getNote(ps.noteId, user, { allowAdservice: true }).catch((err) => { + if (err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") + throw new ApiError(meta.errors.noSuchNote); + throw err; + }), + PromoNotes.findOneBy({ noteId: ps.noteId }), + ]); + + if ( + note == null || + promo == null || + promo.expiresAt.getTime() <= Date.now() || + promo.remainingCredits <= 0 + ) { + throw new ApiError(meta.errors.noSuchNote); + } + + await readPromo(note, promo, user); +}); diff --git a/packages/backend/src/server/api/endpoints/promo/show.ts b/packages/backend/src/server/api/endpoints/promo/show.ts new file mode 100644 index 0000000..19b6b82 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/promo/show.ts @@ -0,0 +1,124 @@ +import rndstr from "rndstr"; +import { Notes, PromoNotes, PromoReads, Users } from "@/models/index.js"; +import type { Note } from "@/models/entities/note.js"; +import type { User } from "@/models/entities/user.js"; +import define from "../../define.js"; +import { shouldHideEUsersFor } from "../../common/generate-minor-badge-visibility-query.js"; +import { readPromo } from "../../common/read-promo.js"; + +const serviceTags = { + video: "videoservice", + audio: "audioservice", + image: "imageservice", + karaoke: "karaokeservice", + lua4frozen: "lua4frozen", +} as const; + +const systemTags = new Set([ + "adservice", + "videoservice", + "audioservice", + "imageservice", + "karaokeservice", + "lua4frozen", +]); + +function isExplicitAd(note: Note): boolean { + return note.tags.includes("explicit") || note.user?.minorBadges?.includes("E") === true; +} + +function canShowExplicit(note: Note, user: User | null | undefined, hideExplicit: boolean): boolean { + if (!isExplicitAd(note)) return true; + if (hideExplicit) return false; + return !(user && shouldHideEUsersFor(user)); +} + +function hasPollOr(note: Note, fn: () => boolean): boolean { + return note.hasPoll || fn(); +} + +function hasFileType(note: Note, prefix: string): boolean { + return note.attachedFileTypes.some((type) => type.startsWith(prefix)); +} + +function matchesService(note: Note, service: keyof typeof serviceTags | null): boolean { + if (service == null) return true; + if (!note.tags.includes(serviceTags[service])) return false; + + switch (service) { + case "video": + return hasPollOr(note, () => hasFileType(note, "video/")); + case "audio": + return hasPollOr(note, () => hasFileType(note, "audio/")); + case "image": + return hasPollOr(note, () => hasFileType(note, "image/")); + case "karaoke": + return hasFileType(note, "video/") || (hasFileType(note, "audio/") && hasFileType(note, "image/")); + case "lua4frozen": + return hasPollOr(note, () => hasFileType(note, "image/")); + } +} + +export const meta = { + tags: ["notes"], + + requireCredential: false, + requireCredentialPrivateMode: true, +} as const; + +export const paramDef = { + type: "object", + properties: { + service: { + type: "string", + enum: ["video", "audio", "image", "karaoke", "lua4frozen"], + nullable: true, + }, + tag: { type: "string", nullable: true }, + hideExplicit: { type: "boolean", default: false }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const readDay = new Date().toISOString().slice(0, 10); + const reads = user + ? await PromoReads.findBy({ + userId: user.id, + readDay, + }) + : []; + const readNoteIds = new Set(reads.map((read) => read.noteId)); + const promos = (await PromoNotes.find()).filter((promo) => + promo.expiresAt.getTime() > Date.now() && + promo.remainingCredits > 0 && + !readNoteIds.has(promo.noteId) + ); + + const candidates: Note[] = []; + for (const promo of promos) { + const note = await Notes.findOneBy({ id: promo.noteId }); + if (!note?.tags.includes("adservice")) continue; + note.user = await Users.findOneByOrFail({ id: note.userId }); + if (!canShowExplicit(note, user, ps.hideExplicit)) continue; + if (!matchesService(note, ps.service ?? null)) continue; + candidates.push(note); + } + + if (candidates.length === 0) return null; + + const requestedTag = ps.tag?.trim().toLowerCase().replace(/^#/, ""); + const priority = requestedTag && !systemTags.has(requestedTag) + ? candidates.filter((note) => note.tags.includes(requestedTag)) + : []; + const pool = priority.length > 0 ? priority : candidates; + const note = pool[Math.floor(Math.random() * pool.length)]; + const promo = promos.find((promo) => promo.noteId === note.id); + (note as any)._prId_ = rndstr("a-z0-9", 8); + + if (user && promo) { + await readPromo(note, promo, user); + } + + return Notes.pack(note, user); +}); diff --git a/packages/backend/src/server/api/endpoints/recommended-instances.ts b/packages/backend/src/server/api/endpoints/recommended-instances.ts new file mode 100644 index 0000000..8407afb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/recommended-instances.ts @@ -0,0 +1,33 @@ +// import { IsNull } from 'typeorm'; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import define from "../define.js"; + +export const meta = { + tags: ["meta"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "string", + optional: false, + nullable: false, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async () => { + const meta = await fetchMeta(); + const instances = await Promise.all(meta.recommendedInstances.map((x) => x)); + return instances; +}); diff --git a/packages/backend/src/server/api/endpoints/release.ts b/packages/backend/src/server/api/endpoints/release.ts new file mode 100644 index 0000000..ba03e59 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/release.ts @@ -0,0 +1,28 @@ +import define from "../define.js"; + +export const meta = { + tags: ["meta"], + description: "Get release notes from Codeberg", + + requireCredential: false, + requireCredentialPrivateMode: false, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async () => { + let release; + + await fetch( + "https://iceshrimp.dev/iceshrimp/iceshrimp/raw/branch/dev/release.json", + ) + .then((response) => response.json()) + .then((data) => { + release = data; + }); + return release; +}); diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts new file mode 100644 index 0000000..f09f197 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts @@ -0,0 +1,70 @@ +import { genId } from "@/misc/gen-id.js"; +import { RenoteMutings } from "@/models/index.js"; +import { RenoteMuting } from "@/models/entities/renote-muting.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getUser } from "../../common/getters.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + kind: "write:mutes", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "6fef56f3-e765-4957-88e5-c6f65329b8a5", + }, + + alreadyMuting: { + message: "You are already muting that user.", + code: "ALREADY_MUTING", + id: "7e7359cb-160c-4956-b08f-4d1c653cd007", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const muter = user; + + // Get mutee + const mutee = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check if already muting + const exist = await RenoteMutings.exist({ + where: { + muterId: muter.id, + muteeId: mutee.id, + }, + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyMuting); + } + + // Create mute + await RenoteMutings.insert({ + id: genId(), + createdAt: new Date(), + muterId: muter.id, + muteeId: mutee.id, + } as RenoteMuting); + + // publishUserEvent(user.id, "mute", mutee); +}); diff --git a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts new file mode 100644 index 0000000..7a89814 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts @@ -0,0 +1,63 @@ +import { RenoteMutings } from "@/models/index.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getUser } from "../../common/getters.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + kind: "write:mutes", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "b851d00b-8ab1-4a56-8b1b-e24187cb48ef", + }, + + notMuting: { + message: "You are not muting that user.", + code: "NOT_MUTING", + id: "5467d020-daa9-4553-81e1-135c0c35a96d", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const muter = user; + + // Get mutee + const mutee = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check not muting + const muting = await RenoteMutings.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (muting == null) { + throw new ApiError(meta.errors.notMuting); + } + + // Delete mute + await RenoteMutings.delete({ + id: muting.id, + }); + + // publishUserEvent(user.id, "unmute", mutee); +}); diff --git a/packages/backend/src/server/api/endpoints/renote-mute/list.ts b/packages/backend/src/server/api/endpoints/renote-mute/list.ts new file mode 100644 index 0000000..9149dd9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/list.ts @@ -0,0 +1,46 @@ +import { RenoteMutings } from "@/models/index.js"; +import define from "../../define.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + kind: "read:mutes", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "RenoteMuting", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery( + RenoteMutings.createQueryBuilder("muting"), + ps.sinceId, + ps.untilId, + ).andWhere("muting.muterId = :meId", { meId: me.id }); + + const mutings = await query.take(ps.limit).getMany(); + + return await RenoteMutings.packMany(mutings, me); +}); diff --git a/packages/backend/src/server/api/endpoints/request-reset-password.ts b/packages/backend/src/server/api/endpoints/request-reset-password.ts new file mode 100644 index 0000000..bac564c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/request-reset-password.ts @@ -0,0 +1,76 @@ +import rndstr from "rndstr"; +import { IsNull } from "typeorm"; +import { publishMainStream } from "@/services/stream.js"; +import config from "@/config/index.js"; +import { Users, UserProfiles, PasswordResetRequests } from "@/models/index.js"; +import { sendEmail } from "@/services/send-email.js"; +import { genId } from "@/misc/gen-id.js"; +import { ApiError } from "../error.js"; +import define from "../define.js"; +import { HOUR } from "@/const.js"; + +export const meta = { + tags: ["reset password"], + + requireCredential: false, + + description: "Request a users password to be reset.", + + limit: { + duration: HOUR, + max: 3, + }, + + errors: {}, +} as const; + +export const paramDef = { + type: "object", + properties: { + username: { type: "string" }, + email: { type: "string" }, + }, + required: ["username", "email"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const user = await Users.findOneBy({ + usernameLower: ps.username.toLowerCase(), + host: IsNull(), + }); + + // 合致するユーザーが登録されていなかったら無視 + if (user == null) { + return; + } + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + // 合致するメアドが登録されていなかったら無視 + if (profile.email !== ps.email) { + return; + } + + // メアドが認証されていなかったら無視 + if (!profile.emailVerified) { + return; + } + + const token = rndstr("a-z0-9", 64); + + await PasswordResetRequests.insert({ + id: genId(), + createdAt: new Date(), + userId: profile.userId, + token, + }); + + const link = `${config.url}/reset-password/${token}`; + + sendEmail( + ps.email, + "Password reset requested", + `To reset password, please click this link:
${link}`, + `To reset password, please click this link: ${link}`, + ); +}); diff --git a/packages/backend/src/server/api/endpoints/reset-db.ts b/packages/backend/src/server/api/endpoints/reset-db.ts new file mode 100644 index 0000000..c64db7b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reset-db.ts @@ -0,0 +1,29 @@ +import { resetDb } from "@/db/postgre.js"; +import define from "../define.js"; +import { ApiError } from "../error.js"; + +export const meta = { + tags: ["non-productive"], + + requireCredential: false, + + description: + "Only available when running with NODE_ENV=testing. Reset the database and flush Redis.", + + errors: {}, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + if (process.env.NODE_ENV !== "test") + throw new Error("NODE_ENV is not a test"); + + await resetDb(); + + await new Promise((resolve) => setTimeout(resolve, 1000)); +}); diff --git a/packages/backend/src/server/api/endpoints/reset-password.ts b/packages/backend/src/server/api/endpoints/reset-password.ts new file mode 100644 index 0000000..f695ae4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reset-password.ts @@ -0,0 +1,44 @@ +import { publishMainStream } from "@/services/stream.js"; +import { Users, UserProfiles, PasswordResetRequests } from "@/models/index.js"; +import define from "../define.js"; +import { ApiError } from "../error.js"; +import { hashPassword } from "@/misc/password.js"; + +export const meta = { + tags: ["reset password"], + + requireCredential: false, + + description: "Complete the password reset that was previously requested.", + + errors: {}, +} as const; + +export const paramDef = { + type: "object", + properties: { + token: { type: "string" }, + password: { type: "string" }, + }, + required: ["token", "password"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const req = await PasswordResetRequests.findOneByOrFail({ + token: ps.token, + }); + + // 発行してから30分以上経過していたら無効 + if (Date.now() - req.createdAt.getTime() > 1000 * 60 * 30) { + throw new Error(); // TODO + } + + // Generate hash of password + const hash = await hashPassword(ps.password); + + await UserProfiles.update(req.userId, { + password: hash, + }); + + PasswordResetRequests.delete(req.id); +}); diff --git a/packages/backend/src/server/api/endpoints/reversi/cancel-match.ts b/packages/backend/src/server/api/endpoints/reversi/cancel-match.ts new file mode 100644 index 0000000..76c2f12 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reversi/cancel-match.ts @@ -0,0 +1,20 @@ +import define from "../../define.js"; +import { cancelMatch } from "@/services/reversi/index.js"; + +export const meta = { + tags: ["reversi"], + requireCredential: true, + kind: "write:account", +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + await cancelMatch(user, ps.userId); +}); diff --git a/packages/backend/src/server/api/endpoints/reversi/games.ts b/packages/backend/src/server/api/endpoints/reversi/games.ts new file mode 100644 index 0000000..dae4fa5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reversi/games.ts @@ -0,0 +1,47 @@ +import { Brackets } from "typeorm"; +import define from "../../define.js"; +import { ReversiGames } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["reversi"], + requireCredential: false, + res: { + type: "array", + optional: false, + nullable: false, + items: { type: "object" }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + my: { type: "boolean", default: false }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + ReversiGames.createQueryBuilder("game"), + ps.sinceId, + ps.untilId, + ).innerJoinAndSelect("game.user1", "user1") + .innerJoinAndSelect("game.user2", "user2"); + + if (ps.my && user) { + query.andWhere(new Brackets((qb) => { + qb.where("game.user1Id = :userId", { userId: user.id }) + .orWhere("game.user2Id = :userId", { userId: user.id }); + })); + } else { + query.andWhere("game.isStarted = TRUE"); + } + + const games = await query.take(ps.limit).getMany(); + return await Promise.all(games.map((game) => ReversiGames.packLite(game))); +}); diff --git a/packages/backend/src/server/api/endpoints/reversi/invitations.ts b/packages/backend/src/server/api/endpoints/reversi/invitations.ts new file mode 100644 index 0000000..5b92a33 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reversi/invitations.ts @@ -0,0 +1,26 @@ +import define from "../../define.js"; +import { Users } from "@/models/index.js"; +import { getInvitations } from "@/services/reversi/index.js"; + +export const meta = { + tags: ["reversi"], + requireCredential: true, + kind: "read:account", + res: { + type: "array", + optional: false, + nullable: false, + items: { type: "object" }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (_ps, user) => { + const ids = await getInvitations(user); + return await Users.packMany(ids, user, { detail: false }); +}); diff --git a/packages/backend/src/server/api/endpoints/reversi/match.ts b/packages/backend/src/server/api/endpoints/reversi/match.ts new file mode 100644 index 0000000..5ea9de4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reversi/match.ts @@ -0,0 +1,57 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { Users, ReversiGames } from "@/models/index.js"; +import { + matchAnyUser, + matchSpecificUser, +} from "@/services/reversi/index.js"; + +export const meta = { + tags: ["reversi"], + requireCredential: true, + kind: "write:account", + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "0b4f0559-b484-4e31-9581-3f73cee89b28", + }, + isYourself: { + message: "Target user is yourself.", + code: "TARGET_IS_YOURSELF", + id: "96fd7bd6-d2bc-426c-a865-d055dcd2828e", + }, + }, + res: { + type: "object", + optional: true, + nullable: false, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id", nullable: true }, + noIrregularRules: { type: "boolean", default: false }, + multiple: { type: "boolean", default: false }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + if (ps.userId === user.id) throw new ApiError(meta.errors.isYourself); + + const target = ps.userId + ? await Users.findOneBy({ id: ps.userId }).then((u) => { + if (!u) throw new ApiError(meta.errors.noSuchUser); + return u; + }) + : null; + + const game = target + ? await matchSpecificUser(user, target, ps.multiple) + : await matchAnyUser(user, { noIrregularRules: ps.noIrregularRules }, ps.multiple); + + return game ? await ReversiGames.packDetail(game) : undefined; +}); diff --git a/packages/backend/src/server/api/endpoints/reversi/show-game.ts b/packages/backend/src/server/api/endpoints/reversi/show-game.ts new file mode 100644 index 0000000..1150b68 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reversi/show-game.ts @@ -0,0 +1,37 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { ReversiGames } from "@/models/index.js"; + +export const meta = { + tags: ["reversi"], + requireCredential: false, + errors: { + noSuchGame: { + message: "No such game.", + code: "NO_SUCH_GAME", + id: "f13a03db-fae1-46c9-87f3-43c8165419e1", + }, + }, + res: { + type: "object", + optional: false, + nullable: false, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + gameId: { type: "string", format: "misskey:id" }, + }, + required: ["gameId"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const game = await ReversiGames.findOne({ + where: { id: ps.gameId }, + relations: { user1: true, user2: true }, + }); + if (!game) throw new ApiError(meta.errors.noSuchGame); + return await ReversiGames.packDetail(game); +}); diff --git a/packages/backend/src/server/api/endpoints/reversi/surrender.ts b/packages/backend/src/server/api/endpoints/reversi/surrender.ts new file mode 100644 index 0000000..2a91d89 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reversi/surrender.ts @@ -0,0 +1,20 @@ +import define from "../../define.js"; +import { surrender } from "@/services/reversi/index.js"; + +export const meta = { + tags: ["reversi"], + requireCredential: true, + kind: "write:account", +} as const; + +export const paramDef = { + type: "object", + properties: { + gameId: { type: "string", format: "misskey:id" }, + }, + required: ["gameId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + await surrender(ps.gameId, user); +}); diff --git a/packages/backend/src/server/api/endpoints/reversi/verify.ts b/packages/backend/src/server/api/endpoints/reversi/verify.ts new file mode 100644 index 0000000..a962547 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reversi/verify.ts @@ -0,0 +1,27 @@ +import define from "../../define.js"; +import { ReversiGames } from "@/models/index.js"; +import { checkCrc } from "@/services/reversi/index.js"; + +export const meta = { + tags: ["reversi"], + requireCredential: false, + res: { + type: "object", + optional: true, + nullable: false, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + gameId: { type: "string", format: "misskey:id" }, + crc32: { anyOf: [{ type: "string" }, { type: "number" }] }, + }, + required: ["gameId", "crc32"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const game = await checkCrc(ps.gameId, ps.crc32); + return game ? await ReversiGames.packDetail(game) : undefined; +}); diff --git a/packages/backend/src/server/api/endpoints/server-info.ts b/packages/backend/src/server/api/endpoints/server-info.ts new file mode 100644 index 0000000..b740886 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/server-info.ts @@ -0,0 +1,64 @@ +import * as os from "node:os"; +import si from "systeminformation"; +import define from "../define.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; + +export const meta = { + requireCredential: false, + requireCredentialPrivateMode: true, + allowGet: true, + cacheSec: 30, + tags: ["meta"], +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async () => { + const memStats = await si.mem(); + const fsStats = await si.fsSize(); + + let fsIndex = 0; + // Get the first index of fs sizes that are actualy used. + for (const [i, stat] of fsStats.entries()) { + if (stat.rw === true && stat.used > 0) { + fsIndex = i; + break; + } + } + + const instanceMeta = await fetchMeta(); + if (!instanceMeta.enableServerMachineStats) { + return { + machine: "Not specified", + cpu: { + model: "Not specified", + cores: 0, + }, + mem: { + total: 0, + }, + fs: { + total: 0, + used: 0, + }, + }; + } + return { + machine: os.hostname(), + cpu: { + model: os.cpus()[0].model, + cores: os.cpus().length, + }, + mem: { + total: memStats.total, + }, + fs: { + total: fsStats[fsIndex].size, + used: fsStats[fsIndex].used, + }, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/shogi/cancel-match.ts b/packages/backend/src/server/api/endpoints/shogi/cancel-match.ts new file mode 100644 index 0000000..1e3d774 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/shogi/cancel-match.ts @@ -0,0 +1,20 @@ +import define from "../../define.js"; +import { cancelMatch } from "@/services/shogi/index.js"; + +export const meta = { + tags: ["shogi"], + requireCredential: true, + kind: "write:account", +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + await cancelMatch(user, ps.userId); +}); diff --git a/packages/backend/src/server/api/endpoints/shogi/games.ts b/packages/backend/src/server/api/endpoints/shogi/games.ts new file mode 100644 index 0000000..9fec099 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/shogi/games.ts @@ -0,0 +1,47 @@ +import { Brackets } from "typeorm"; +import define from "../../define.js"; +import { ShogiGames } from "@/models/index.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["shogi"], + requireCredential: false, + res: { + type: "array", + optional: false, + nullable: false, + items: { type: "object" }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + my: { type: "boolean", default: false }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + ShogiGames.createQueryBuilder("game"), + ps.sinceId, + ps.untilId, + ).innerJoinAndSelect("game.user1", "user1") + .innerJoinAndSelect("game.user2", "user2"); + + if (ps.my && user) { + query.andWhere(new Brackets((qb) => { + qb.where("game.user1Id = :userId", { userId: user.id }) + .orWhere("game.user2Id = :userId", { userId: user.id }); + })); + } else { + query.andWhere("game.isStarted = TRUE"); + } + + const games = await query.take(ps.limit).getMany(); + return await Promise.all(games.map((game) => ShogiGames.packLite(game))); +}); diff --git a/packages/backend/src/server/api/endpoints/shogi/invitations.ts b/packages/backend/src/server/api/endpoints/shogi/invitations.ts new file mode 100644 index 0000000..7a933f9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/shogi/invitations.ts @@ -0,0 +1,26 @@ +import define from "../../define.js"; +import { Users } from "@/models/index.js"; +import { getInvitations } from "@/services/shogi/index.js"; + +export const meta = { + tags: ["shogi"], + requireCredential: true, + kind: "read:account", + res: { + type: "array", + optional: false, + nullable: false, + items: { type: "object" }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (_ps, user) => { + const ids = await getInvitations(user); + return await Users.packMany(ids, user, { detail: false }); +}); diff --git a/packages/backend/src/server/api/endpoints/shogi/match.ts b/packages/backend/src/server/api/endpoints/shogi/match.ts new file mode 100644 index 0000000..f6ef318 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/shogi/match.ts @@ -0,0 +1,56 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { ShogiGames, Users } from "@/models/index.js"; +import { + matchAnyUser, + matchSpecificUser, +} from "@/services/shogi/index.js"; + +export const meta = { + tags: ["shogi"], + requireCredential: true, + kind: "write:account", + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "6dc7bf6e-d472-42ec-95c7-43d8f91ecf97", + }, + isYourself: { + message: "Target user is yourself.", + code: "TARGET_IS_YOURSELF", + id: "d7cb9670-245e-4fc4-90d0-11cc1796f647", + }, + }, + res: { + type: "object", + optional: true, + nullable: false, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id", nullable: true }, + multiple: { type: "boolean", default: false }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + if (ps.userId === user.id) throw new ApiError(meta.errors.isYourself); + + const target = ps.userId + ? await Users.findOneBy({ id: ps.userId }).then((u) => { + if (!u) throw new ApiError(meta.errors.noSuchUser); + return u; + }) + : null; + + const game = target + ? await matchSpecificUser(user, target, ps.multiple) + : await matchAnyUser(user, ps.multiple); + + return game ? await ShogiGames.packDetail(game) : undefined; +}); diff --git a/packages/backend/src/server/api/endpoints/shogi/show-game.ts b/packages/backend/src/server/api/endpoints/shogi/show-game.ts new file mode 100644 index 0000000..0ec9d7c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/shogi/show-game.ts @@ -0,0 +1,37 @@ +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { ShogiGames } from "@/models/index.js"; + +export const meta = { + tags: ["shogi"], + requireCredential: false, + errors: { + noSuchGame: { + message: "No such game.", + code: "NO_SUCH_GAME", + id: "f732066e-d92e-4d80-940f-b596d512f2ef", + }, + }, + res: { + type: "object", + optional: false, + nullable: false, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + gameId: { type: "string", format: "misskey:id" }, + }, + required: ["gameId"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const game = await ShogiGames.findOne({ + where: { id: ps.gameId }, + relations: { user1: true, user2: true }, + }); + if (!game) throw new ApiError(meta.errors.noSuchGame); + return await ShogiGames.packDetail(game); +}); diff --git a/packages/backend/src/server/api/endpoints/shogi/surrender.ts b/packages/backend/src/server/api/endpoints/shogi/surrender.ts new file mode 100644 index 0000000..1b73be8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/shogi/surrender.ts @@ -0,0 +1,20 @@ +import define from "../../define.js"; +import { surrender } from "@/services/shogi/index.js"; + +export const meta = { + tags: ["shogi"], + requireCredential: true, + kind: "write:account", +} as const; + +export const paramDef = { + type: "object", + properties: { + gameId: { type: "string", format: "misskey:id" }, + }, + required: ["gameId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + await surrender(ps.gameId, user); +}); diff --git a/packages/backend/src/server/api/endpoints/stats.ts b/packages/backend/src/server/api/endpoints/stats.ts new file mode 100644 index 0000000..97889c4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/stats.ts @@ -0,0 +1,98 @@ +import { Instances, NoteReactions, Notes, Users } from "@/models/index.js"; +import define from "../define.js"; +import { driveChart, notesChart, usersChart } from "@/services/chart/index.js"; +import { IsNull } from "typeorm"; + +export const meta = { + requireCredential: false, + requireCredentialPrivateMode: true, + + tags: ["meta"], + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + notesCount: { + type: "number", + optional: false, + nullable: false, + }, + originalNotesCount: { + type: "number", + optional: false, + nullable: false, + }, + usersCount: { + type: "number", + optional: false, + nullable: false, + }, + originalUsersCount: { + type: "number", + optional: false, + nullable: false, + }, + instances: { + type: "number", + optional: false, + nullable: false, + }, + driveUsageLocal: { + type: "number", + optional: false, + nullable: false, + }, + driveUsageRemote: { + type: "number", + optional: false, + nullable: false, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async () => { + const notesChartData = await notesChart.getChart("hour", 1, null); + const notesCount = + notesChartData.local.total[0] + notesChartData.remote.total[0]; + const originalNotesCount = notesChartData.local.total[0]; + + const usersChartData = await usersChart.getChart("hour", 1, null); + const usersCount = + usersChartData.local.total[0] + usersChartData.remote.total[0]; + const originalUsersCount = usersChartData.local.total[0]; + const driveChartData = await driveChart.getChart("hour", 1, null); + //TODO: fixme currently returns 0 + const driveUsageLocal = driveChartData.local.incSize[0]; + const driveUsageRemote = driveChartData.remote.incSize[0]; + + const [ + reactionsCount, + //originalReactionsCount, + instances, + ] = await Promise.all([ + NoteReactions.count({ cache: 3600000 }), // 1 hour + //NoteReactions.count({ where: { userHost: IsNull() }, cache: 3600000 }), + Instances.count({ cache: 3600000 }), + ]); + + return { + notesCount, + originalNotesCount, + usersCount, + originalUsersCount, + reactionsCount, + //originalReactionsCount, + instances, + driveUsageLocal, + driveUsageRemote, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/sw/register.ts b/packages/backend/src/server/api/endpoints/sw/register.ts new file mode 100644 index 0000000..6268ae2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/sw/register.ts @@ -0,0 +1,97 @@ +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { genId } from "@/misc/gen-id.js"; +import { SwSubscriptions } from "@/models/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + description: "Register to receive push notifications.", + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + state: { + type: "string", + optional: true, + nullable: false, + enum: ["already-subscribed", "subscribed"], + }, + key: { + type: "string", + optional: false, + nullable: true, + }, + userId: { + type: "string", + optional: true, + nullable: false, + }, + endpoint: { + type: "string", + optional: false, + nullable: false, + }, + sendReadMessage: { + type: "boolean", + optional: false, + nullable: false, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + endpoint: { type: "string" }, + auth: { type: "string" }, + publickey: { type: "string" }, + sendReadMessage: { type: "boolean", default: false }, + }, + required: ["endpoint", "auth", "publickey"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const subscription = await SwSubscriptions.findOneBy({ + userId: me.id, + endpoint: ps.endpoint, + auth: ps.auth, + publickey: ps.publickey, + }); + + const instance = await fetchMeta(true); + + // if already subscribed + if (subscription != null) { + return { + state: "already-subscribed" as const, + key: instance.swPublicKey, + userId: me.id, + endpoint: subscription.endpoint, + sendReadMessage: subscription.sendReadMessage, + }; + } + + await SwSubscriptions.insert({ + id: genId(), + createdAt: new Date(), + userId: me.id, + endpoint: ps.endpoint, + auth: ps.auth, + publickey: ps.publickey, + sendReadMessage: ps.sendReadMessage, + }); + + return { + state: "subscribed" as const, + key: instance.swPublicKey, + userId: me.id, + endpoint: ps.endpoint, + sendReadMessage: ps.sendReadMessage, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/sw/show-registration.ts b/packages/backend/src/server/api/endpoints/sw/show-registration.ts new file mode 100644 index 0000000..3ccb7de --- /dev/null +++ b/packages/backend/src/server/api/endpoints/sw/show-registration.ts @@ -0,0 +1,59 @@ +import { SwSubscriptions } from "@/models/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + description: "Check push notification registration exists.", + + res: { + type: "object", + optional: false, + nullable: true, + properties: { + userId: { + type: "string", + optional: false, + nullable: false, + }, + endpoint: { + type: "string", + optional: false, + nullable: false, + }, + sendReadMessage: { + type: "boolean", + optional: false, + nullable: false, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + endpoint: { type: "string" }, + }, + required: ["endpoint"], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const subscription = await SwSubscriptions.findOneBy({ + userId: me.id, + endpoint: ps.endpoint, + }); + + if (subscription != null) { + return { + userId: subscription.userId, + endpoint: subscription.endpoint, + sendReadMessage: subscription.sendReadMessage, + }; + } + + return null; +}); diff --git a/packages/backend/src/server/api/endpoints/sw/unregister.ts b/packages/backend/src/server/api/endpoints/sw/unregister.ts new file mode 100644 index 0000000..e2a40f5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/sw/unregister.ts @@ -0,0 +1,25 @@ +import { SwSubscriptions } from "@/models/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["account"], + + requireCredential: false, + + description: "Unregister from receiving push notifications.", +} as const; + +export const paramDef = { + type: "object", + properties: { + endpoint: { type: "string" }, + }, + required: ["endpoint"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + await SwSubscriptions.delete({ + ...(me ? { userId: me.id } : {}), + endpoint: ps.endpoint, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/sw/update-registration.ts b/packages/backend/src/server/api/endpoints/sw/update-registration.ts new file mode 100644 index 0000000..5ba53ee --- /dev/null +++ b/packages/backend/src/server/api/endpoints/sw/update-registration.ts @@ -0,0 +1,44 @@ +import { SwSubscriptions } from "@/models/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + description: "Unregister from receiving push notifications.", +} as const; + +export const paramDef = { + type: "object", + properties: { + endpoint: { type: "string" }, + sendReadMessage: { type: "boolean" }, + }, + required: ["endpoint"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const swSubscription = await SwSubscriptions.findOneBy({ + userId: me.id, + endpoint: ps.endpoint, + }); + + if (swSubscription === null) { + throw new Error("No such registration"); + } + + if (ps.sendReadMessage !== undefined) { + swSubscription.sendReadMessage = ps.sendReadMessage; + } + + await SwSubscriptions.update(swSubscription.id, { + sendReadMessage: swSubscription.sendReadMessage, + }); + + return { + userId: swSubscription.userId, + endpoint: swSubscription.endpoint, + sendReadMessage: swSubscription.sendReadMessage, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/test.ts b/packages/backend/src/server/api/endpoints/test.ts new file mode 100644 index 0000000..2c43c61 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/test.ts @@ -0,0 +1,25 @@ +import define from "../define.js"; + +export const meta = { + tags: ["non-productive"], + + description: "Endpoint for testing input validation.", + + requireCredential: false, +} as const; + +export const paramDef = { + type: "object", + properties: { + required: { type: "boolean" }, + string: { type: "string" }, + default: { type: "string", default: "hello" }, + nullableDefault: { type: "string", nullable: true, default: "hello" }, + id: { type: "string", format: "misskey:id" }, + }, + required: ["required"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + return ps; +}); diff --git a/packages/backend/src/server/api/endpoints/username/available.ts b/packages/backend/src/server/api/endpoints/username/available.ts new file mode 100644 index 0000000..6fa09ba --- /dev/null +++ b/packages/backend/src/server/api/endpoints/username/available.ts @@ -0,0 +1,51 @@ +import { IsNull } from "typeorm"; +import { Users, UsedUsernames } from "@/models/index.js"; +import config from "@/config/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["users"], + + requireCredential: false, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + available: { + type: "boolean", + optional: false, + nullable: false, + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + username: Users.localUsernameSchema, + }, + required: ["username"], +} as const; + +export default define(meta, paramDef, async (ps) => { + // Get exist + const exist = await Users.countBy({ + host: IsNull(), + usernameLower: ps.username.toLowerCase(), + }); + + const exist2 = await UsedUsernames.countBy({ + username: ps.username.toLowerCase(), + }); + + const reserved = config.reservedUsernames?.includes( + ps.username.toLowerCase(), + ); + + return { + available: exist === 0 && exist2 === 0 && !reserved, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts new file mode 100644 index 0000000..18ea197 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users.ts @@ -0,0 +1,136 @@ +import { Users } from "@/models/index.js"; +import define from "../define.js"; +import { generateMutedUserQueryForUsers } from "../common/generate-muted-user-query.js"; +import { generateBlockQueryForUsers } from "../common/generate-block-query.js"; +import { generateMinorBadgeUserVisibilityQuery } from "../common/generate-minor-badge-visibility-query.js"; + +export const meta = { + tags: ["users"], + + requireCredential: true, + requireCredentialPrivateMode: true, + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "UserDetailed", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + offset: { type: "integer", default: 0 }, + sort: { + type: "string", + enum: [ + "+follower", + "-follower", + "+createdAt", + "-createdAt", + "+updatedAt", + "-updatedAt", + ], + }, + state: { + type: "string", + enum: ["all", "admin", "moderator", "adminOrModerator", "alive"], + default: "all", + }, + origin: { + type: "string", + enum: ["combined", "local", "remote"], + default: "local", + }, + hostname: { + type: "string", + nullable: true, + default: null, + description: "The local host is represented with `null`.", + }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = Users.createQueryBuilder("user"); + query.where("user.isExplorable = TRUE"); + + switch (ps.state) { + case "admin": + query.andWhere("user.isAdmin = TRUE"); + break; + case "moderator": + query.andWhere("user.isModerator = TRUE"); + break; + case "adminOrModerator": + query.andWhere("user.isAdmin = TRUE OR user.isModerator = TRUE"); + break; + case "alive": + query.andWhere("user.updatedAt > :date", { + date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5), + }); + break; + } + + switch (ps.origin) { + case "local": + query.andWhere("user.host IS NULL"); + break; + case "remote": + query.andWhere("user.host IS NOT NULL"); + break; + } + + if (ps.hostname) { + query.andWhere("user.host = :hostname", { + hostname: ps.hostname.toLowerCase(), + }); + } + + switch (ps.sort) { + case "+follower": + query.orderBy("user.followersCount", "DESC"); + break; + case "-follower": + query.orderBy("user.followersCount", "ASC"); + break; + case "+createdAt": + query.orderBy("user.createdAt", "DESC"); + break; + case "-createdAt": + query.orderBy("user.createdAt", "ASC"); + break; + case "+updatedAt": + query + .andWhere("user.updatedAt IS NOT NULL") + .orderBy("user.updatedAt", "DESC"); + break; + case "-updatedAt": + query + .andWhere("user.updatedAt IS NOT NULL") + .orderBy("user.updatedAt", "ASC"); + break; + default: + query.orderBy("user.id", "ASC"); + break; + } + + if (me) generateMutedUserQueryForUsers(query, me); + if (me) generateBlockQueryForUsers(query, me); + generateMinorBadgeUserVisibilityQuery(query, me); + + query.take(ps.limit); + query.skip(ps.offset); + + const users = await query.getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/clips.ts b/packages/backend/src/server/api/endpoints/users/clips.ts new file mode 100644 index 0000000..0dc90b8 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/clips.ts @@ -0,0 +1,47 @@ +import { Clips } from "@/models/index.js"; +import define from "../../define.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["users", "clips"], + requireCredentialPrivateMode: true, + + description: "Show all clips this user owns.", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Clip", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + Clips.createQueryBuilder("clip"), + ps.sinceId, + ps.untilId, + ) + .andWhere("clip.userId = :userId", { userId: ps.userId }) + .andWhere("clip.isPublic = true"); + + const clips = await query.take(ps.limit).getMany(); + + return await Clips.packMany(clips); +}); diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts new file mode 100644 index 0000000..31719ba --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/followers.ts @@ -0,0 +1,124 @@ +import { IsNull } from "typeorm"; +import { Users, Followings, UserProfiles } from "@/models/index.js"; +import { toPunyNullable } from "@/misc/convert-host.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["users"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + description: "Show everyone that follows this user.", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Following", + }, + }, + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "27fa5435-88ab-43de-9360-387de88727cd", + }, + + forbidden: { + message: "Forbidden.", + code: "FORBIDDEN", + id: "3c6a84db-d619-26af-ca14-06232a21df8a", + }, + + nullFollowers: { + message: "No followers found.", + code: "NULL_FOLLOWERS", + id: "174a6507-a6c2-4925-8e5d-92fd08aedc9e", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + }, + anyOf: [ + { + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], + }, + { + properties: { + username: { type: "string" }, + host: { + type: "string", + nullable: true, + description: "The local host is represented with `null`.", + }, + }, + required: ["username", "host"], + }, + ], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy( + ps.userId != null + ? { id: ps.userId } + : { + usernameLower: ps.username!.toLowerCase(), + host: toPunyNullable(ps.host) ?? IsNull(), + }, + ); + + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + if (profile.ffVisibility === "private") { + if (me == null || me.id !== user.id) { + throw new ApiError(meta.errors.forbidden); + } + } else if (profile.ffVisibility === "followers") { + if (me == null) { + throw new ApiError(meta.errors.forbidden); + } else if (me.id !== user.id) { + const isFollowed = await Followings.exist({ + where: { + followeeId: user.id, + followerId: me.id, + }, + }); + if (!isFollowed) { + throw new ApiError(meta.errors.nullFollowers); + } + } + } + + const query = makePaginationQuery( + Followings.createQueryBuilder("following"), + ps.sinceId, + ps.untilId, + ) + .andWhere("following.followeeId = :userId", { userId: user.id }) + .innerJoinAndSelect("following.follower", "follower"); + + const followings = await query.take(ps.limit).getMany(); + + return await Followings.packMany(followings, me, { populateFollower: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts new file mode 100644 index 0000000..1c1da0e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -0,0 +1,123 @@ +import { IsNull } from "typeorm"; +import { Users, Followings, UserProfiles } from "@/models/index.js"; +import { toPunyNullable } from "@/misc/convert-host.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["users"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + description: "Show everyone that this user is following.", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Following", + }, + }, + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "63e4aba4-4156-4e53-be25-c9559e42d71b", + }, + + forbidden: { + message: "Forbidden.", + code: "FORBIDDEN", + id: "f6cdb0df-c19f-ec5c-7dbb-0ba84a1f92ba", + }, + cannot_find: { + message: "Cannot find the following.", + code: "CANNOT_FIND", + id: "7a55f0d7-8e06-4a7e-9c77-ee7d59b25a82", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + }, + anyOf: [ + { + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], + }, + { + properties: { + username: { type: "string" }, + host: { + type: "string", + nullable: true, + description: "The local host is represented with `null`.", + }, + }, + required: ["username", "host"], + }, + ], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy( + ps.userId != null + ? { id: ps.userId } + : { + usernameLower: ps.username!.toLowerCase(), + host: toPunyNullable(ps.host) ?? IsNull(), + }, + ); + + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + if (profile.ffVisibility === "private") { + if (me == null || me.id !== user.id) { + throw new ApiError(meta.errors.forbidden); + } + } else if (profile.ffVisibility === "followers") { + if (me == null) { + throw new ApiError(meta.errors.forbidden); + } else if (me.id !== user.id) { + const isFollowing = await Followings.exist({ + where: { + followeeId: user.id, + followerId: me.id, + }, + }); + if (!isFollowing) { + throw new ApiError(meta.errors.cannot_find); + } + } + } + + const query = makePaginationQuery( + Followings.createQueryBuilder("following"), + ps.sinceId, + ps.untilId, + ) + .andWhere("following.followerId = :userId", { userId: user.id }) + .innerJoinAndSelect("following.followee", "followee"); + + const followings = await query.take(ps.limit).getMany(); + + return await Followings.packMany(followings, me, { populateFollowee: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/gallery/posts.ts b/packages/backend/src/server/api/endpoints/users/gallery/posts.ts new file mode 100644 index 0000000..5d64fb4 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/gallery/posts.ts @@ -0,0 +1,45 @@ +import define from "../../../define.js"; +import { GalleryPosts } from "@/models/index.js"; +import { makePaginationQuery } from "../../../common/make-pagination-query.js"; + +export const meta = { + tags: ["users", "gallery"], + requireCredentialPrivateMode: true, + + description: "Show all gallery posts by the given user.", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "GalleryPost", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + GalleryPosts.createQueryBuilder("post"), + ps.sinceId, + ps.untilId, + ).andWhere("post.userId = :userId", { userId: ps.userId }); + + const posts = await query.take(ps.limit).getMany(); + + return await GalleryPosts.packMany(posts, user); +}); diff --git a/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts new file mode 100644 index 0000000..9722804 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/get-frequently-replied-users.ts @@ -0,0 +1,124 @@ +import { Not, In, IsNull } from "typeorm"; +import { maximum } from "@/prelude/array.js"; +import { Notes, Users } from "@/models/index.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getUser } from "../../common/getters.js"; + +export const meta = { + tags: ["users"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + description: + "Get a list of other users that the specified user frequently replies to.", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + properties: { + user: { + type: "object", + optional: false, + nullable: false, + ref: "UserDetailed", + }, + weight: { + type: "number", + optional: false, + nullable: false, + }, + }, + }, + }, + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "e6965129-7b2a-40a4-bae2-cd84cd434822", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + // Lookup user + const user = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Fetch recent notes + const recentNotes = await Notes.find({ + where: { + userId: user.id, + replyId: Not(IsNull()), + }, + order: { + id: -1, + }, + take: 1000, + select: ["replyId"], + }); + + // 投稿が少なかったら中断 + if (recentNotes.length === 0) { + return []; + } + + // TODO ミュートを考慮 + const replyTargetNotes = await Notes.find({ + where: { + id: In(recentNotes.map((p) => p.replyId)), + }, + select: ["userId"], + }); + + const repliedUsers: any = {}; + + // Extract replies from recent notes + for (const userId of replyTargetNotes.map((x) => x.userId.toString())) { + if (repliedUsers[userId]) { + repliedUsers[userId]++; + } else { + repliedUsers[userId] = 1; + } + } + + // Calc peak + const peak = maximum(Object.values(repliedUsers)); + + // Sort replies by frequency + const repliedUsersSorted = Object.keys(repliedUsers).sort( + (a, b) => repliedUsers[b] - repliedUsers[a], + ); + + // Extract top replied users + const topRepliedUsers = repliedUsersSorted.slice(0, ps.limit); + + // Make replies object (includes weights) + const repliesObj = await Promise.all( + topRepliedUsers.map(async (user) => ({ + user: await Users.pack(user, me, { detail: true }), + weight: repliedUsers[user] / peak, + })), + ); + + return repliesObj; +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/create.ts b/packages/backend/src/server/api/endpoints/users/groups/create.ts new file mode 100644 index 0000000..76bd78c --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/create.ts @@ -0,0 +1,49 @@ +import { UserGroups, UserGroupJoinings } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import type { UserGroup } from "@/models/entities/user-group.js"; +import type { UserGroupJoining } from "@/models/entities/user-group-joining.js"; +import define from "../../../define.js"; + +export const meta = { + tags: ["groups"], + + requireCredential: true, + + kind: "write:user-groups", + + description: "Create a new group.", + + res: { + type: "object", + optional: false, + nullable: false, + ref: "UserGroup", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 100 }, + }, + required: ["name"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const userGroup = await UserGroups.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + } as UserGroup).then((x) => UserGroups.findOneByOrFail(x.identifiers[0])); + + // Push the owner + await UserGroupJoinings.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + userGroupId: userGroup.id, + } as UserGroupJoining); + + return await UserGroups.pack(userGroup); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/delete.ts b/packages/backend/src/server/api/endpoints/users/groups/delete.ts new file mode 100644 index 0000000..81c15ad --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/delete.ts @@ -0,0 +1,42 @@ +import { UserGroups } from "@/models/index.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + tags: ["groups"], + + requireCredential: true, + + kind: "write:user-groups", + + description: "Delete an existing group.", + + errors: { + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "63dbd64c-cd77-413f-8e08-61781e210b38", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + groupId: { type: "string", format: "misskey:id" }, + }, + required: ["groupId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const userGroup = await UserGroups.findOneBy({ + id: ps.groupId, + userId: user.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + await UserGroups.delete(userGroup.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/emojis/create.ts b/packages/backend/src/server/api/endpoints/users/groups/emojis/create.ts new file mode 100644 index 0000000..7a1251a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/emojis/create.ts @@ -0,0 +1,89 @@ +import define from "../../../../define.js"; +import { ApiError } from "../../../../error.js"; +import { DriveFiles, UserEmojis, UserGroups } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { getEmojiSize } from "@/misc/emoji-meta.js"; +import { clearGroupEmojiCache } from "@/misc/populate-emojis.js"; + +function normalizeMimeType(type: string | null | undefined): string | null { + const mime = type?.split(";")[0]?.trim().toLowerCase(); + return mime && mime.length <= 64 ? mime : null; +} + +export const meta = { + tags: ["groups"], + requireCredential: true, + kind: "write:user-groups", + errors: { + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "ab752eba-3f77-4746-b805-1457719f648d", + }, + noSuchFile: { + message: "No such file.", + code: "NO_SUCH_FILE", + id: "be2812bf-55c0-4fd9-8d1b-813578fc81f9", + }, + alreadyExists: { + message: "Group emoji already exists.", + code: "ALREADY_EXISTS", + id: "7957d717-e009-47c8-b32d-37a13afed4db", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + groupId: { type: "string", format: "misskey:id" }, + name: { type: "string", pattern: "^[a-z0-9_]{1,64}$" }, + fileId: { type: "string", format: "misskey:id" }, + glyph: { type: "boolean", default: false }, + }, + required: ["groupId", "name", "fileId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const group = await UserGroups.findOneBy({ id: ps.groupId, userId: me.id }); + if (!group) throw new ApiError(meta.errors.noSuchGroup); + + const file = await DriveFiles.findOneBy({ id: ps.fileId, userId: me.id }); + if (!file || !file.type.startsWith("image/")) + throw new ApiError(meta.errors.noSuchFile); + + const exists = await UserEmojis.findOneBy({ + name: ps.name, + userGroupId: group.id, + }); + if (exists) throw new ApiError(meta.errors.alreadyExists); + + const size = await getEmojiSize(file.url).catch(() => ({ + width: null, + height: null, + })); + const emoji = await UserEmojis.insert({ + id: genId(), + createdAt: new Date(), + name: ps.name, + userId: null, + userGroupId: group.id, + originalUrl: file.url, + publicUrl: file.webpublicUrl ?? file.url, + type: normalizeMimeType(file.webpublicType ?? file.type), + glyph: ps.glyph, + width: size.width || null, + height: size.height || null, + }).then((x) => UserEmojis.findOneByOrFail(x.identifiers[0])); + await clearGroupEmojiCache(emoji.name, group.username); + + return { + id: emoji.id, + name: emoji.name, + url: emoji.publicUrl || emoji.originalUrl, + glyph: emoji.glyph, + glyphUrl: emoji.glyph ? emoji.originalUrl : null, + width: emoji.width, + height: emoji.height, + }; +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/emojis/delete.ts b/packages/backend/src/server/api/endpoints/users/groups/emojis/delete.ts new file mode 100644 index 0000000..071def9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/emojis/delete.ts @@ -0,0 +1,38 @@ +import define from "../../../../define.js"; +import { ApiError } from "../../../../error.js"; +import { UserEmojis, UserGroups } from "@/models/index.js"; +import { clearGroupEmojiCache } from "@/misc/populate-emojis.js"; + +export const meta = { + tags: ["groups"], + requireCredential: true, + kind: "write:user-groups", + errors: { + noSuchEmoji: { + message: "No such group emoji.", + code: "NO_SUCH_GROUP_EMOJI", + id: "e22d8bd7-d9c5-4470-8ef0-9a9095985b3f", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + id: { type: "string", format: "misskey:id" }, + }, + required: ["id"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const emoji = await UserEmojis.findOneBy({ id: ps.id }); + if (!emoji?.userGroupId) throw new ApiError(meta.errors.noSuchEmoji); + const group = await UserGroups.findOneBy({ + id: emoji.userGroupId, + userId: me.id, + }); + if (!group) throw new ApiError(meta.errors.noSuchEmoji); + + await UserEmojis.delete(emoji.id); + await clearGroupEmojiCache(emoji.name, group.username); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/emojis/list.ts b/packages/backend/src/server/api/endpoints/users/groups/emojis/list.ts new file mode 100644 index 0000000..fcdc8c2 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/emojis/list.ts @@ -0,0 +1,49 @@ +import define from "../../../../define.js"; +import { ApiError } from "../../../../error.js"; +import { UserEmojis, UserGroupJoinings, UserGroups } from "@/models/index.js"; + +export const meta = { + tags: ["groups"], + requireCredential: true, + kind: "read:user-groups", + errors: { + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "e9f78109-710a-44fd-8ab2-51582f65dd97", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + groupId: { type: "string", format: "misskey:id" }, + }, + required: ["groupId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const group = await UserGroups.findOneBy({ id: ps.groupId }); + if (!group) throw new ApiError(meta.errors.noSuchGroup); + const member = await UserGroupJoinings.findOneBy({ + userGroupId: group.id, + userId: me.id, + }); + if (group.userId !== me.id && !member) throw new ApiError(meta.errors.noSuchGroup); + + const emojis = await UserEmojis.find({ + where: { userGroupId: group.id }, + order: { createdAt: "DESC" }, + }); + + return emojis.map((emoji) => ({ + id: emoji.id, + name: emoji.name, + url: emoji.publicUrl || emoji.originalUrl, + glyph: emoji.glyph, + glyphUrl: emoji.glyph ? emoji.originalUrl : null, + width: emoji.width, + height: emoji.height, + })); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts b/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts new file mode 100644 index 0000000..5cb3a7b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/invitations/accept.ts @@ -0,0 +1,56 @@ +import { UserGroupJoinings, UserGroupInvitations } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import type { UserGroupJoining } from "@/models/entities/user-group-joining.js"; +import { ApiError } from "../../../../error.js"; +import define from "../../../../define.js"; + +export const meta = { + tags: ["groups", "users"], + + requireCredential: true, + + kind: "write:user-groups", + + description: "Join a group the authenticated user has been invited to.", + + errors: { + noSuchInvitation: { + message: "No such invitation.", + code: "NO_SUCH_INVITATION", + id: "98c11eca-c890-4f42-9806-c8c8303ebb5e", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + invitationId: { type: "string", format: "misskey:id" }, + }, + required: ["invitationId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Fetch the invitation + const invitation = await UserGroupInvitations.findOneBy({ + id: ps.invitationId, + }); + + if (invitation == null) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + if (invitation.userId !== user.id) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + // Push the user + await UserGroupJoinings.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + userGroupId: invitation.userGroupId, + } as UserGroupJoining); + + UserGroupInvitations.delete(invitation.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts b/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts new file mode 100644 index 0000000..c04ebed --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/invitations/reject.ts @@ -0,0 +1,47 @@ +import { UserGroupInvitations } from "@/models/index.js"; +import define from "../../../../define.js"; +import { ApiError } from "../../../../error.js"; + +export const meta = { + tags: ["groups", "users"], + + requireCredential: true, + + kind: "write:user-groups", + + description: + "Delete an existing group invitation for the authenticated user without joining the group.", + + errors: { + noSuchInvitation: { + message: "No such invitation.", + code: "NO_SUCH_INVITATION", + id: "ad7471d4-2cd9-44b4-ac68-e7136b4ce656", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + invitationId: { type: "string", format: "misskey:id" }, + }, + required: ["invitationId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Fetch the invitation + const invitation = await UserGroupInvitations.findOneBy({ + id: ps.invitationId, + }); + + if (invitation == null) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + if (invitation.userId !== user.id) { + throw new ApiError(meta.errors.noSuchInvitation); + } + + await UserGroupInvitations.delete(invitation.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/invite.ts b/packages/backend/src/server/api/endpoints/users/groups/invite.ts new file mode 100644 index 0000000..10cc215 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/invite.ts @@ -0,0 +1,108 @@ +import { + UserGroups, + UserGroupJoinings, + UserGroupInvitations, +} from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import type { UserGroupInvitation } from "@/models/entities/user-group-invitation.js"; +import { createNotification } from "@/services/create-notification.js"; +import { getUser } from "../../../common/getters.js"; +import { ApiError } from "../../../error.js"; +import define from "../../../define.js"; + +export const meta = { + tags: ["groups", "users"], + + requireCredential: true, + + kind: "write:user-groups", + + description: "Invite a user to an existing group.", + + errors: { + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "583f8bc0-8eee-4b78-9299-1e14fc91e409", + }, + + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "da52de61-002c-475b-90e1-ba64f9cf13a8", + }, + + alreadyAdded: { + message: "That user has already been added to that group.", + code: "ALREADY_ADDED", + id: "7e35c6a0-39b2-4488-aea6-6ee20bd5da2c", + }, + + alreadyInvited: { + message: "That user has already been invited to that group.", + code: "ALREADY_INVITED", + id: "ee0f58b4-b529-4d13-b761-b9a3e69f97e6", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + groupId: { type: "string", format: "misskey:id" }, + userId: { type: "string", format: "misskey:id" }, + }, + required: ["groupId", "userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOneBy({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const joining = await UserGroupJoinings.findOneBy({ + userGroupId: userGroup.id, + userId: user.id, + }); + + if (joining) { + throw new ApiError(meta.errors.alreadyAdded); + } + + const existInvitation = await UserGroupInvitations.findOneBy({ + userGroupId: userGroup.id, + userId: user.id, + }); + + if (existInvitation) { + throw new ApiError(meta.errors.alreadyInvited); + } + + const invitation = await UserGroupInvitations.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + userGroupId: userGroup.id, + } as UserGroupInvitation).then((x) => + UserGroupInvitations.findOneByOrFail(x.identifiers[0]), + ); + + // 通知を作成 + createNotification(user.id, "groupInvited", { + notifierId: me.id, + userGroupInvitationId: invitation.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/joined.ts b/packages/backend/src/server/api/endpoints/users/groups/joined.ts new file mode 100644 index 0000000..8422cf5 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/joined.ts @@ -0,0 +1,48 @@ +import { Not, In } from "typeorm"; +import { UserGroups, UserGroupJoinings } from "@/models/index.js"; +import define from "../../../define.js"; + +export const meta = { + tags: ["groups", "account"], + + requireCredential: true, + + kind: "read:user-groups", + + description: "List the groups that the authenticated user is a member of.", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "UserGroup", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const ownedGroups = await UserGroups.findBy({ + userId: me.id, + }); + + const joinings = await UserGroupJoinings.findBy({ + userId: me.id, + ...(ownedGroups.length > 0 + ? { + userGroupId: Not(In(ownedGroups.map((x) => x.id))), + } + : {}), + }); + + return await Promise.all(joinings.map((x) => UserGroups.pack(x.userGroupId))); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/leave.ts b/packages/backend/src/server/api/endpoints/users/groups/leave.ts new file mode 100644 index 0000000..d963b18 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/leave.ts @@ -0,0 +1,53 @@ +import { UserGroups, UserGroupJoinings } from "@/models/index.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + tags: ["groups", "users"], + + requireCredential: true, + + kind: "write:user-groups", + + description: + "Leave a group. The owner of a group can not leave. They must transfer ownership or delete the group instead.", + + errors: { + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "62780270-1f67-5dc0-daca-3eb510612e31", + }, + + youAreOwner: { + message: "Your are the owner.", + code: "YOU_ARE_OWNER", + id: "b6d6e0c2-ef8a-9bb8-653d-79f4a3107c69", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + groupId: { type: "string", format: "misskey:id" }, + }, + required: ["groupId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOneBy({ + id: ps.groupId, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + if (me.id === userGroup.userId) { + throw new ApiError(meta.errors.youAreOwner); + } + + await UserGroupJoinings.delete({ userGroupId: userGroup.id, userId: me.id }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/owned.ts b/packages/backend/src/server/api/endpoints/users/groups/owned.ts new file mode 100644 index 0000000..d86185f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/owned.ts @@ -0,0 +1,38 @@ +import { UserGroups } from "@/models/index.js"; +import define from "../../../define.js"; + +export const meta = { + tags: ["groups", "account"], + + requireCredential: true, + + kind: "read:user-groups", + + description: "List the groups that the authenticated user is the owner of.", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "UserGroup", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const userGroups = await UserGroups.findBy({ + userId: me.id, + }); + + return await Promise.all(userGroups.map((x) => UserGroups.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/pull.ts b/packages/backend/src/server/api/endpoints/users/groups/pull.ts new file mode 100644 index 0000000..1f79a2d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/pull.ts @@ -0,0 +1,73 @@ +import { UserGroups, UserGroupJoinings } from "@/models/index.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { getUser } from "../../../common/getters.js"; + +export const meta = { + tags: ["groups", "users"], + + requireCredential: true, + + kind: "write:user-groups", + + description: + "Removes a specified user from a group. The owner can not be removed.", + + errors: { + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "4662487c-05b1-4b78-86e5-fd46998aba74", + }, + + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "0b5cc374-3681-41da-861e-8bc1146f7a55", + }, + + isOwner: { + message: "The user is the owner.", + code: "IS_OWNER", + id: "1546eed5-4414-4dea-81c1-b0aec4f6d2af", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + groupId: { type: "string", format: "misskey:id" }, + userId: { type: "string", format: "misskey:id" }, + }, + required: ["groupId", "userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOneBy({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + if (user.id === userGroup.userId) { + throw new ApiError(meta.errors.isOwner); + } + + // Pull the user + await UserGroupJoinings.delete({ + userGroupId: userGroup.id, + userId: user.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/show-by-username.ts b/packages/backend/src/server/api/endpoints/users/groups/show-by-username.ts new file mode 100644 index 0000000..68e5048 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/show-by-username.ts @@ -0,0 +1,40 @@ +import { UserGroups } from "@/models/index.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + tags: ["groups"], + requireCredential: false, + requireCredentialPrivateMode: true, + res: { + type: "object", + optional: false, + nullable: false, + ref: "UserGroup", + }, + errors: { + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "e2eb1dcc-d778-4a5f-b3c1-f7a0c7c3f9ad", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + username: { type: "string", minLength: 1, maxLength: 64 }, + }, + required: ["username"], +} as const; + +export default define(meta, paramDef, async (ps) => { + const group = await UserGroups.findOneBy({ + username: ps.username.toLowerCase(), + isPrivate: false, + }); + if (!group) throw new ApiError(meta.errors.noSuchGroup); + + return UserGroups.pack(group); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/show.ts b/packages/backend/src/server/api/endpoints/users/groups/show.ts new file mode 100644 index 0000000..46f4410 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/show.ts @@ -0,0 +1,58 @@ +import { UserGroups, UserGroupJoinings } from "@/models/index.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + tags: ["groups", "account"], + + requireCredential: true, + + kind: "read:user-groups", + + description: "Show the properties of a group.", + + res: { + type: "object", + optional: false, + nullable: false, + ref: "UserGroup", + }, + + errors: { + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "ea04751e-9b7e-487b-a509-330fb6bd6b9b", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + groupId: { type: "string", format: "misskey:id" }, + }, + required: ["groupId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOneBy({ + id: ps.groupId, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + const joining = await UserGroupJoinings.findOneBy({ + userId: me.id, + userGroupId: userGroup.id, + }); + + if (joining == null && userGroup.userId !== me.id) { + throw new ApiError(meta.errors.noSuchGroup); + } + + return await UserGroups.pack(userGroup); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/transfer.ts b/packages/backend/src/server/api/endpoints/users/groups/transfer.ts new file mode 100644 index 0000000..0322441 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/transfer.ts @@ -0,0 +1,85 @@ +import { UserGroups, UserGroupJoinings } from "@/models/index.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { getUser } from "../../../common/getters.js"; + +export const meta = { + tags: ["groups", "users"], + + requireCredential: true, + + kind: "write:user-groups", + + description: + "Transfer ownership of a group from the authenticated user to another user.", + + res: { + type: "object", + optional: false, + nullable: false, + ref: "UserGroup", + }, + + errors: { + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "8e31d36b-2f88-4ccd-a438-e2d78a9162db", + }, + + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "711f7ebb-bbb9-4dfa-b540-b27809fed5e9", + }, + + noSuchGroupMember: { + message: "No such group member.", + code: "NO_SUCH_GROUP_MEMBER", + id: "d31bebee-196d-42c2-9a3e-9474d4be6cc4", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + groupId: { type: "string", format: "misskey:id" }, + userId: { type: "string", format: "misskey:id" }, + }, + required: ["groupId", "userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOneBy({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // Fetch the user + const user = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + const joining = await UserGroupJoinings.findOneBy({ + userGroupId: userGroup.id, + userId: user.id, + }); + + if (joining == null) { + throw new ApiError(meta.errors.noSuchGroupMember); + } + + await UserGroups.update(userGroup.id, { + userId: ps.userId, + }); + + return await UserGroups.pack(userGroup.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/groups/update.ts b/packages/backend/src/server/api/endpoints/users/groups/update.ts new file mode 100644 index 0000000..d0b60a9 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/groups/update.ts @@ -0,0 +1,94 @@ +import { DriveFiles, UserGroups } from "@/models/index.js"; +import type { UserGroup } from "@/models/entities/user-group.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + tags: ["groups"], + + requireCredential: true, + + kind: "write:user-groups", + + description: "Update the properties of a group.", + + res: { + type: "object", + optional: false, + nullable: false, + ref: "UserGroup", + }, + + errors: { + noSuchGroup: { + message: "No such group.", + code: "NO_SUCH_GROUP", + id: "9081cda3-7a9e-4fac-a6ce-908d70f282f6", + }, + noSuchFile: { + message: "No such file.", + code: "NO_SUCH_FILE", + id: "45861e5e-75d4-4353-b8a9-a72fd2b4c62f", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + groupId: { type: "string", format: "misskey:id" }, + name: { type: "string", minLength: 1, maxLength: 100 }, + username: { + type: "string", + pattern: "^[a-zA-Z0-9_]{1,64}$", + nullable: true, + }, + allowCalls: { type: "boolean" }, + iconFileId: { type: "string", format: "misskey:id", nullable: true }, + symbolFileId: { type: "string", format: "misskey:id", nullable: true }, + }, + required: ["groupId", "name"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + // Fetch the group + const userGroup = await UserGroups.findOneBy({ + id: ps.groupId, + userId: me.id, + }); + + if (userGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + const updates: Partial = { + name: ps.name, + }; + + if (ps.username !== undefined) updates.username = ps.username?.toLowerCase() ?? null; + if (typeof ps.allowCalls === "boolean") updates.allowCalls = ps.allowCalls; + if (ps.iconFileId !== undefined) { + if (ps.iconFileId) { + const file = await DriveFiles.findOneBy({ + id: ps.iconFileId, + userId: me.id, + }); + if (!file) throw new ApiError(meta.errors.noSuchFile); + } + updates.iconFileId = ps.iconFileId; + } + if (ps.symbolFileId !== undefined) { + if (ps.symbolFileId) { + const file = await DriveFiles.findOneBy({ + id: ps.symbolFileId, + userId: me.id, + }); + if (!file) throw new ApiError(meta.errors.noSuchFile); + } + updates.symbolFileId = ps.symbolFileId; + } + + await UserGroups.update(userGroup.id, updates); + + return await UserGroups.pack(userGroup.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/create.ts b/packages/backend/src/server/api/endpoints/users/lists/create.ts new file mode 100644 index 0000000..6bbbf60 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/create.ts @@ -0,0 +1,40 @@ +import { UserLists } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import type { UserList } from "@/models/entities/user-list.js"; +import define from "../../../define.js"; + +export const meta = { + tags: ["lists"], + + requireCredential: true, + + kind: "write:account", + + description: "Create a new list of users.", + + res: { + type: "object", + optional: false, + nullable: false, + ref: "UserList", + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + name: { type: "string", minLength: 1, maxLength: 100 }, + }, + required: ["name"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const userList = await UserLists.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: ps.name, + } as UserList).then((x) => UserLists.findOneByOrFail(x.identifiers[0])); + + return await UserLists.pack(userList); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/delete-all.ts b/packages/backend/src/server/api/endpoints/users/lists/delete-all.ts new file mode 100644 index 0000000..49c4cf6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/delete-all.ts @@ -0,0 +1,35 @@ +import { UserLists } from "@/models/index.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + tags: ["lists"], + + requireCredential: true, + + kind: "write:account", + + description: "Delete all lists of users.", + + errors: { + noSuchList: { + message: "No such list.", + code: "NO_SUCH_LIST", + id: "78436795-db79-42f5-b1e2-55ea2cf19166", + }, + }, +} as const; + +export const paramDef = { + type: "object", +} as const; + +export default define(meta, paramDef, async (ps, user) => { + while ((await UserLists.findOneBy({ userId: user.id })) != null) { + const userList = await UserLists.findOneBy({ userId: user.id }); + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + await UserLists.delete(userList.id); + } +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/delete.ts b/packages/backend/src/server/api/endpoints/users/lists/delete.ts new file mode 100644 index 0000000..4566295 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/delete.ts @@ -0,0 +1,42 @@ +import { UserLists } from "@/models/index.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + tags: ["lists"], + + requireCredential: true, + + kind: "write:account", + + description: "Delete an existing list of users.", + + errors: { + noSuchList: { + message: "No such list.", + code: "NO_SUCH_LIST", + id: "78436795-db79-42f5-b1e2-55ea2cf19166", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + listId: { type: "string", format: "misskey:id" }, + }, + required: ["listId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const userList = await UserLists.findOneBy({ + id: ps.listId, + userId: user.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + await UserLists.delete(userList.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/list.ts b/packages/backend/src/server/api/endpoints/users/lists/list.ts new file mode 100644 index 0000000..5d590ee --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/list.ts @@ -0,0 +1,38 @@ +import { UserLists } from "@/models/index.js"; +import define from "../../../define.js"; + +export const meta = { + tags: ["lists", "account"], + + requireCredential: true, + + kind: "read:account", + + description: "Show all lists that the authenticated user has created.", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "UserList", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: {}, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const userLists = await UserLists.findBy({ + userId: me.id, + }); + + return await Promise.all(userLists.map((x) => UserLists.pack(x))); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/pull.ts b/packages/backend/src/server/api/endpoints/users/lists/pull.ts new file mode 100644 index 0000000..b536d22 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/pull.ts @@ -0,0 +1,60 @@ +import { UserLists } from "@/models/index.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { getUser } from "../../../common/getters.js"; +import { pullUserFromUserList } from "@/services/user-list/pull.js"; + +export const meta = { + tags: ["lists", "users"], + + requireCredential: true, + + kind: "write:account", + + description: "Remove a user from a list.", + + errors: { + noSuchList: { + message: "No such list.", + code: "NO_SUCH_LIST", + id: "7f44670e-ab16-43b8-b4c1-ccd2ee89cc02", + }, + + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "588e7f72-c744-4a61-b180-d354e912bda2", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + listId: { type: "string", format: "misskey:id" }, + userId: { type: "string", format: "misskey:id" }, + }, + required: ["listId", "userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + // Fetch the list + const userList = await UserLists.findOneBy({ + id: ps.listId, + userId: me.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + // Fetch the user + const user = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Pull the user + await pullUserFromUserList(user, userList); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/push.ts b/packages/backend/src/server/api/endpoints/users/lists/push.ts new file mode 100644 index 0000000..3b4a878 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/push.ts @@ -0,0 +1,113 @@ +import { pushUserToUserList } from "@/services/user-list/push.js"; +import { UserLists, UserListJoinings, Blockings, Followings } from "@/models/index.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { getUser } from "../../../common/getters.js"; + +export const meta = { + tags: ["lists", "users"], + + requireCredential: true, + + kind: "write:account", + + description: "Add a user to an existing list.", + + errors: { + noSuchList: { + message: "No such list.", + code: "NO_SUCH_LIST", + id: "2214501d-ac96-4049-b717-91e42272a711", + }, + + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "a89abd3d-f0bc-4cce-beb1-2f446f4f1e6a", + }, + + alreadyAdded: { + message: "That user has already been added to that list.", + code: "ALREADY_ADDED", + id: "1de7c884-1595-49e9-857e-61f12f4d4fc5", + }, + + youHaveBeenBlocked: { + message: + "You cannot push this user because you have been blocked by this user.", + code: "YOU_HAVE_BEEN_BLOCKED", + id: "990232c5-3f9d-4d83-9f3f-ef27b6332a4b", + }, + + notFollowing: { + message: + "You cannot push this user because you are not following this user.", + code: "NOT_FOLLOWING", + id: "0a2e4d73-fe61-41fb-822c-d365ec81ba2a", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + listId: { type: "string", format: "misskey:id" }, + userId: { type: "string", format: "misskey:id" }, + }, + required: ["listId", "userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + // Fetch the list + const userList = await UserLists.findOneBy({ + id: ps.listId, + userId: me.id, + }); + + if (!userList) { + throw new ApiError(meta.errors.noSuchList); + } + + // Fetch the user + const user = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check blocking and following status + if (user.id !== me.id) { + const isBlocked = await Blockings.exist({ + where: { + blockerId: user.id, + blockeeId: me.id, + }, + }); + const isFollowed = await Followings.exist({ + where: { + followerId: me.id, + followeeId: user.id, + }, + }); + if (isBlocked) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + if (!isFollowed) { + throw new ApiError(meta.errors.notFollowing); + } + } + + const exist = await UserListJoinings.exist({ + where: { + userListId: ps.listId, + userId: user.id, + }, + }); + + if (exist) { + throw new ApiError(meta.errors.alreadyAdded); + } + + // Push the user + await pushUserToUserList(user, userList); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/show.ts b/packages/backend/src/server/api/endpoints/users/lists/show.ts new file mode 100644 index 0000000..cb4893b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/show.ts @@ -0,0 +1,50 @@ +import { UserLists } from "@/models/index.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; + +export const meta = { + tags: ["lists", "account"], + + requireCredential: true, + + kind: "read:account", + + description: "Show the properties of a list.", + + res: { + type: "object", + optional: false, + nullable: false, + ref: "UserList", + }, + + errors: { + noSuchList: { + message: "No such list.", + code: "NO_SUCH_LIST", + id: "7bc05c21-1d7a-41ae-88f1-66820f4dc686", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + listId: { type: "string", format: "misskey:id" }, + }, + required: ["listId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + // Fetch the list + const userList = await UserLists.findOneBy({ + id: ps.listId, + userId: me.id, + }); + + if (!userList) { + throw new ApiError(meta.errors.noSuchList); + } + + return await UserLists.pack(userList); +}); diff --git a/packages/backend/src/server/api/endpoints/users/lists/update.ts b/packages/backend/src/server/api/endpoints/users/lists/update.ts new file mode 100644 index 0000000..50bb65e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/lists/update.ts @@ -0,0 +1,68 @@ +import { UserListJoinings, UserLists, Users } from "@/models/index.js"; +import define from "../../../define.js"; +import { ApiError } from "../../../error.js"; +import { publishUserEvent } from "@/services/stream.js"; + +export const meta = { + tags: ["lists"], + + requireCredential: true, + + kind: "write:account", + + description: "Update the properties of a list.", + + res: { + type: "object", + optional: false, + nullable: false, + ref: "UserList", + }, + + errors: { + noSuchList: { + message: "No such list.", + code: "NO_SUCH_LIST", + id: "796666fe-3dff-4d39-becb-8a5932c1d5b7", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + listId: { type: "string", format: "misskey:id" }, + name: { type: "string", minLength: 1, maxLength: 100 }, + hideFromHomeTl: { type: "boolean", nullable: true }, + }, + required: ["listId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + // Fetch the list + const userList = await UserLists.findOneBy({ + id: ps.listId, + userId: user.id, + }); + + if (userList == null) { + throw new ApiError(meta.errors.noSuchList); + } + + const partial = { + name: ps.name ?? undefined, + hideFromHomeTl: ps.hideFromHomeTl ?? undefined + }; + if (Object.keys(partial).length > 0) await UserLists.update(userList.id, partial); + + if (ps.hideFromHomeTl != null) { + UserListJoinings.findBy({ userListId: ps.listId }) + .then(members => { + for (const member of members) { + publishUserEvent(userList.userId, ps.hideFromHomeTl ? "userHidden" : "userUnhidden", member.userId); + } + }); + } + + return await UserLists.pack(userList.id); +}); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts new file mode 100644 index 0000000..157ab3a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -0,0 +1,138 @@ +import { Brackets } from "typeorm"; +import { Notes } from "@/models/index.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getUser } from "../../common/getters.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; +import { generateMutedUserQuery } from "../../common/generate-muted-user-query.js"; +import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; + +export const meta = { + tags: ["users", "notes"], + + requireCredentialPrivateMode: true, + description: "Show all notes that this user created.", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Note", + }, + }, + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "27e494ba-2ac2-48e8-893b-10d4d8c2387b", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + includeReplies: { type: "boolean", default: true }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + sinceDate: { type: "integer" }, + untilDate: { type: "integer" }, + includeMyRenotes: { type: "boolean", default: true }, + withFiles: { type: "boolean", default: false }, + fileType: { + type: "array", + items: { + type: "string", + }, + }, + excludeNsfw: { type: "boolean", default: false }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + // Lookup user + const user = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + //#region Construct query + const query = makePaginationQuery( + Notes.createQueryBuilder("note"), + ps.sinceId, + ps.untilId, + ps.sinceDate, + ps.untilDate, + ) + .andWhere("note.userId = :userId", { userId: user.id }) + .innerJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.reply", "reply") + .leftJoinAndSelect("note.renote", "renote") + .leftJoinAndSelect("reply.user", "replyUser") + .leftJoinAndSelect("renote.user", "renoteUser"); + + generateVisibilityQuery(query, me); + if (me) { + generateMutedUserQuery(query, me, user); + generateBlockedUserQuery(query, me); + } + + if (ps.withFiles) { + query.andWhere("note.fileIds != '{}'"); + } + + if (ps.fileType != null) { + query.andWhere("note.fileIds != '{}'"); + query.andWhere( + new Brackets((qb) => { + for (const type of ps.fileType!) { + const i = ps.fileType!.indexOf(type); + qb.orWhere(`:type${i} = ANY(note.attachedFileTypes)`, { + [`type${i}`]: type, + }); + } + }), + ); + + if (ps.excludeNsfw) { + query.andWhere("note.cw IS NULL"); + query.andWhere( + '0 = (SELECT COUNT(*) FROM drive_file df WHERE df.id = ANY(note."fileIds") AND df."isSensitive" = TRUE)', + ); + } + } + + if (!ps.includeReplies) { + query.andWhere("note.replyId IS NULL"); + } + + if (ps.includeMyRenotes === false) { + query.andWhere( + new Brackets((qb) => { + qb.orWhere("note.userId != :userId", { userId: user.id }); + qb.orWhere("note.renoteId IS NULL"); + qb.orWhere("note.text IS NOT NULL"); + qb.orWhere("note.fileIds != '{}'"); + qb.orWhere( + '0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)', + ); + }), + ); + } + + //#endregion + + const timeline = await query.take(ps.limit).getMany(); + + return await Notes.packMany(timeline, me); +}); diff --git a/packages/backend/src/server/api/endpoints/users/pages.ts b/packages/backend/src/server/api/endpoints/users/pages.ts new file mode 100644 index 0000000..c08258b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/pages.ts @@ -0,0 +1,48 @@ +import { Pages } from "@/models/index.js"; +import define from "../../define.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["users", "pages"], + requireCredentialPrivateMode: true, + + description: "Show all pages this user created.", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "Page", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, user) => { + const query = makePaginationQuery( + Pages.createQueryBuilder("page"), + ps.sinceId, + ps.untilId, + ) + .andWhere("page.userId = :userId", { userId: ps.userId }) + .andWhere("page.visibility = 'public'") + .andWhere("page.isPublic = true"); + + const pages = await query.take(ps.limit).getMany(); + + return await Pages.packMany(pages); +}); diff --git a/packages/backend/src/server/api/endpoints/users/reactions.ts b/packages/backend/src/server/api/endpoints/users/reactions.ts new file mode 100644 index 0000000..6b6d32e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/reactions.ts @@ -0,0 +1,71 @@ +import { NoteReactions, UserProfiles } from "@/models/index.js"; +import define from "../../define.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; +import { generateVisibilityQuery } from "../../common/generate-visibility-query.js"; +import { ApiError } from "../../error.js"; + +export const meta = { + tags: ["users", "reactions"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + description: "Show all reactions this user made.", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "NoteReaction", + }, + }, + + errors: { + reactionsNotPublic: { + message: "Reactions of the user is not public.", + code: "REACTIONS_NOT_PUBLIC", + id: "673a7dd2-6924-1093-e0c0-e68456ceae5c", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + sinceDate: { type: "integer" }, + untilDate: { type: "integer" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const profile = await UserProfiles.findOneByOrFail({ userId: ps.userId }); + + if (me.id !== ps.userId && !profile.publicReactions) { + throw new ApiError(meta.errors.reactionsNotPublic); + } + + const query = makePaginationQuery( + NoteReactions.createQueryBuilder("reaction"), + ps.sinceId, + ps.untilId, + ps.sinceDate, + ps.untilDate, + ) + .andWhere("reaction.userId = :userId", { userId: ps.userId }) + .leftJoinAndSelect("reaction.note", "note"); + + generateVisibilityQuery(query, me); + + const reactions = await query.take(ps.limit).getMany(); + + return await NoteReactions.packMany(reactions, me, { withNote: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/recommendation.ts b/packages/backend/src/server/api/endpoints/users/recommendation.ts new file mode 100644 index 0000000..615cca7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/recommendation.ts @@ -0,0 +1,68 @@ +import { Users, Followings } from "@/models/index.js"; +import define from "../../define.js"; +import { generateMutedUserQueryForUsers } from "../../common/generate-muted-user-query.js"; +import { + generateBlockedUserQuery, + generateBlockQueryForUsers, +} from "../../common/generate-block-query.js"; +import { DAY } from "@/const.js"; + +export const meta = { + tags: ["users"], + + requireCredential: true, + + kind: "read:account", + + description: + "Show users that the authenticated user might be interested to follow.", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "UserDetailed", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + offset: { type: "integer", default: 0 }, + }, + required: [], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const query = Users.createQueryBuilder("user") + .where("user.isLocked = FALSE") + .andWhere("user.isExplorable = TRUE") + .andWhere("user.host IS NULL") + .andWhere("user.updatedAt >= :date", { + date: new Date(Date.now() - 7 * DAY), + }) + .andWhere("user.id != :meId", { meId: me.id }) + .orderBy("user.followersCount", "DESC"); + + generateMutedUserQueryForUsers(query, me); + generateBlockQueryForUsers(query, me); + generateBlockedUserQuery(query, me); + + const followingQuery = Followings.createQueryBuilder("following") + .select("following.followeeId") + .where("following.followerId = :followerId", { followerId: me.id }); + + query.andWhere(`user.id NOT IN (${followingQuery.getQuery()})`); + + query.setParameters(followingQuery.getParameters()); + + const users = await query.take(ps.limit).skip(ps.offset).getMany(); + + return await Users.packMany(users, me, { detail: true }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts new file mode 100644 index 0000000..5580eae --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -0,0 +1,151 @@ +import { Users } from "@/models/index.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["users"], + + requireCredential: true, + + description: + "Show the different kinds of relations between the authenticated user and the specified user(s).", + + res: { + optional: false, + nullable: false, + oneOf: [ + { + type: "object", + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + isFollowing: { + type: "boolean", + optional: false, + nullable: false, + }, + hasPendingFollowRequestFromYou: { + type: "boolean", + optional: false, + nullable: false, + }, + hasPendingFollowRequestToYou: { + type: "boolean", + optional: false, + nullable: false, + }, + isFollowed: { + type: "boolean", + optional: false, + nullable: false, + }, + isBlocking: { + type: "boolean", + optional: false, + nullable: false, + }, + isBlocked: { + type: "boolean", + optional: false, + nullable: false, + }, + isMuted: { + type: "boolean", + optional: false, + nullable: false, + }, + isRenoteMuted: { + type: "boolean", + optional: false, + nullable: false, + }, + }, + }, + { + type: "array", + items: { + type: "object", + optional: false, + nullable: false, + properties: { + id: { + type: "string", + optional: false, + nullable: false, + format: "id", + }, + isFollowing: { + type: "boolean", + optional: false, + nullable: false, + }, + hasPendingFollowRequestFromYou: { + type: "boolean", + optional: false, + nullable: false, + }, + hasPendingFollowRequestToYou: { + type: "boolean", + optional: false, + nullable: false, + }, + isFollowed: { + type: "boolean", + optional: false, + nullable: false, + }, + isBlocking: { + type: "boolean", + optional: false, + nullable: false, + }, + isBlocked: { + type: "boolean", + optional: false, + nullable: false, + }, + isMuted: { + type: "boolean", + optional: false, + nullable: false, + }, + isRenoteMuted: { + type: "boolean", + optional: false, + nullable: false, + }, + }, + }, + }, + ], + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { + anyOf: [ + { type: "string", format: "misskey:id" }, + { + type: "array", + items: { type: "string", format: "misskey:id" }, + }, + ], + }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const ids = Array.isArray(ps.userId) ? ps.userId : [ps.userId]; + + const relations = await Promise.all( + ids.map((id) => Users.getRelation(me.id, id)), + ); + + return Array.isArray(ps.userId) ? relations : relations[0]; +}); diff --git a/packages/backend/src/server/api/endpoints/users/report-abuse.ts b/packages/backend/src/server/api/endpoints/users/report-abuse.ts new file mode 100644 index 0000000..44d3f9b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/report-abuse.ts @@ -0,0 +1,106 @@ +import * as sanitizeHtml from "sanitize-html"; +import { publishAdminStream } from "@/services/stream.js"; +import { AbuseUserReports, Users } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { sendEmail } from "@/services/send-email.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { getUser } from "../../common/getters.js"; +import { ApiError } from "../../error.js"; +import define from "../../define.js"; + +export const meta = { + tags: ["users"], + + requireCredential: true, + + description: "File a report.", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "1acefcb5-0959-43fd-9685-b48305736cb5", + }, + + cannotReportYourself: { + message: "Cannot report yourself.", + code: "CANNOT_REPORT_YOURSELF", + id: "1e13149e-b1e8-43cf-902e-c01dbfcb202f", + }, + + cannotReportAdmin: { + message: "Cannot report the admin.", + code: "CANNOT_REPORT_THE_ADMIN", + id: "35e166f5-05fb-4f87-a2d5-adb42676d48f", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + comment: { type: "string", minLength: 1, maxLength: 2048 }, + }, + required: ["userId", "comment"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + // Lookup user + const user = await getUser(ps.userId).catch((e) => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") + throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + if (user.id === me.id) { + throw new ApiError(meta.errors.cannotReportYourself); + } + + if (user.isAdmin) { + throw new ApiError(meta.errors.cannotReportAdmin); + } + + const report = await AbuseUserReports.insert({ + id: genId(), + createdAt: new Date(), + targetUserId: user.id, + targetUserHost: user.host, + reporterId: me.id, + reporterHost: null, + comment: ps.comment, + }).then((x) => AbuseUserReports.findOneByOrFail(x.identifiers[0])); + + // Publish event to moderators + setImmediate(async () => { + const moderators = await Users.find({ + where: [ + { + isAdmin: true, + }, + { + isModerator: true, + }, + ], + }); + + for (const moderator of moderators) { + publishAdminStream(moderator.id, "newAbuseUserReport", { + id: report.id, + targetUserId: report.targetUserId, + reporterId: report.reporterId, + comment: report.comment, + }); + } + + const meta = await fetchMeta(); + if (meta.email) { + sendEmail( + meta.email, + "New abuse report", + sanitizeHtml(ps.comment), + sanitizeHtml(ps.comment), + ); + } + }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts new file mode 100644 index 0000000..f29fbcc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/search-by-username-and-host.ts @@ -0,0 +1,144 @@ +import { Brackets } from "typeorm"; +import { Followings, Users } from "@/models/index.js"; +import { USER_ACTIVE_THRESHOLD } from "@/const.js"; +import type { User } from "@/models/entities/user.js"; +import define from "../../define.js"; +import { sqlLikeEscape } from "@/misc/sql-like-escape.js"; +import { generateMinorBadgeUserVisibilityQuery } from "../../common/generate-minor-badge-visibility-query.js"; + +export const meta = { + tags: ["users"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + description: "Search for a user by username and/or host.", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "User", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + username: { type: "string", nullable: true }, + host: { type: "string", nullable: true }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + maxDaysSinceLastActive: { + type: "integer", + minimum: 1, + maximum: 1000, + nullable: true + }, + detail: { type: "boolean", default: true }, + }, + anyOf: [{ required: ["username"] }, { required: ["host"] }], +} as const; + +// TODO: avatar,bannerをJOINしたいけどエラーになる + +export default define(meta, paramDef, async (ps, me) => { + const activeThreshold = ps.maxDaysSinceLastActive + ? new Date(Date.now() - 1000 * 60 * 60 * 24 * ps.maxDaysSinceLastActive) + : null; + + if (ps.host) { + const q = Users.createQueryBuilder("user") + .where("user.isSuspended = FALSE") + .andWhere("user.host LIKE :host", { + host: `${sqlLikeEscape(ps.host.toLowerCase())}%`, + }); + + if (ps.username) { + q.andWhere("user.usernameLower LIKE :username", { + username: `${sqlLikeEscape(ps.username.toLowerCase())}%`, + }); + } + + q.andWhere("user.updatedAt IS NOT NULL"); + generateMinorBadgeUserVisibilityQuery(q, me); + q.orderBy("user.updatedAt", "DESC"); + + const users = await q.take(ps.limit).getMany(); + + return await Users.packMany(users, me, { detail: ps.detail }); + } else if (ps.username) { + let users: User[] = []; + + if (me) { + const followingQuery = Followings.createQueryBuilder("following") + .select("following.followeeId") + .where("following.followerId = :followerId", { followerId: me.id }); + + const query = Users.createQueryBuilder("user") + .where(`user.id IN (${followingQuery.getQuery()})`) + .andWhere("user.isSuspended = FALSE") + .andWhere("user.usernameLower LIKE :username", { + username: `${sqlLikeEscape(ps.username.toLowerCase())}%`, + }); + + if (activeThreshold) { + query.andWhere( + new Brackets((qb) => { + qb.where("user.updatedAt IS NULL").orWhere( + "user.updatedAt > :activeThreshold", + { activeThreshold: activeThreshold }, + ); + }), + ); + } + generateMinorBadgeUserVisibilityQuery(query, me); + + query.setParameters(followingQuery.getParameters()); + + users = await query + .orderBy("user.usernameLower", "ASC") + .take(ps.limit) + .getMany(); + + if (users.length < ps.limit) { + const otherQuery = await Users.createQueryBuilder("user") + .where(`user.id NOT IN (${followingQuery.getQuery()})`) + .andWhere("user.isSuspended = FALSE") + .andWhere("user.usernameLower LIKE :username", { + username: `${sqlLikeEscape(ps.username.toLowerCase())}%`, + }) + .andWhere("user.updatedAt IS NOT NULL"); + generateMinorBadgeUserVisibilityQuery(otherQuery, me); + + otherQuery.setParameters(followingQuery.getParameters()); + + const otherUsers = await otherQuery + .orderBy("user.updatedAt", "DESC") + .take(ps.limit - users.length) + .getMany(); + + users = users.concat(otherUsers); + } + } else { + users = await Users.createQueryBuilder("user") + .where("user.isSuspended = FALSE") + .andWhere("user.usernameLower LIKE :username", { + username: `${sqlLikeEscape(ps.username.toLowerCase())}%`, + }) + .andWhere("user.updatedAt IS NOT NULL") + .andWhere("NOT ('E' = ANY(user.\"minorBadges\"))") + .orderBy("user.updatedAt", "DESC") + .take(ps.limit - users.length) + .getMany(); + } + + return await Users.packMany(users, me, { detail: !!ps.detail }); + } + + return []; +}); diff --git a/packages/backend/src/server/api/endpoints/users/search.ts b/packages/backend/src/server/api/endpoints/users/search.ts new file mode 100644 index 0000000..4ecc5fe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/search.ts @@ -0,0 +1,156 @@ +import { Brackets } from "typeorm"; +import { UserProfiles, Users } from "@/models/index.js"; +import type { User } from "@/models/entities/user.js"; +import define from "../../define.js"; +import { sqlLikeEscape } from "@/misc/sql-like-escape.js"; +import { generateMinorBadgeUserVisibilityQuery } from "../../common/generate-minor-badge-visibility-query.js"; + +export const meta = { + tags: ["users"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + description: "Search for users.", + + res: { + type: "array", + optional: false, + nullable: false, + items: { + type: "object", + optional: false, + nullable: false, + ref: "User", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + query: { type: "string" }, + offset: { type: "integer", default: 0 }, + limit: { type: "integer", minimum: 1, maximum: 100, default: 10 }, + origin: { + type: "string", + enum: ["local", "remote", "combined"], + default: "combined", + }, + detail: { type: "boolean", default: true }, + }, + required: ["query"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const activeThreshold = new Date(Date.now() - 1000 * 60 * 60 * 24 * 30); // 30日 + + const isUsername = ps.query.startsWith("@"); + + let users: User[] = []; + + if (isUsername) { + const usernameQuery = Users.createQueryBuilder("user") + .where("user.usernameLower LIKE :username", { + username: `${sqlLikeEscape(ps.query.replace("@", "").toLowerCase())}%`, + }) + .andWhere( + new Brackets((qb) => { + qb.where("user.updatedAt IS NULL").orWhere( + "user.updatedAt > :activeThreshold", + { activeThreshold: activeThreshold }, + ); + }), + ) + .andWhere("user.isSuspended = FALSE"); + + if (ps.origin === "local") { + usernameQuery.andWhere("user.host IS NULL"); + } else if (ps.origin === "remote") { + usernameQuery.andWhere("user.host IS NOT NULL"); + } + generateMinorBadgeUserVisibilityQuery(usernameQuery, me); + + users = await usernameQuery + .orderBy("user.updatedAt", "DESC", "NULLS LAST") + .take(ps.limit) + .skip(ps.offset) + .getMany(); + } else { + const nameQuery = Users.createQueryBuilder("user") + .where( + new Brackets((qb) => { + qb.where("user.name ILIKE :query", { + query: `%${sqlLikeEscape(ps.query)}%`, + }); + + // Also search username if it qualifies as username + if (Users.validateLocalUsername(ps.query)) { + qb.orWhere("user.usernameLower LIKE :username", { + username: `%${sqlLikeEscape(ps.query.toLowerCase())}%`, + }); + } + }), + ) + .andWhere( + new Brackets((qb) => { + qb.where("user.updatedAt IS NULL").orWhere( + "user.updatedAt > :activeThreshold", + { activeThreshold: activeThreshold }, + ); + }), + ) + .andWhere("user.isSuspended = FALSE"); + + if (ps.origin === "local") { + nameQuery.andWhere("user.host IS NULL"); + } else if (ps.origin === "remote") { + nameQuery.andWhere("user.host IS NOT NULL"); + } + generateMinorBadgeUserVisibilityQuery(nameQuery, me); + + users = await nameQuery + .orderBy("user.updatedAt", "DESC", "NULLS LAST") + .take(ps.limit) + .skip(ps.offset) + .getMany(); + + if (users.length < ps.limit) { + const profQuery = UserProfiles.createQueryBuilder("prof") + .select("prof.userId") + .where("prof.description ILIKE :query", { + query: `%${sqlLikeEscape(ps.query)}%`, + }); + + if (ps.origin === "local") { + profQuery.andWhere("prof.userHost IS NULL"); + } else if (ps.origin === "remote") { + profQuery.andWhere("prof.userHost IS NOT NULL"); + } + + const query = Users.createQueryBuilder("user") + .where(`user.id IN (${profQuery.getQuery()})`) + .andWhere( + new Brackets((qb) => { + qb.where("user.updatedAt IS NULL").orWhere( + "user.updatedAt > :activeThreshold", + { activeThreshold: activeThreshold }, + ); + }), + ) + .andWhere("user.isSuspended = FALSE") + .setParameters(profQuery.getParameters()); + generateMinorBadgeUserVisibilityQuery(query, me); + + users = users.concat( + await query + .orderBy("user.updatedAt", "DESC", "NULLS LAST") + .take(ps.limit) + .skip(ps.offset) + .getMany(), + ); + } + } + + return await Users.packMany(users, me, { detail: ps.detail }); +}); diff --git a/packages/backend/src/server/api/endpoints/users/show.ts b/packages/backend/src/server/api/endpoints/users/show.ts new file mode 100644 index 0000000..f987569 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/show.ts @@ -0,0 +1,172 @@ +import type { FindOptionsWhere } from "typeorm"; +import { In, IsNull } from "typeorm"; +import { resolveUser } from "@/remote/resolve-user.js"; +import { Users } from "@/models/index.js"; +import type { User } from "@/models/entities/user.js"; +import define from "../../define.js"; +import { apiLogger } from "../../logger.js"; +import { ApiError } from "../../error.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { shouldHideEUsersFor } from "../../common/generate-minor-badge-visibility-query.js"; + +export const meta = { + tags: ["users"], + + // TODO: determine if should allow this in private mode or to create a new endpoint just for 2fa + requireCredential: false, + requireCredentialPrivateMode: false, // set to false to allow FIDO2 and other 2fa auth + + description: "Show the properties of a user.", + + res: { + optional: false, + nullable: false, + oneOf: [ + { + type: "object", + ref: "UserDetailed", + }, + { + type: "array", + items: { + type: "object", + ref: "UserDetailed", + }, + }, + ], + }, + + errors: { + failedToResolveRemoteUser: { + message: "Failed to resolve remote user.", + code: "FAILED_TO_RESOLVE_REMOTE_USER", + id: "ef7b9be4-9cba-4e6f-ab41-90ed171c7d3c", + kind: "server", + }, + + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "4362f8dc-731f-4ad8-a694-be5a88922a24", + }, + }, +} as const; + +export const paramDef = { + type: "object", + anyOf: [ + { + properties: { + userId: { type: "string" }, + }, + required: ["userId"], + }, + { + properties: { + userIds: { + type: "array", + uniqueItems: true, + items: { + type: "string", + }, + }, + }, + required: ["userIds"], + }, + { + properties: { + username: { type: "string" }, + host: { + type: "string", + nullable: true, + description: "The local host is represented with `null`.", + }, + }, + required: ["username"], + }, + ], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + let user; + + const isAdminOrModerator = me && (me.isAdmin || me.isModerator); + + if (ps.userIds) { + if (ps.userIds.length === 0) { + return []; + } + + const isUrl = ps.userIds[0].startsWith("http"); + let users: User[]; + if (isUrl) { + users = await Users.findBy( + isAdminOrModerator + ? { uri: In(ps.userIds) } + : { uri: In(ps.userIds), isSuspended: false }, + ); + } else { + users = await Users.findBy( + isAdminOrModerator + ? { id: In(ps.userIds) } + : { id: In(ps.userIds), isSuspended: false }, + ); + } + + // リクエストされた通りに並べ替え + const _users: User[] = []; + for (const id of ps.userIds) { + const res = users.find((x) => (isUrl ? x.uri === id : x.id === id)); + if ( + res && + !( + shouldHideEUsersFor(me) && + res.id !== me!.id && + (res.minorBadges ?? []).includes("E") + ) + ) _users.push(res); + } + + return await Promise.all( + _users.map((u) => + Users.pack(u, me, { + detail: true, + }), + ), + ); + } else { + // Lookup user + if (typeof ps.host === "string" && typeof ps.username === "string") { + user = await resolveUser(ps.username, ps.host).catch((e) => { + apiLogger.warn(`failed to resolve remote user: ${e}`); + throw new ApiError(meta.errors.failedToResolveRemoteUser); + }); + } else { + const q: FindOptionsWhere = + ps.userId != null + ? ps.userId.startsWith("http") + ? { uri: ps.userId } + : { id: ps.userId } + : { usernameLower: ps.username!.toLowerCase(), host: IsNull() }; + + user = await Users.findOneBy(q); + } + + if ( + user == null || + (!isAdminOrModerator && user.isSuspended) || + (shouldHideEUsersFor(me) && user.id !== me!.id && (user.minorBadges ?? []).includes("E")) + ) { + throw new ApiError(meta.errors.noSuchUser); + } + + // apiLogger.debug(`packed (detailed): ${JSON.stringify(await Users.pack(user, me, {detail: true}))}`); + // apiLogger.debug(`packed (private): ${JSON.stringify(await Users.pack(user, me, {detail: true, isPrivateMode: true}))}`); + + const serverMeta = await fetchMeta(); + return await Users.pack(user, me, { + detail: true, + isPrivateMode: me !== null ? false : serverMeta.privateMode + }); + } +}); diff --git a/packages/backend/src/server/api/endpoints/users/stats.ts b/packages/backend/src/server/api/endpoints/users/stats.ts new file mode 100644 index 0000000..83e821f --- /dev/null +++ b/packages/backend/src/server/api/endpoints/users/stats.ts @@ -0,0 +1,225 @@ +import { + DriveFiles, + Followings, + NoteFavorites, + NoteReactions, + Notes, + PageLikes, + PollVotes, + Users, +} from "@/models/index.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; + +export const meta = { + tags: ["users"], + + requireCredential: false, + requireCredentialPrivateMode: true, + + description: "Show statistics about a user.", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "9e638e45-3b25-4ef7-8f95-07e8498f1819", + }, + }, + + res: { + type: "object", + optional: false, + nullable: false, + properties: { + notesCount: { + type: "integer", + optional: false, + nullable: false, + }, + repliesCount: { + type: "integer", + optional: false, + nullable: false, + }, + renotesCount: { + type: "integer", + optional: false, + nullable: false, + }, + repliedCount: { + type: "integer", + optional: false, + nullable: false, + }, + renotedCount: { + type: "integer", + optional: false, + nullable: false, + }, + pollVotesCount: { + type: "integer", + optional: false, + nullable: false, + }, + pollVotedCount: { + type: "integer", + optional: false, + nullable: false, + }, + localFollowingCount: { + type: "integer", + optional: false, + nullable: false, + }, + remoteFollowingCount: { + type: "integer", + optional: false, + nullable: false, + }, + localFollowersCount: { + type: "integer", + optional: false, + nullable: false, + }, + remoteFollowersCount: { + type: "integer", + optional: false, + nullable: false, + }, + followingCount: { + type: "integer", + optional: false, + nullable: false, + }, + followersCount: { + type: "integer", + optional: false, + nullable: false, + }, + sentReactionsCount: { + type: "integer", + optional: false, + nullable: false, + }, + receivedReactionsCount: { + type: "integer", + optional: false, + nullable: false, + }, + noteFavoritesCount: { + type: "integer", + optional: false, + nullable: false, + }, + pageLikesCount: { + type: "integer", + optional: false, + nullable: false, + }, + pageLikedCount: { + type: "integer", + optional: false, + nullable: false, + }, + driveFilesCount: { + type: "integer", + optional: false, + nullable: false, + }, + driveUsage: { + type: "integer", + optional: false, + nullable: false, + description: "Drive usage in bytes", + }, + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); + if (user == null) { + throw new ApiError(meta.errors.noSuchUser); + } + + const result = await awaitAll({ + notesCount: Notes.createQueryBuilder("note") + .where("note.userId = :userId", { userId: user.id }) + .getCount(), + repliesCount: Notes.createQueryBuilder("note") + .where("note.userId = :userId", { userId: user.id }) + .andWhere("note.replyId IS NOT NULL") + .getCount(), + renotesCount: Notes.createQueryBuilder("note") + .where("note.userId = :userId", { userId: user.id }) + .andWhere("note.renoteId IS NOT NULL") + .getCount(), + repliedCount: Notes.createQueryBuilder("note") + .where("note.replyUserId = :userId", { userId: user.id }) + .getCount(), + renotedCount: Notes.createQueryBuilder("note") + .where("note.renoteUserId = :userId", { userId: user.id }) + .getCount(), + pollVotesCount: PollVotes.createQueryBuilder("vote") + .where("vote.userId = :userId", { userId: user.id }) + .getCount(), + pollVotedCount: PollVotes.createQueryBuilder("vote") + .innerJoin("vote.note", "note") + .where("note.userId = :userId", { userId: user.id }) + .getCount(), + localFollowingCount: Followings.createQueryBuilder("following") + .where("following.followerId = :userId", { userId: user.id }) + .andWhere("following.followeeHost IS NULL") + .getCount(), + remoteFollowingCount: Followings.createQueryBuilder("following") + .where("following.followerId = :userId", { userId: user.id }) + .andWhere("following.followeeHost IS NOT NULL") + .getCount(), + localFollowersCount: Followings.createQueryBuilder("following") + .where("following.followeeId = :userId", { userId: user.id }) + .andWhere("following.followerHost IS NULL") + .getCount(), + remoteFollowersCount: Followings.createQueryBuilder("following") + .where("following.followeeId = :userId", { userId: user.id }) + .andWhere("following.followerHost IS NOT NULL") + .getCount(), + sentReactionsCount: NoteReactions.createQueryBuilder("reaction") + .where("reaction.userId = :userId", { userId: user.id }) + .getCount(), + receivedReactionsCount: NoteReactions.createQueryBuilder("reaction") + .innerJoin("reaction.note", "note") + .where("note.userId = :userId", { userId: user.id }) + .getCount(), + noteFavoritesCount: NoteFavorites.createQueryBuilder("favorite") + .where("favorite.userId = :userId", { userId: user.id }) + .getCount(), + pageLikesCount: PageLikes.createQueryBuilder("like") + .where("like.userId = :userId", { userId: user.id }) + .getCount(), + pageLikedCount: PageLikes.createQueryBuilder("like") + .innerJoin("like.page", "page") + .where("page.userId = :userId", { userId: user.id }) + .getCount(), + driveFilesCount: DriveFiles.createQueryBuilder("file") + .where("file.userId = :userId", { userId: user.id }) + .getCount(), + driveUsage: DriveFiles.calcDriveUsageOf(user), + }); + + result.followingCount = + result.localFollowingCount + result.remoteFollowingCount; + result.followersCount = + result.localFollowersCount + result.remoteFollowersCount; + + return result; +}); diff --git a/packages/backend/src/server/api/error.ts b/packages/backend/src/server/api/error.ts new file mode 100644 index 0000000..c58561a --- /dev/null +++ b/packages/backend/src/server/api/error.ts @@ -0,0 +1,36 @@ +type E = { + message: string; + code: string; + id: string; + kind?: "client" | "server"; + httpStatusCode?: number; +}; + +export class ApiError extends Error { + public message: string; + public code: string; + public id: string; + public kind: string; + public httpStatusCode?: number; + public info?: any; + + constructor(e?: E | null | undefined, info?: any | null | undefined) { + if (e == null) + e = { + message: + "Internal error occurred. Please contact us if the error persists.", + code: "INTERNAL_ERROR", + id: "5d37dbcb-891e-41ca-a3d6-e690c97775ac", + kind: "server", + httpStatusCode: 500, + }; + + super(e.message); + this.message = e.message; + this.code = e.code; + this.id = e.id; + this.kind = e.kind || "client"; + this.httpStatusCode = e.httpStatusCode; + this.info = info; + } +} diff --git a/packages/backend/src/server/api/index.ts b/packages/backend/src/server/api/index.ts new file mode 100644 index 0000000..5a472ed --- /dev/null +++ b/packages/backend/src/server/api/index.ts @@ -0,0 +1,153 @@ +/** + * API Server + */ + +import Koa from "koa"; +import Router from "@koa/router"; +import multer from "@koa/multer"; +import bodyParser from "koa-bodyparser"; +import cors from "@koa/cors"; +import { setupMastodonApi } from "./mastodon/index.js"; +import { AccessTokens, Users } from "@/models/index.js"; +import config from "@/config/index.js"; +import endpoints from "./endpoints.js"; +import compatibility from "./compatibility.js"; +import handler from "./api-handler.js"; +import signup from "./private/signup.js"; +import signin from "./private/signin.js"; +import signupPending from "./private/signup-pending.js"; +import verifyEmail from "./private/verify-email.js"; +import discord from "./service/discord.js"; +import github from "./service/github.js"; +import { serverLogger } from "../index.js"; +import { isIgnorableConnectionError } from "../is-ignorable-connection-error.js"; + +// Init app +const app = new Koa(); +app.on("error", (err) => { + if (isIgnorableConnectionError(err)) return; + serverLogger.error(err); +}); + +app.use( + cors({ + origin: "*", + }), +); + +// No caching +app.use(async (ctx, next) => { + ctx.set("Cache-Control", "private, max-age=0, must-revalidate"); + await next(); +}); + +// Init router +const router = new Router(); +const mastoRouter = new Router(); +const errorRouter = new Router(); + +// Init multer instance +const upload = multer({ + storage: multer.diskStorage({}), + limits: { + fileSize: config.maxFileSize || 262144000, + files: 1, + }, +}); + +router.use( + bodyParser({ + // リクエストが multipart/form-data でない限りはJSONだと見なす + detectJSON: (ctx) => + !( + ctx.is("multipart/form-data") || + ctx.is("application/x-www-form-urlencoded") + ), + }), +); + +setupMastodonApi(mastoRouter); + +/** + * Register endpoint handlers + */ +for (const endpoint of [...endpoints, ...compatibility]) { + if (endpoint.meta.requireFile) { + router.post( + `/${endpoint.name}`, + upload.single("file"), + handler.bind(null, endpoint), + ); + } else { + // 後方互換性のため + if (endpoint.name.includes("-")) { + router.post( + `/${endpoint.name.replace(/-/g, "_")}`, + handler.bind(null, endpoint), + ); + + if (endpoint.meta.allowGet) { + router.get( + `/${endpoint.name.replace(/-/g, "_")}`, + handler.bind(null, endpoint), + ); + } else { + router.get(`/${endpoint.name.replace(/-/g, "_")}`, async (ctx) => { + ctx.status = 405; + }); + } + } + + router.post(`/${endpoint.name}`, handler.bind(null, endpoint)); + + if (endpoint.meta.allowGet) { + router.get(`/${endpoint.name}`, handler.bind(null, endpoint)); + } else { + router.get(`/${endpoint.name}`, async (ctx) => { + ctx.status = 405; + }); + } + } +} + +router.post("/signup", signup); +router.post("/signin", signin); +router.post("/signup-pending", signupPending); +router.post("/verify-email", verifyEmail); + +router.use(discord.routes()); +router.use(github.routes()); +router.post("/miauth/:session/check", async (ctx) => { + const token = await AccessTokens.findOneBy({ + session: ctx.params.session, + }); + + if (token?.session != null && !token.fetched) { + AccessTokens.update(token.id, { + fetched: true, + }); + + ctx.body = { + ok: true, + token: token.token, + user: await Users.pack(token.userId, null, { detail: true }), + }; + } else { + ctx.body = { + ok: false, + }; + } +}); + +// Return 404 for unknown API +errorRouter.all("{*splat}", async (ctx) => { + ctx.status = 404; +}); + +// Register router +app.use(mastoRouter.routes()); +app.use(mastoRouter.allowedMethods()); +app.use(router.routes()); +app.use(errorRouter.routes()); + +export default app; diff --git a/packages/backend/src/server/api/limiter.ts b/packages/backend/src/server/api/limiter.ts new file mode 100644 index 0000000..367fb3d --- /dev/null +++ b/packages/backend/src/server/api/limiter.ts @@ -0,0 +1,89 @@ +import Limiter from "ratelimiter"; +import { CacheableLocalUser, User } from "@/models/entities/user.js"; +import Logger from "@/services/logger.js"; +import { redisClient } from "../../db/redis.js"; +import type { IEndpointMeta } from "./endpoints.js"; +import { convertMilliseconds } from "@/misc/convert-milliseconds.js"; + +const logger = new Logger("limiter"); + +export const limiter = ( + limitation: IEndpointMeta["limit"] & { key: NonNullable }, + actor: string, +) => + new Promise((ok, reject) => { + if (process.env.NODE_ENV === "test") ok(); + + const hasShortTermLimit = typeof limitation.minInterval === "number"; + + const hasLongTermLimit = + typeof limitation.duration === "number" && + typeof limitation.max === "number"; + + if (hasShortTermLimit) { + min(); + } else if (hasLongTermLimit) { + max(); + } else { + ok(); + } + + // Short-term limit + function min(): void { + const minIntervalLimiter = new Limiter({ + id: `${actor}:${limitation.key}:min`, + duration: limitation.minInterval, + max: 1, + db: redisClient, + }); + + minIntervalLimiter.get((err, info) => { + if (err) { + return reject("ERR"); + } + + logger.debug( + `${actor} ${limitation.key} min remaining: ${info.remaining}`, + ); + + if (info.remaining === 0) { + reject("BRIEF_REQUEST_INTERVAL"); + } else { + if (hasLongTermLimit) { + max(); + } else { + ok(); + } + } + }); + } + + // Long term limit + function max(): void { + const limiter = new Limiter({ + id: `${actor}:${limitation.key}`, + duration: limitation.duration, + max: limitation.max, + db: redisClient, + }); + + limiter.get((err, info) => { + if (err) { + return reject("ERR"); + } + + logger.debug( + `${actor} ${limitation.key} max remaining: ${info.remaining}`, + ); + + if (info.remaining === 0) { + reject({ + message: "RATE_LIMIT_EXCEEDED", + remainingTime: convertMilliseconds(info.resetMs - Date.now()), + }); + } else { + ok(); + } + }); + } + }); diff --git a/packages/backend/src/server/api/logger.ts b/packages/backend/src/server/api/logger.ts new file mode 100644 index 0000000..083888e --- /dev/null +++ b/packages/backend/src/server/api/logger.ts @@ -0,0 +1,3 @@ +import Logger from "@/services/logger.js"; + +export const apiLogger = new Logger("api"); diff --git a/packages/backend/src/server/api/mastodon/converters/announcement.ts b/packages/backend/src/server/api/mastodon/converters/announcement.ts new file mode 100644 index 0000000..afadf07 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/converters/announcement.ts @@ -0,0 +1,24 @@ +import { Announcement } from "@/models/entities/announcement.js"; +import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js"; +import mfm from "mfm-js"; + +export class AnnouncementConverter { + public static async encode(announcement: Announcement, isRead: boolean): Promise { + return { + id: announcement.id, + content: `

${await MfmHelpers.toHtml(mfm.parse(announcement.title), [], null) ?? 'Announcement'}

${await MfmHelpers.toHtml(mfm.parse(announcement.text), [], null) ?? ''}`, + starts_at: null, + ends_at: null, + published: true, + all_day: false, + published_at: announcement.createdAt.toISOString(), + updated_at: announcement.updatedAt?.toISOString() ?? announcement.createdAt.toISOString(), + read: isRead, + mentions: [], //FIXME + statuses: [], + tags: [], + emojis: [], //FIXME + reactions: [], + }; + } +} diff --git a/packages/backend/src/server/api/mastodon/converters/emoji.ts b/packages/backend/src/server/api/mastodon/converters/emoji.ts new file mode 100644 index 0000000..4ee8d45 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/converters/emoji.ts @@ -0,0 +1,13 @@ +import { PopulatedEmoji } from "@/misc/populate-emojis.js"; + +export class EmojiConverter { + public static encode(e: PopulatedEmoji): MastodonEntity.Emoji { + return { + shortcode: e.name, + static_url: e.url, + url: e.url, + visible_in_picker: true, + category: undefined + }; + } +} diff --git a/packages/backend/src/server/api/mastodon/converters/file.ts b/packages/backend/src/server/api/mastodon/converters/file.ts new file mode 100644 index 0000000..7cf064b --- /dev/null +++ b/packages/backend/src/server/api/mastodon/converters/file.ts @@ -0,0 +1,36 @@ +import { Packed } from "@/misc/schema.js"; + +export class FileConverter { + public static encode(f: Packed<"DriveFile">): MastodonEntity.Attachment { + return { + id: f.id, + type: this.encodefileType(f.type), + url: f.url ?? "", + remote_url: f.url, + preview_url: f.thumbnailUrl ?? f.url ?? "", + text_url: f.url, + meta: { + width: f.properties.width, + height: f.properties.height, + }, + description: f.comment, + blurhash: f.blurhash, + }; + } + + private static encodefileType(s: string): "unknown" | "image" | "gifv" | "video" | "audio" { + if (s === "image/gif") { + return "gifv"; + } + if (s.includes("image")) { + return "image"; + } + if (s.includes("video")) { + return "video"; + } + if (s.includes("audio")) { + return "audio"; + } + return "unknown"; + }; +} diff --git a/packages/backend/src/server/api/mastodon/converters/mention.ts b/packages/backend/src/server/api/mastodon/converters/mention.ts new file mode 100644 index 0000000..d2f9cab --- /dev/null +++ b/packages/backend/src/server/api/mastodon/converters/mention.ts @@ -0,0 +1,23 @@ +import { User } from "@/models/entities/user.js"; +import config from "@/config/index.js"; +import { IMentionedRemoteUsers } from "@/models/entities/note.js"; + +export class MentionConverter { + public static encode(u: User, m: IMentionedRemoteUsers): MastodonEntity.Mention { + let acct = u.username; + let acctUrl = `https://${u.host || config.host}/@${u.username}`; + let url: string | null = null; + if (u.host) { + const info = m.find(r => r.username === u.username && r.host === u.host); + acct = `${u.username}@${u.host}`; + acctUrl = `https://${u.host}/@${u.username}`; + if (info) url = info.url ?? info.uri; + } + return { + id: u.id, + username: u.username, + acct: acct, + url: url ?? acctUrl, + }; + } +} diff --git a/packages/backend/src/server/api/mastodon/converters/mfm.ts b/packages/backend/src/server/api/mastodon/converters/mfm.ts new file mode 100644 index 0000000..f398829 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/converters/mfm.ts @@ -0,0 +1,8 @@ +export const escapeMFM = (text: string): string => text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") + .replace(/`/g, "`") + .replace(/\r?\n/g, "
"); diff --git a/packages/backend/src/server/api/mastodon/converters/note.ts b/packages/backend/src/server/api/mastodon/converters/note.ts new file mode 100644 index 0000000..5913a1b --- /dev/null +++ b/packages/backend/src/server/api/mastodon/converters/note.ts @@ -0,0 +1,403 @@ +import { ILocalUser, User } from "@/models/entities/user.js"; +import { getNote } from "@/server/api/common/getters.js"; +import { Note } from "@/models/entities/note.js"; +import config from "@/config/index.js"; +import mfm from "mfm-js"; +import { UserConverter } from "@/server/api/mastodon/converters/user.js"; +import { VisibilityConverter } from "@/server/api/mastodon/converters/visibility.js"; +import { escapeMFM } from "@/server/api/mastodon/converters/mfm.js"; +import { aggregateNoteEmojis, PopulatedEmoji, populateEmojis, prefetchEmojis } from "@/misc/populate-emojis.js"; +import { EmojiConverter } from "@/server/api/mastodon/converters/emoji.js"; +import { + DriveFiles, + HtmlNoteCacheEntries, + NoteFavorites, + NoteReactions, + Notes, + NoteThreadMutings, + UserNotePinings +} from "@/models/index.js"; +import { decodeReaction } from "@/misc/reaction-lib.js"; +import { MentionConverter } from "@/server/api/mastodon/converters/mention.js"; +import { PollConverter } from "@/server/api/mastodon/converters/poll.js"; +import { populatePoll } from "@/models/repositories/note.js"; +import { FileConverter } from "@/server/api/mastodon/converters/file.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import { AccountCache, UserHelpers } from "@/server/api/mastodon/helpers/user.js"; +import { In, IsNull } from "typeorm"; +import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js"; +import { getStubMastoContext, MastoContext } from "@/server/api/mastodon/index.js"; +import { NoteHelpers } from "@/server/api/mastodon/helpers/note.js"; +import isQuote from "@/misc/is-quote.js"; +import { unique } from "@/prelude/array.js"; +import { NoteReaction } from "@/models/entities/note-reaction.js"; +import { Cache } from "@/misc/cache.js"; +import { HtmlNoteCacheEntry } from "@/models/entities/html-note-cache-entry.js"; +import { isFiltered } from "@/misc/is-filtered.js"; + +export class NoteConverter { + private static noteContentHtmlCache = new Cache('html:note:content', config.htmlCache?.ttlSeconds ?? 60 * 60); + public static async encode(note: Note, ctx: MastoContext, recurseCounter: number = 2): Promise { + const user = ctx.user as ILocalUser | null; + const noteUser = note.user ?? UserHelpers.getUserCached(note.userId, ctx); + + if (!await Notes.isVisibleForMe(note, user?.id ?? null)) + throw new Error('Cannot encode note not visible for user'); + + const host = Promise.resolve(noteUser).then(noteUser => noteUser.host ?? null); + + const reactionEmojiNames = Object.keys(note.reactions) + .filter((x) => x?.startsWith(":")) + .map((x) => decodeReaction(x).reaction) + .map((x) => x.replace(/:/g, "")); + + const populated = host.then(async host => populateEmojis( + note.emojis.concat(reactionEmojiNames), + host, + )); + + const noteEmoji = populated.then(noteEmoji => noteEmoji + .filter((e) => e.name.indexOf("@") === -1) + .map((e) => EmojiConverter.encode(e))); + + const reactionCount = Object.values(note.reactions).reduce((a, b) => a + b, 0); + + const aggregateReaction = (ctx.reactionAggregate as Map)?.get(note.id); + + const reaction = aggregateReaction !== undefined + ? aggregateReaction + : user ? NoteReactions.findOneBy({ + userId: user.id, + noteId: note.id, + }) : null; + + const isFavorited = Promise.resolve(reaction).then(p => !!p); + + const isReblogged = (ctx.renoteAggregate as Map)?.get(note.id) + ?? (user ? Notes.exist({ + where: { + userId: user.id, + renoteId: note.id, + text: IsNull(), + } + }) : null); + + const renote = note.renote ?? (note.renoteId && recurseCounter > 0 ? getNote(note.renoteId, user) : null); + + const isBookmarked = (ctx.bookmarkAggregate as Map)?.get(note.id) + ?? (user ? NoteFavorites.exist({ + where: { + userId: user.id, + noteId: note.id, + }, + take: 1, + }) : false); + + const isMuted = (ctx.mutingAggregate as Map)?.get(note.threadId ?? note.id) + ?? (user ? NoteThreadMutings.exist({ + where: { + userId: user.id, + threadId: note.threadId || note.id, + } + }) : false); + + const files = DriveFiles.packMany(note.fileIds); + + const mentions = Promise.all(note.mentions.map(p => + UserHelpers.getUserCached(p, ctx) + .then(u => MentionConverter.encode(u, JSON.parse(note.mentionedRemoteUsers))) + .catch(() => null))) + .then(p => p.filter(m => m)) as Promise; + + const quoteUri = Promise.resolve(renote).then(renote => { + if (!renote || !isQuote(note)) return null; + return renote.url ?? renote.uri ?? `${config.url}/notes/${renote.id}`; + }); + + const identifier = `${note.id}:${(note.updatedAt ?? note.createdAt).getTime()}`; + + const text = quoteUri.then(quoteUri => note.text !== null ? quoteUri !== null ? note.text.replaceAll(`RE: ${quoteUri}`, '').replaceAll(quoteUri, '').trimEnd() : note.text : null); + + const content = this.noteContentHtmlCache.fetch(identifier, async () => + Promise.resolve(await this.fetchFromCacheWithFallback(note, ctx) ?? text.then(text => text !== null + ? quoteUri.then(quoteUri => MfmHelpers.toHtml(mfm.parse(text), JSON.parse(note.mentionedRemoteUsers), note.userHost, false, quoteUri)) + .then(p => p ?? escapeMFM(text)) + : "")), true) + .then(p => p ?? ''); + + const isPinned = (ctx.pinAggregate as Map)?.get(note.id) + ?? (user && note.userId === user.id + ? UserNotePinings.exist({ where: { userId: user.id, noteId: note.id } }) + : undefined); + + const tags = note.tags.map(tag => { + return { + name: tag, + url: `${config.url}/tags/${tag}` + } as MastodonEntity.Tag; + }); + + const reblog = Promise.resolve(renote).then(renote => recurseCounter > 0 && renote ? this.encode(renote, ctx, isQuote(renote) && !isQuote(note) ? --recurseCounter : 0) : null); + + const filtered = isFiltered(note, user).then(res => { + if (!res || ctx.filterContext == null || !['home', 'public'].includes(ctx.filterContext)) return null; + return [{ + filter: { + id: '0', + title: 'Hard word mutes', + context: ['home', 'public'], + expires_at: null, + filter_action: 'hide', + keywords: [], + statuses: [], + } + } as MastodonEntity.FilterResult]; + }); + + // noinspection ES6MissingAwait + return await awaitAll({ + id: note.id, + uri: note.uri ?? `https://${config.host}/notes/${note.id}`, + url: note.url ?? note.uri ?? `https://${config.host}/notes/${note.id}`, + account: Promise.resolve(noteUser).then(p => UserConverter.encode(p, ctx)), + in_reply_to_id: note.replyId, + in_reply_to_account_id: note.replyUserId, + reblog: reblog.then(reblog => !isQuote(note) ? reblog : null), + content: content, + content_type: 'text/x.misskeymarkdown', + text: text, + created_at: note.createdAt.toISOString(), + emojis: noteEmoji, + replies_count: note.repliesCount, + reblogs_count: note.renoteCount, + favourites_count: reactionCount, + reblogged: isReblogged, + favourited: isFavorited, + muted: isMuted, + sensitive: files.then(files => files.length > 0 ? files.some((f) => f.isSensitive) : false), + spoiler_text: note.cw ? note.cw : "", + visibility: VisibilityConverter.encode(note.visibility), + media_attachments: files.then(files => files.length > 0 ? files.map((f) => FileConverter.encode(f)) : []), + mentions: mentions, + tags: tags, + card: null, //FIXME + poll: note.hasPoll ? populatePoll(note, user?.id ?? null).then(p => noteEmoji.then(emojis => PollConverter.encode(p, note.id, emojis))) : null, + application: null, //FIXME + language: null, //FIXME + pinned: isPinned, + reactions: populated.then(populated => Promise.resolve(reaction).then(reaction => this.encodeReactions(note.reactions, reaction?.reaction, populated))), + bookmarked: isBookmarked, + quote: reblog.then(reblog => reblog !== null && isQuote(note) ? { + state: "accepted", + quoted_status: reblog, + ...reblog, + } : null), + quote_id: isQuote(note) ? note.renoteId : null, + edited_at: note.updatedAt?.toISOString() ?? null, + filtered: filtered, + quote_approval: { + automatic: ["public"], + manual: [], + current_user: "automatic", + }, + }); + } + + public static async encodeMany(notes: Note[], ctx: MastoContext): Promise { + await this.aggregateData(notes, ctx); + const encoded = notes.map(n => this.encode(n, ctx)); + return Promise.all(encoded); + } + + public static async aggregateData(notes: Note[], ctx: MastoContext): Promise { + if (notes.length === 0) return; + + const user = ctx.user as ILocalUser | null; + const reactionAggregate = new Map(); + const renoteAggregate = new Map(); + const mutingAggregate = new Map(); + const bookmarkAggregate = new Map(); + const pinAggregate = new Map(); + const htmlNoteCacheAggregate = new Map(); + + const renoteIds = notes + .filter((n) => n.renoteId != null) + .map((n) => n.renoteId!); + + const noteIds = unique(notes.map((n) => n.id)); + const targets = unique([...noteIds, ...renoteIds]); + + if (config.htmlCache?.dbFallback) { + const htmlNoteCacheEntries = await HtmlNoteCacheEntries.findBy({ + noteId: In(targets) + }); + + for (const target of targets) { + htmlNoteCacheAggregate.set(target, htmlNoteCacheEntries.find(n => n.noteId === target) ?? null); + } + } + + if (user?.id != null) { + const mutingTargets = unique([...notes.map(n => n.threadId ?? n.id)]); + const pinTargets = unique([...notes.filter(n => n.userId === user.id).map(n => n.id)]); + + const reactions = await NoteReactions.findBy({ + userId: user.id, + noteId: In(targets), + }); + + const renotes = await Notes.createQueryBuilder('note') + .select('note.renoteId') + .where('note.userId = :meId', { meId: user.id }) + .andWhere('note.renoteId IN (:...targets)', { targets }) + .andWhere('note.text IS NULL') + .andWhere('note.hasPoll = FALSE') + .andWhere(`note.fileIds = '{}'`) + .getMany(); + + const mutings = await NoteThreadMutings.createQueryBuilder('muting') + .select('muting.threadId') + .where('muting.userId = :meId', { meId: user.id }) + .andWhere('muting.threadId IN (:...targets)', { targets: mutingTargets }) + .getMany(); + + const bookmarks = await NoteFavorites.createQueryBuilder('bookmark') + .select('bookmark.noteId') + .where('bookmark.userId = :meId', { meId: user.id }) + .andWhere('bookmark.noteId IN (:...targets)', { targets }) + .getMany(); + + const pins = pinTargets.length > 0 ? await UserNotePinings.createQueryBuilder('pin') + .select('pin.noteId') + .where('pin.userId = :meId', { meId: user.id }) + .andWhere('pin.noteId IN (:...targets)', { targets: pinTargets }) + .getMany() : []; + + for (const target of targets) { + reactionAggregate.set(target, reactions.find(r => r.noteId === target) ?? null); + renoteAggregate.set(target, !!renotes.find(n => n.renoteId === target)); + bookmarkAggregate.set(target, !!bookmarks.find(b => b.noteId === target)); + } + + for (const target of mutingTargets) { + mutingAggregate.set(target, !!mutings.find(m => m.threadId === target)); + } + + for (const target of pinTargets) { + mutingAggregate.set(target, !!pins.find(m => m.noteId === target)); + } + } + + ctx.reactionAggregate = reactionAggregate; + ctx.renoteAggregate = renoteAggregate; + ctx.mutingAggregate = mutingAggregate; + ctx.bookmarkAggregate = bookmarkAggregate; + ctx.pinAggregate = pinAggregate; + ctx.htmlNoteCacheAggregate = htmlNoteCacheAggregate; + + const users = notes.filter(p => !!p.user).map(p => p.user as User); + const renoteUserIds = notes.filter(p => p.renoteUserId !== null).map(p => p.renoteUserId as string); + await UserConverter.aggregateData([...users], ctx) + await UserConverter.aggregateDataByIds(renoteUserIds, ctx); + await prefetchEmojis(aggregateNoteEmojis(notes)); + } + + private static encodeReactions(reactions: Record, myReaction: string | undefined, populated: PopulatedEmoji[]): MastodonEntity.Reaction[] { + return Object.keys(reactions).map(key => { + + const isCustom = key.startsWith(':') && key.endsWith(':'); + const name = isCustom ? key.substring(1, key.length - 1) : key; + const populatedName = isCustom && name.indexOf('@') === -1 ? `${name}@.` : name; + const url = isCustom ? populated.find(p => p.name === populatedName)?.url : undefined; + + return { + count: reactions[key], + me: key === myReaction, + name: name, + url: url, + static_url: url, + }; + }).filter(r => r.count > 0); + } + + public static async encodeEvent(note: Note, user: ILocalUser | undefined, filterContext?: string): Promise { + const ctx = getStubMastoContext(user, filterContext); + NoteHelpers.fixupEventNote(note); + return NoteConverter.encode(note, ctx); + } + + private static async fetchFromCacheWithFallback(note: Note, ctx: MastoContext): Promise { + if (!config.htmlCache?.dbFallback) return null; + + let dbHit: HtmlNoteCacheEntry | Promise | null | undefined = (ctx.htmlNoteCacheAggregate as Map | undefined)?.get(note.id); + if (dbHit === undefined) dbHit = HtmlNoteCacheEntries.findOneBy({ noteId: note.id }); + + return Promise.resolve(dbHit) + .then(res => { + if (res === null || (res.updatedAt?.getTime() !== note.updatedAt?.getTime())) { + return this.dbCacheMiss(note, ctx); + } + return res; + }) + .then(hit => hit?.updatedAt === note.updatedAt ? hit?.content ?? null : null); + } + + private static async dbCacheMiss(note: Note, ctx: MastoContext): Promise { + const identifier = `${note.id}:${(note.updatedAt ?? note.createdAt).getTime()}`; + const cache = ctx.cache as AccountCache; + return cache.locks.acquire(identifier, async () => { + const cachedContent = await this.noteContentHtmlCache.get(identifier); + if (cachedContent !== undefined) { + return { content: cachedContent } as HtmlNoteCacheEntry; + } + + const quoteUri = note.renote + ? isQuote(note) + ? (note.renote.url ?? note.renote.uri ?? `${config.url}/notes/${note.renote.id}`) + : null + : null; + + const text = note.text !== null ? quoteUri !== null ? note.text.replaceAll(`RE: ${quoteUri}`, '').replaceAll(quoteUri, '').trimEnd() : note.text : null; + const content = text !== null + ? MfmHelpers.toHtml(mfm.parse(text), JSON.parse(note.mentionedRemoteUsers), note.userHost, false, quoteUri) + .then(p => p ?? escapeMFM(text)) + : null; + + HtmlNoteCacheEntries.upsert({ noteId: note.id, updatedAt: note.updatedAt ?? note.createdAt, content: await content }, ["noteId"]); + await this.noteContentHtmlCache.set(identifier, await content); + return { content } as HtmlNoteCacheEntry; + }); + } + + public static async prewarmCache(note: Note): Promise { + if (!config.htmlCache?.prewarm) return; + const identifier = `${note.id}:${(note.updatedAt ?? note.createdAt).getTime()}`; + if (await this.noteContentHtmlCache.get(identifier) !== undefined) return; + + if (note.renoteId !== null && !note.renote) { + note.renote = await Notes.findOneBy({ id: note.renoteId }); + } + + const quoteUri = note.renote + ? isQuote(note) + ? (note.renote.url ?? note.renote.uri ?? `${config.url}/notes/${note.renote.id}`) + : null + : null; + + const text = note.text !== null ? quoteUri !== null ? note.text.replaceAll(`RE: ${quoteUri}`, '').replaceAll(quoteUri, '').trimEnd() : note.text : null; + const content = text !== null + ? MfmHelpers.toHtml(mfm.parse(text), JSON.parse(note.mentionedRemoteUsers), note.userHost, false, quoteUri) + .then(p => p ?? escapeMFM(text)) + : null; + + if (note.user) UserConverter.prewarmCache(note.user); + else if (note.userId) UserConverter.prewarmCacheById(note.userId); + + if (note.replyUserId) UserConverter.prewarmCacheById(note.replyUserId); + if (note.renoteUserId) UserConverter.prewarmCacheById(note.renoteUserId); + this.noteContentHtmlCache.set(identifier, await content); + + if (config.htmlCache?.dbFallback) + HtmlNoteCacheEntries.upsert({ noteId: note.id, updatedAt: note.updatedAt ?? note.createdAt, content: await content }, ["noteId"]); + } +} diff --git a/packages/backend/src/server/api/mastodon/converters/notification.ts b/packages/backend/src/server/api/mastodon/converters/notification.ts new file mode 100644 index 0000000..fa0235e --- /dev/null +++ b/packages/backend/src/server/api/mastodon/converters/notification.ts @@ -0,0 +1,103 @@ +import { ILocalUser, User } from "@/models/entities/user.js"; +import { Notification } from "@/models/entities/notification.js"; +import { notificationTypes } from "@/types.js"; +import { UserConverter } from "@/server/api/mastodon/converters/user.js"; +import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { getNote } from "@/server/api/common/getters.js"; +import { getStubMastoContext, MastoContext } from "@/server/api/mastodon/index.js"; +import { Notifications } from "@/models/index.js"; +import isQuote from "@/misc/is-quote.js"; +import { unique } from "@/prelude/array.js"; +import { Note } from "@/models/entities/note.js"; + +type NotificationType = typeof notificationTypes[number]; + +export class NotificationConverter { + public static async encode(notification: Notification, ctx: MastoContext): Promise { + const localUser = ctx.user as ILocalUser; + if (notification.notifieeId !== localUser.id) throw new Error('User is not recipient of notification'); + + const account = notification.notifierId + ? UserHelpers.getUserCached(notification.notifierId, ctx).then(p => UserConverter.encode(p, ctx)) + : UserConverter.encode(localUser, ctx); + + let result = { + id: notification.id, + account: account, + created_at: notification.createdAt.toISOString(), + type: this.encodeNotificationType(notification.type), + }; + + const note = notification.note ?? (notification.noteId ? await getNote(notification.noteId, localUser) : null); + + if (note) { + const isPureRenote = note.renoteId !== null && !isQuote(note); + const encodedNote = isPureRenote + ? getNote(note.renoteId!, localUser).then(note => NoteConverter.encode(note, ctx)) + : NoteConverter.encode(note, ctx); + result = Object.assign(result, { + status: encodedNote, + }); + if (result.type === 'poll') { + result = Object.assign(result, { + account: encodedNote.then(p => p.account), + }); + } + if (notification.reaction) { + //FIXME: Implement reactions; + } + } + return awaitAll(result); + } + + public static async encodeMany(notifications: Notification[], ctx: MastoContext): Promise { + await this.aggregateData(notifications, ctx); + const encoded = notifications.map(u => this.encode(u, ctx)); + return Promise.all(encoded) + .then(p => p.filter(n => n !== null) as MastodonEntity.Notification[]); + } + + private static async aggregateData(notifications: Notification[], ctx: MastoContext): Promise { + if (notifications.length === 0) return; + const notes = unique(notifications.filter(p => p.note != null).map((n) => n.note as Note)); + const users = unique(notifications.filter(p => p.notifier != null).map(n => n.notifier as User) + .concat(notifications.filter(p => p.notifiee != null).map(n => n.notifiee as User))); + await NoteConverter.aggregateData(notes, ctx); + await UserConverter.aggregateData(users, ctx); + } + + private static encodeNotificationType(t: NotificationType): MastodonEntity.NotificationType { + //FIXME: Implement custom notification for followRequestAccepted + //FIXME: Implement mastodon notification type 'update' on misskey side + switch (t) { + case "follow": + return 'follow'; + case "mention": + case "reply": + return 'mention' + case "renote": + return 'reblog'; + case "quote": + return 'reblog'; + case "reaction": + return 'favourite'; + case "pollEnded": + return 'poll'; + case "receiveFollowRequest": + return 'follow_request'; + case "followRequestAccepted": + case "pollVote": + case "groupInvited": + case "app": + throw new Error(`Notification type ${t} not supported`); + } + } + + public static async encodeEvent(target: Notification["id"], user: ILocalUser, filterContext?: string): Promise { + const ctx = getStubMastoContext(user, filterContext); + const notification = await Notifications.findOneByOrFail({ id: target }); + return this.encode(notification, ctx).catch(_ => null); + } +} diff --git a/packages/backend/src/server/api/mastodon/converters/poll.ts b/packages/backend/src/server/api/mastodon/converters/poll.ts new file mode 100644 index 0000000..211316b --- /dev/null +++ b/packages/backend/src/server/api/mastodon/converters/poll.ts @@ -0,0 +1,38 @@ +type Choice = { + text: string + votes: number + isVoted: boolean +} + +type Poll = { + multiple: boolean + expiresAt: Date | null + choices: Array +} + +export class PollConverter { + public static encode(p: Poll, noteId: string, emojis: MastodonEntity.Emoji[]): MastodonEntity.Poll { + const now = new Date(); + const count = p.choices.reduce((sum, choice) => sum + choice.votes, 0); + return { + id: noteId, + expires_at: p.expiresAt?.toISOString() ?? null, + expired: p.expiresAt == null ? false : now > p.expiresAt, + multiple: p.multiple, + votes_count: count, + options: p.choices.map((c) => this.encodeChoice(c)), + emojis: emojis, + voted: p.choices.some((c) => c.isVoted), + own_votes: p.choices + .filter((c) => c.isVoted) + .map((c) => p.choices.indexOf(c)), + }; + } + + private static encodeChoice(c: Choice): MastodonEntity.PollOption { + return { + title: c.text, + votes_count: c.votes, + }; + } +} diff --git a/packages/backend/src/server/api/mastodon/converters/user.ts b/packages/backend/src/server/api/mastodon/converters/user.ts new file mode 100644 index 0000000..957b147 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/converters/user.ts @@ -0,0 +1,332 @@ +import { ILocalUser, User } from "@/models/entities/user.js"; +import config from "@/config/index.js"; +import { DriveFiles, Followings, HtmlUserCacheEntries, UserProfiles, Users } from "@/models/index.js"; +import { EmojiConverter } from "@/server/api/mastodon/converters/emoji.js"; +import { populateEmojis } from "@/misc/populate-emojis.js"; +import { escapeMFM } from "@/server/api/mastodon/converters/mfm.js"; +import mfm from "mfm-js"; +import { awaitAll } from "@/prelude/await-all.js"; +import { AccountCache, UserHelpers } from "@/server/api/mastodon/helpers/user.js"; +import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js"; +import { MastoContext } from "@/server/api/mastodon/index.js"; +import { IMentionedRemoteUsers, Note } from "@/models/entities/note.js"; +import { UserProfile } from "@/models/entities/user-profile.js"; +import { In } from "typeorm"; +import { unique } from "@/prelude/array.js"; +import { Cache } from "@/misc/cache.js"; +import { getUser } from "../../common/getters.js"; +import { HtmlUserCacheEntry } from "@/models/entities/html-user-cache-entry.js"; +import AsyncLock from "async-lock"; + +type Field = { + name: string; + value: string; + verified?: boolean; +}; + +export class UserConverter { + private static userBioHtmlCache = new Cache('html:user:bio', config.htmlCache?.ttlSeconds ?? 60 * 60); + private static userFieldsHtmlCache = new Cache('html:user:fields', config.htmlCache?.ttlSeconds ?? 60 * 60); + + public static async encode(u: User, ctx: MastoContext): Promise { + const localUser = ctx.user as ILocalUser | null; + const cache = ctx.cache as AccountCache; + return cache.locks.acquire(u.id, async () => { + const cacheHit = cache.accounts.find(p => p.id == u.id); + if (cacheHit) return cacheHit; + + const identifier = `${u.id}:${(u.lastFetchedAt ?? u.updatedAt ?? u.createdAt).getTime()}`; + let fqn = `${u.username}@${u.host ?? config.domain}`; + let acct = u.username; + let acctUrl = `https://${u.host || config.host}/@${u.username}`; + if (u.host) { + acct = `${u.username}@${u.host}`; + acctUrl = `https://${u.host}/@${u.username}`; + } + + const aggregateProfile = (ctx.userProfileAggregate as Map)?.get(u.id); + + let htmlCacheEntry: HtmlUserCacheEntry | null | undefined = undefined; + const htmlCacheEntryLock = new AsyncLock(); + + const profile = aggregateProfile !== undefined + ? aggregateProfile + : UserProfiles.findOneBy({ userId: u.id }); + const bio = this.userBioHtmlCache.fetch(identifier, async () => { + return htmlCacheEntryLock.acquire(u.id, async () => { + if (htmlCacheEntry === undefined) htmlCacheEntry = await this.fetchFromCacheWithFallback(u, await profile, ctx); + if (htmlCacheEntry === null) { + return Promise.resolve(profile).then(async profile => { + return MfmHelpers.toHtml(mfm.parse(profile?.description ?? ""), profile?.mentions, u.host) + .then(p => p ?? escapeMFM(profile?.description ?? "")) + .then(p => p !== '

' ? p : null) + }); + } + return htmlCacheEntry?.bio ?? null; + }); + }, true) + .then(p => p ?? '

'); + + const avatar = u.avatarId + ? DriveFiles.getFinalUrlMaybe(u.avatarUrl) ?? (DriveFiles.findOneBy({ id: u.avatarId })) + .then(p => p?.url ?? Users.getIdenticonUrl(u.id)) + .then(p => DriveFiles.getFinalUrl(p)) + : Users.getIdenticonUrl(u.id); + + const banner = u.bannerId + ? DriveFiles.getFinalUrlMaybe(u.bannerUrl) ?? (DriveFiles.findOneBy({ id: u.bannerId })) + .then(p => p?.url ?? `${config.url}/static-assets/transparent.png`) + .then(p => DriveFiles.getFinalUrl(p)) + : `${config.url}/static-assets/transparent.png`; + + const isFollowedOrSelf = (ctx.followedOrSelfAggregate as Map)?.get(u.id) + ?? (!!localUser && + (localUser.id === u.id || + Followings.exist({ + where: { + followeeId: u.id, + followerId: localUser.id, + }, + }) + )); + + const followersCount = Promise.resolve(profile).then(async profile => { + if (profile === null) return u.followersCount; + switch (profile.ffVisibility) { + case "public": + return u.followersCount; + case "followers": + return Promise.resolve(isFollowedOrSelf).then(isFollowedOrSelf => isFollowedOrSelf ? u.followersCount : 0); + case "private": + return localUser?.id === profile.userId ? u.followersCount : 0; + } + }); + + const followingCount = Promise.resolve(profile).then(async profile => { + if (profile === null) return u.followingCount; + switch (profile.ffVisibility) { + case "public": + return u.followingCount; + case "followers": + return Promise.resolve(isFollowedOrSelf).then(isFollowedOrSelf => isFollowedOrSelf ? u.followingCount : 0); + case "private": + return localUser?.id === profile.userId ? u.followingCount : 0; + } + }); + + const fields = + this.userFieldsHtmlCache.fetch(identifier, async () => { + return htmlCacheEntryLock.acquire(u.id, async () => { + if (htmlCacheEntry === undefined) htmlCacheEntry = await this.fetchFromCacheWithFallback(u, await profile, ctx); + if (htmlCacheEntry === null) { + return Promise.resolve(profile).then(profile => Promise.all(profile?.fields.map(async p => this.encodeField(p, u.host, profile?.mentions)) ?? [])); + } + return htmlCacheEntry?.fields ?? []; + }); + }, true); + + return awaitAll({ + id: u.id, + username: u.username, + acct: acct, + fqn: fqn, + display_name: u.name || u.username, + locked: u.isLocked, + created_at: u.createdAt.toISOString(), + followers_count: followersCount, + following_count: followingCount, + statuses_count: u.notesCount, + note: bio, + url: u.uri ?? acctUrl, + avatar: avatar, + avatar_static: avatar, + header: banner, + header_static: banner, + emojis: populateEmojis(u.emojis, u.host).then(emoji => emoji.map((e) => EmojiConverter.encode(e))), + moved: null, //FIXME + fields: fields, + bot: u.isBot, + discoverable: u.isExplorable + }).then(p => { + // noinspection ES6MissingAwait + UserHelpers.updateUserInBackground(u); + cache.accounts.push(p); + return p; + }); + }); + } + + public static async aggregateData(users: User[], ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser | null; + const targets = unique(users.map(u => u.id)); + + const followedOrSelfAggregate = new Map(); + const userProfileAggregate = new Map(); + const htmlUserCacheAggregate = ctx.htmlUserCacheAggregate ?? new Map(); + + if (config.htmlCache?.dbFallback) { + const htmlUserCacheEntries = await HtmlUserCacheEntries.findBy({ + userId: In(targets) + }); + + for (const target of targets) { + htmlUserCacheAggregate.set(target, htmlUserCacheEntries.find(n => n.userId === target) ?? null); + } + } + + if (user) { + const targetsWithoutSelf = targets.filter(u => u !== user.id); + + if (targetsWithoutSelf.length > 0) { + const followings = await Followings.createQueryBuilder('following') + .select('following.followeeId') + .where('following.followerId = :meId', { meId: user.id }) + .andWhere('following.followeeId IN (:...targets)', { targets: targetsWithoutSelf }) + .getMany(); + + for (const userId of targetsWithoutSelf) { + followedOrSelfAggregate.set(userId, !!followings.find(f => f.followerId === userId)); + } + } + + followedOrSelfAggregate.set(user.id, true); + } + + const profiles = await UserProfiles.findBy({ + userId: In(targets) + }); + + for (const userId of targets) { + userProfileAggregate.set(userId, profiles.find(p => p.userId === userId) ?? null); + } + + ctx.followedOrSelfAggregate = followedOrSelfAggregate; + ctx.htmlUserCacheAggregate = htmlUserCacheAggregate; + } + + public static async aggregateDataByIds(userIds: User["id"][], ctx: MastoContext): Promise { + const targets = unique(userIds); + const htmlUserCacheAggregate = ctx.htmlUserCacheAggregate ?? new Map(); + + if (config.htmlCache?.dbFallback) { + const htmlUserCacheEntries = await HtmlUserCacheEntries.findBy({ + userId: In(targets) + }); + + for (const target of targets) { + htmlUserCacheAggregate.set(target, htmlUserCacheEntries.find(n => n.userId === target) ?? null); + } + } + + ctx.htmlUserCacheAggregate = htmlUserCacheAggregate; + } + + public static async encodeMany(users: User[], ctx: MastoContext): Promise { + await this.aggregateData(users, ctx); + const encoded = users.map(u => this.encode(u, ctx)); + return Promise.all(encoded); + } + + private static async encodeField(f: Field, host: string | null, mentions: IMentionedRemoteUsers): Promise { + return { + name: f.name, + value: await MfmHelpers.toHtml(mfm.parse(f.value), mentions, host, true) ?? escapeMFM(f.value), + verified_at: f.verified ? (new Date()).toISOString() : null, + } + } + + private static async fetchFromCacheWithFallback(user: User, profile: UserProfile | null, ctx: MastoContext): Promise { + if (!config.htmlCache?.dbFallback) return null; + + let dbHit: HtmlUserCacheEntry | Promise | null | undefined = (ctx.htmlUserCacheAggregate as Map | undefined)?.get(user.id); + if (dbHit === undefined) dbHit = HtmlUserCacheEntries.findOneBy({ userId: user.id }); + + return Promise.resolve(dbHit) + .then(res => { + if (res === null || (res.updatedAt.getTime() !== (user.lastFetchedAt ?? user.updatedAt ?? user.createdAt).getTime())) { + return this.dbCacheMiss(user, profile, ctx); + } + return res; + }); + } + + private static async dbCacheMiss(user: User, profile: UserProfile | null, ctx: MastoContext): Promise { + const identifier = `${user.id}:${(user.lastFetchedAt ?? user.updatedAt ?? user.createdAt).getTime()}`; + const cache = ctx.cache as AccountCache; + return cache.locks.acquire(identifier, async () => { + const cachedBio = await this.userBioHtmlCache.get(identifier); + const cachedFields = await this.userFieldsHtmlCache.get(identifier); + if (cachedBio !== undefined && cachedFields !== undefined) { + return { bio: cachedBio, fields: cachedFields } as HtmlUserCacheEntry; + } + + if (profile === undefined) { + profile = await UserProfiles.findOneBy({ userId: user.id }); + } + + let bio: string | null | Promise | undefined = cachedBio; + let fields: MastodonEntity.Field[] | Promise | undefined = cachedFields; + + if (bio === undefined) { + bio = MfmHelpers.toHtml(mfm.parse(profile?.description ?? ""), profile?.mentions, user.host) + .then(p => p ?? escapeMFM(profile?.description ?? "")) + .then(p => p !== '

' ? p : null); + } + + if (fields === undefined) { + fields = Promise.all(profile!.fields.map(async p => this.encodeField(p, user.host, profile!.mentions)) ?? []); + } + + HtmlUserCacheEntries.upsert({ userId: user.id, updatedAt: user.lastFetchedAt ?? user.updatedAt ?? user.createdAt, bio: await bio, fields: await fields }, ["userId"]); + + await this.userBioHtmlCache.set(identifier, await bio); + await this.userFieldsHtmlCache.set(identifier, await fields); + + return { bio, fields } as HtmlUserCacheEntry; + }); + } + + public static async prewarmCache(user: User, profile?: UserProfile | null, oldProfile?: UserProfile | null): Promise { + const identifier = `${user.id}:${(user.lastFetchedAt ?? user.updatedAt ?? user.createdAt).getTime()}`; + if (profile !== null) { + if (config.htmlCache?.dbFallback) { + if (profile === undefined) { + profile = await UserProfiles.findOneBy({ userId: user.id }); + } + if (oldProfile !== undefined && profile?.fields === oldProfile?.fields && profile?.description === oldProfile?.description) { + HtmlUserCacheEntries.update({ userId: user.id }, { updatedAt: user.lastFetchedAt ?? user.updatedAt ?? user.createdAt }); + return; + } + } + + if (!config.htmlCache?.prewarm) return; + + if (profile === undefined) { + profile = await UserProfiles.findOneBy({ userId: user.id }); + } + + if (await this.userBioHtmlCache.get(identifier) === undefined) { + const bio = MfmHelpers.toHtml(mfm.parse(profile?.description ?? ""), profile?.mentions, user.host) + .then(p => p ?? escapeMFM(profile?.description ?? "")) + .then(p => p !== '

' ? p : null); + + this.userBioHtmlCache.set(identifier, await bio); + + if (config.htmlCache?.dbFallback) + HtmlUserCacheEntries.upsert({ userId: user.id, updatedAt: user.lastFetchedAt ?? user.updatedAt ?? user.createdAt, bio: await bio }, ["userId"]); + } + + if (await this.userFieldsHtmlCache.get(identifier) === undefined) { + const fields = await Promise.all(profile!.fields.map(async p => this.encodeField(p, user.host, profile!.mentions)) ?? []); + this.userFieldsHtmlCache.set(identifier, fields); + + if (config.htmlCache?.dbFallback) + HtmlUserCacheEntries.upsert({ userId: user.id, updatedAt: user.lastFetchedAt ?? user.updatedAt ?? user.createdAt, fields: fields }, ["userId"]); + } + } + } + + public static async prewarmCacheById(userId: string, oldProfile?: UserProfile | null): Promise { + await this.prewarmCache(await getUser(userId), undefined, oldProfile); + } +} diff --git a/packages/backend/src/server/api/mastodon/converters/visibility.ts b/packages/backend/src/server/api/mastodon/converters/visibility.ts new file mode 100644 index 0000000..6d9c443 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/converters/visibility.ts @@ -0,0 +1,32 @@ +export type IceshrimpVisibility = "public" | "home" | "followers" | "specified" | "hidden"; +export type MastodonVisibility = "public" | "unlisted" | "private" | "direct"; + +export class VisibilityConverter { + public static encode(v: IceshrimpVisibility): MastodonVisibility { + switch (v) { + case "public": + return v; + case "home": + return "unlisted"; + case "followers": + return "private"; + case "specified": + return "direct"; + case "hidden": + throw new Error(); + } + } + + public static decode(v: MastodonVisibility): IceshrimpVisibility { + switch (v) { + case "public": + return v; + case "unlisted": + return "home"; + case "private": + return "followers"; + case "direct": + return "specified"; + } + } +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/account.ts b/packages/backend/src/server/api/mastodon/endpoints/account.ts new file mode 100644 index 0000000..e453ce6 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/account.ts @@ -0,0 +1,216 @@ +import Router from "@koa/router"; +import { argsToBools, limitToInt, normalizeUrlQuery } from "./timeline.js"; +import { UserConverter } from "@/server/api/mastodon/converters/user.js"; +import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; +import { ListHelpers } from "@/server/api/mastodon/helpers/list.js"; +import { auth } from "@/server/api/mastodon/middleware/auth.js"; +import { SearchHelpers } from "@/server/api/mastodon/helpers/search.js"; +import { filterContext } from "@/server/api/mastodon/middleware/filter-context.js"; + +export function setupEndpointsAccount(router: Router): void { + router.get("/v1/accounts/verify_credentials", + auth(true, ['read:accounts']), + async (ctx) => { + ctx.body = await UserHelpers.verifyCredentials(ctx); + } + ); + router.patch("/v1/accounts/update_credentials", + auth(true, ['write:accounts']), + async (ctx) => { + ctx.body = await UserHelpers.updateCredentials(ctx) + } + ); + router.get("/v1/accounts/lookup", + async (ctx) => { + const args = normalizeUrlQuery(ctx.query); + const user = await UserHelpers.getUserFromAcct(args.acct); + ctx.body = await UserConverter.encode(user, ctx); + } + ); + router.get("/v1/accounts/relationships", + auth(true, ['read:follows']), + async (ctx) => { + const ids = normalizeUrlQuery(ctx.query, ['id[]'])['id[]'] + ?? normalizeUrlQuery(ctx.query, ['id'])['id'] + ?? []; + ctx.body = await UserHelpers.getUserRelationhipToMany(ids, ctx.user.id); + } + ); + // This must come before /accounts/:id, otherwise that will take precedence + router.get("/v1/accounts/search", + auth(true, ['read:accounts']), + async (ctx) => { + const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query), ['resolve', 'following'])); + ctx.body = await SearchHelpers.search(args.q, 'accounts', args.resolve, args.following, undefined, false, undefined, undefined, args.limit, args.offset, ctx) + .then(p => p.accounts); + } + ); + router.get<{ Params: { id: string } }>("/v1/accounts/:id", + auth(false), + async (ctx) => { + ctx.body = await UserConverter.encode(await UserHelpers.getUserOr404(ctx.params.id), ctx); + } + ); + router.get<{ Params: { id: string } }>( + "/v1/accounts/:id/statuses", + auth(false, ["read:statuses"]), + filterContext('account'), + async (ctx) => { + const query = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx); + const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query))); + const res = await UserHelpers.getUserStatuses(query, args.max_id, args.since_id, args.min_id, args.limit, args['only_media'], args['exclude_replies'], args['exclude_reblogs'], args.pinned, args.tagged, ctx); + ctx.body = await NoteConverter.encodeMany(res, ctx); + }, + ); + router.get<{ Params: { id: string } }>( + "/v1/accounts/:id/featured_tags", + async (ctx) => { + ctx.body = []; + }, + ); + router.get<{ Params: { id: string } }>( + "/v1/accounts/:id/followers", + auth(false), + async (ctx) => { + const query = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx); + const args = normalizeUrlQuery(limitToInt(ctx.query as any)); + const res = await UserHelpers.getUserFollowers(query, args.max_id, args.since_id, args.min_id, args.limit, ctx); + ctx.body = await UserConverter.encodeMany(res, ctx); + }, + ); + router.get<{ Params: { id: string } }>( + "/v1/accounts/:id/following", + auth(false), + async (ctx) => { + const query = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx); + const args = normalizeUrlQuery(limitToInt(ctx.query as any)); + const res = await UserHelpers.getUserFollowing(query, args.max_id, args.since_id, args.min_id, args.limit, ctx); + ctx.body = await UserConverter.encodeMany(res, ctx); + }, + ); + router.get<{ Params: { id: string } }>( + "/v1/accounts/:id/lists", + auth(true, ["read:lists"]), + async (ctx) => { + const member = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx); + ctx.body = await ListHelpers.getListsByMember(member, ctx); + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/accounts/:id/follow", + auth(true, ["write:follows"]), + async (ctx) => { + const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx); + //FIXME: Parse form data + ctx.body = await UserHelpers.followUser(target, true, false, ctx); + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/accounts/:id/unfollow", + auth(true, ["write:follows"]), + async (ctx) => { + const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx); + ctx.body = await UserHelpers.unfollowUser(target, ctx); + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/accounts/:id/block", + auth(true, ["write:blocks"]), + async (ctx) => { + const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx); + ctx.body = await UserHelpers.blockUser(target, ctx); + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/accounts/:id/unblock", + auth(true, ["write:blocks"]), + async (ctx) => { + const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx); + ctx.body = await UserHelpers.unblockUser(target, ctx); + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/accounts/:id/mute", + auth(true, ["write:mutes"]), + async (ctx) => { + //FIXME: parse form data + const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query, ['duration']), ['notifications'])); + const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx); + ctx.body = await UserHelpers.muteUser(target, args.notifications, args.duration, ctx); + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/accounts/:id/unmute", + auth(true, ["write:mutes"]), + async (ctx) => { + const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx); + ctx.body = await UserHelpers.unmuteUser(target, ctx); + }, + ); + router.get("/v1/featured_tags", + async (ctx) => { + ctx.body = []; + } + ); + router.get("/v1/followed_tags", + async (ctx) => { + ctx.body = []; + } + ); + router.get("/v1/bookmarks", + auth(true, ["read:bookmarks"]), + async (ctx) => { + const args = normalizeUrlQuery(limitToInt(ctx.query as any)); + const res = await UserHelpers.getUserBookmarks(args.max_id, args.since_id, args.min_id, args.limit, ctx); + ctx.body = await NoteConverter.encodeMany(res, ctx); + } + ); + router.get("/v1/favourites", + auth(true, ["read:favourites"]), + async (ctx) => { + const args = normalizeUrlQuery(limitToInt(ctx.query as any)); + const res = await UserHelpers.getUserFavorites(args.max_id, args.since_id, args.min_id, args.limit, ctx); + ctx.body = await NoteConverter.encodeMany(res, ctx); + } + ); + router.get("/v1/mutes", + auth(true, ["read:mutes"]), + async (ctx) => { + const args = normalizeUrlQuery(limitToInt(ctx.query as any)); + ctx.body = await UserHelpers.getUserMutes(args.max_id, args.since_id, args.min_id, args.limit, ctx); + } + ); + router.get("/v1/blocks", + auth(true, ["read:blocks"]), + async (ctx) => { + const args = normalizeUrlQuery(limitToInt(ctx.query as any)); + const res = await UserHelpers.getUserBlocks(args.max_id, args.since_id, args.min_id, args.limit, ctx); + ctx.body = await UserConverter.encodeMany(res, ctx); + } + ); + router.get("/v1/follow_requests", + auth(true, ["read:follows"]), + async (ctx) => { + const args = normalizeUrlQuery(limitToInt(ctx.query as any)); + const res = await UserHelpers.getUserFollowRequests(args.max_id, args.since_id, args.min_id, args.limit, ctx); + ctx.body = await UserConverter.encodeMany(res, ctx); + } + ); + router.post<{ Params: { id: string } }>( + "/v1/follow_requests/:id/authorize", + auth(true, ["write:follows"]), + async (ctx) => { + const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx); + ctx.body = await UserHelpers.acceptFollowRequest(target, ctx); + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/follow_requests/:id/reject", + auth(true, ["write:follows"]), + async (ctx) => { + const target = await UserHelpers.getUserCachedOr404(ctx.params.id, ctx); + ctx.body = await UserHelpers.rejectFollowRequest(target, ctx); + }, + ); +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/auth.ts b/packages/backend/src/server/api/mastodon/endpoints/auth.ts new file mode 100644 index 0000000..95baa10 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/auth.ts @@ -0,0 +1,35 @@ +import Router from "@koa/router"; +import { AuthHelpers } from "@/server/api/mastodon/helpers/auth.js"; +import { MiAuth } from "@/server/api/mastodon/middleware/auth.js"; + +export function setupEndpointsAuth(router: Router): void { + router.post("/v1/apps", async (ctx) => { + ctx.body = await AuthHelpers.registerApp(ctx); + }); + + router.get("/v1/apps/verify_credentials", async (ctx) => { + ctx.body = await AuthHelpers.verifyAppCredentials(ctx); + }); + + router.post("/v1/iceshrimp/apps/info", + MiAuth(true), + async (ctx) => { + ctx.body = await AuthHelpers.getAppInfo(ctx); + }); + + router.post("/v1/iceshrimp/auth/code", + MiAuth(true), + async (ctx) => { + ctx.body = await AuthHelpers.getAuthCode(ctx); + }); +} + +export function setupEndpointsAuthRoot(router: Router): void { + router.post("/oauth/token", async (ctx) => { + ctx.body = await AuthHelpers.getAuthToken(ctx); + }); + + router.post("/oauth/revoke", async (ctx) => { + ctx.body = await AuthHelpers.revokeAuthToken(ctx); + }); +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/filter.ts b/packages/backend/src/server/api/mastodon/endpoints/filter.ts new file mode 100644 index 0000000..b57adb3 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/filter.ts @@ -0,0 +1,18 @@ +import Router from "@koa/router"; +import { auth } from "@/server/api/mastodon/middleware/auth.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; + +export function setupEndpointsFilter(router: Router): void { + router.get(["/v1/filters", "/v2/filters"], + auth(true, ['read:filters']), + async (ctx) => { + ctx.body = []; + } + ); + router.post(["/v1/filters", "/v2/filters"], + auth(true, ['write:filters']), + async (ctx) => { + throw new MastoApiError(400, "Please change word mute settings in the web frontend settings."); + } + ); +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/list.ts b/packages/backend/src/server/api/mastodon/endpoints/list.ts new file mode 100644 index 0000000..9b059a6 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/list.ts @@ -0,0 +1,98 @@ +import Router from "@koa/router"; +import { limitToInt, normalizeUrlQuery } from "@/server/api/mastodon/endpoints/timeline.js"; +import { ListHelpers } from "@/server/api/mastodon/helpers/list.js"; +import { UserConverter } from "@/server/api/mastodon/converters/user.js"; +import { UserLists } from "@/models/index.js"; +import { getUser } from "@/server/api/common/getters.js"; +import { toArray } from "@/prelude/array.js"; +import { auth } from "@/server/api/mastodon/middleware/auth.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; + +export function setupEndpointsList(router: Router): void { + router.get("/v1/lists", + auth(true, ['read:lists']), + async (ctx, reply) => { + ctx.body = await ListHelpers.getLists(ctx); + } + ); + router.get<{ Params: { id: string } }>( + "/v1/lists/:id", + auth(true, ['read:lists']), + async (ctx, reply) => { + ctx.body = await ListHelpers.getListOr404(ctx.params.id, ctx); + }, + ); + router.post("/v1/lists", + auth(true, ['write:lists']), + async (ctx, reply) => { + const body = ctx.request.body as any; + const title = (body.title ?? '').trim(); + ctx.body = await ListHelpers.createList(title, ctx); + } + ); + router.put<{ Params: { id: string } }>( + "/v1/lists/:id", + auth(true, ['write:lists']), + async (ctx, reply) => { + const list = await UserLists.findOneBy({ userId: ctx.user.id, id: ctx.params.id }); + if (!list) throw new MastoApiError(404); + + const body = ctx.request.body as any; + const title = (body.title ?? '').trim(); + const exclusive = body.exclusive ?? undefined as boolean | undefined; + ctx.body = await ListHelpers.updateList(list, title, exclusive, ctx); + }, + ); + router.delete<{ Params: { id: string } }>( + "/v1/lists/:id", + auth(true, ['write:lists']), + async (ctx, reply) => { + const list = await UserLists.findOneBy({ userId: ctx.user.id, id: ctx.params.id }); + if (!list) throw new MastoApiError(404); + + await ListHelpers.deleteList(list, ctx); + ctx.body = {}; + }, + ); + router.get<{ Params: { id: string } }>( + "/v1/lists/:id/accounts", + auth(true, ['read:lists']), + async (ctx, reply) => { + const args = normalizeUrlQuery(limitToInt(ctx.query)); + const res = await ListHelpers.getListUsers(ctx.params.id, args.max_id, args.since_id, args.min_id, args.limit, ctx); + ctx.body = await UserConverter.encodeMany(res, ctx); + }, + ); + router.post<{ Params: { id: string } }>( + "/v1/lists/:id/accounts", + auth(true, ['write:lists']), + async (ctx, reply) => { + const list = await UserLists.findOneBy({ userId: ctx.user.id, id: ctx.params.id }); + if (!list) throw new MastoApiError(404); + + const body = ctx.request.body as any; + if (!body['account_ids']) throw new MastoApiError(400, "Missing account_ids[] field"); + + const ids = toArray(body['account_ids']); + const targets = await Promise.all(ids.map(p => getUser(p))); + await ListHelpers.addToList(list, targets, ctx); + ctx.body = {} + }, + ); + router.delete<{ Params: { id: string } }>( + "/v1/lists/:id/accounts", + auth(true, ['write:lists']), + async (ctx, reply) => { + const list = await UserLists.findOneBy({ userId: ctx.user.id, id: ctx.params.id }); + if (!list) throw new MastoApiError(404); + + const body = ctx.request.body as any; + if (!body['account_ids']) throw new MastoApiError(400, "Missing account_ids[] field"); + + const ids = toArray(body['account_ids']); + const targets = await Promise.all(ids.map(p => getUser(p))); + await ListHelpers.removeFromList(list, targets, ctx); + ctx.body = {} + }, + ); +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/media.ts b/packages/backend/src/server/api/mastodon/endpoints/media.ts new file mode 100644 index 0000000..b47da0b --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/media.ts @@ -0,0 +1,29 @@ +import Router from "@koa/router"; +import { MediaHelpers } from "@/server/api/mastodon/helpers/media.js"; +import { FileConverter } from "@/server/api/mastodon/converters/file.js"; +import { auth } from "@/server/api/mastodon/middleware/auth.js"; + +export function setupEndpointsMedia(router: Router): void { + router.get<{ Params: { id: string } }>("/v1/media/:id", + auth(true, ['write:media']), + async (ctx) => { + const file = await MediaHelpers.getMediaPackedOr404(ctx.params.id, ctx); + ctx.body = FileConverter.encode(file); + } + ); + router.put<{ Params: { id: string } }>("/v1/media/:id", + auth(true, ['write:media']), + async (ctx) => { + const file = await MediaHelpers.getMediaOr404(ctx.params.id, ctx); + ctx.body = await MediaHelpers.updateMedia(file, ctx) + .then(p => FileConverter.encode(p)); + } + ); + router.post(["/v2/media", "/v1/media"], + auth(true, ['write:media']), + async (ctx) => { + ctx.body = await MediaHelpers.uploadMedia(ctx) + .then(p => FileConverter.encode(p)); + } + ); +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/misc.ts b/packages/backend/src/server/api/mastodon/endpoints/misc.ts new file mode 100644 index 0000000..2abf45b --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/misc.ts @@ -0,0 +1,79 @@ +import Router from "@koa/router"; +import { MiscHelpers } from "@/server/api/mastodon/helpers/misc.js"; +import { argsToBools, limitToInt } from "@/server/api/mastodon/endpoints/timeline.js"; +import { Announcements } from "@/models/index.js"; +import { auth } from "@/server/api/mastodon/middleware/auth.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { filterContext } from "@/server/api/mastodon/middleware/filter-context.js"; + +export function setupEndpointsMisc(router: Router): void { + router.get("/v1/custom_emojis", + async (ctx) => { + ctx.body = await MiscHelpers.getCustomEmoji(); + } + ); + + router.get("/v1/instance", + async (ctx) => { + ctx.body = await MiscHelpers.getInstance(ctx); + } + ); + + router.get("/v1/announcements", + auth(true), + async (ctx) => { + const args = argsToBools(ctx.query, ['with_dismissed']); + ctx.body = await MiscHelpers.getAnnouncements(args['with_dismissed'], ctx); + } + ); + + router.post<{ Params: { id: string } }>( + "/v1/announcements/:id/dismiss", + auth(true, ['write:accounts']), + async (ctx) => { + const announcement = await Announcements.findOneBy({ id: ctx.params.id }); + if (!announcement) throw new MastoApiError(404); + + await MiscHelpers.dismissAnnouncement(announcement, ctx); + ctx.body = {}; + }, + ); + + //FIXME: add link pagination to trends (ref: https://mastodon.social/api/v1/trends/tags?offset=10&limit=1) + router.get(["/v1/trends/tags", "/v1/trends"], + async (ctx) => { + const args = limitToInt(ctx.query); + ctx.body = await MiscHelpers.getTrendingHashtags(args.limit, args.offset); + //FIXME: convert ids + } + ); + + router.get("/v1/trends/statuses", + filterContext('public'), + async (ctx) => { + const args = limitToInt(ctx.query); + ctx.body = await MiscHelpers.getTrendingStatuses(args.limit, args.offset, ctx); + } + ); + + router.get("/v1/trends/links", + async (ctx) => { + ctx.body = []; + } + ); + + router.get("/v1/preferences", + auth(true, ['read:accounts']), + async (ctx) => { + ctx.body = await MiscHelpers.getPreferences(ctx); + } + ); + + router.get("/v2/suggestions", + auth(true, ['read:accounts']), + async (ctx) => { + const args = limitToInt(ctx.query); + ctx.body = await MiscHelpers.getFollowSuggestions(args.limit, ctx); + } + ); +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/notifications.ts b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts new file mode 100644 index 0000000..49e7914 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/notifications.ts @@ -0,0 +1,52 @@ +import Router from "@koa/router"; +import { limitToInt, normalizeUrlQuery } from "./timeline.js"; +import { NotificationHelpers } from "@/server/api/mastodon/helpers/notification.js"; +import { NotificationConverter } from "@/server/api/mastodon/converters/notification.js"; +import { auth } from "@/server/api/mastodon/middleware/auth.js"; +import { filterContext } from "@/server/api/mastodon/middleware/filter-context.js"; + +export function setupEndpointsNotifications(router: Router): void { + router.get("/v1/notifications", + auth(true, ['read:notifications']), + filterContext('notifications'), + async (ctx) => { + const args = normalizeUrlQuery(limitToInt(ctx.query), ['types[]', 'exclude_types[]']); + const res = await NotificationHelpers.getNotifications(args.max_id, args.since_id, args.min_id, args.limit, args['types[]'], args['exclude_types[]'], args.account_id, ctx); + ctx.body = await NotificationConverter.encodeMany(res, ctx); + } + ); + + router.get("/v1/notifications/:id", + auth(true, ['read:notifications']), + filterContext('notifications'), + async (ctx) => { + const notification = await NotificationHelpers.getNotificationOr404(ctx.params.id, ctx); + ctx.body = await NotificationConverter.encode(notification, ctx); + } + ); + + router.post("/v1/notifications/clear", + auth(true, ['write:notifications']), + async (ctx) => { + await NotificationHelpers.clearAllNotifications(ctx); + ctx.body = {}; + } + ); + + router.post("/v1/notifications/:id/dismiss", + auth(true, ['write:notifications']), + async (ctx) => { + const notification = await NotificationHelpers.getNotificationOr404(ctx.params.id, ctx); + await NotificationHelpers.dismissNotification(notification.id, ctx); + ctx.body = {}; + } + ); + + router.post("/v1/conversations/:id/read", + auth(true, ['write:conversations']), + async (ctx, reply) => { + await NotificationHelpers.markConversationAsRead(ctx.params.id, ctx); + ctx.body = {}; + } + ); +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/search.ts b/packages/backend/src/server/api/mastodon/endpoints/search.ts new file mode 100644 index 0000000..6dc0803 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/search.ts @@ -0,0 +1,21 @@ +import Router from "@koa/router"; +import { argsToBools, limitToInt, normalizeUrlQuery } from "./timeline.js"; +import { SearchHelpers } from "@/server/api/mastodon/helpers/search.js"; +import { auth } from "@/server/api/mastodon/middleware/auth.js"; + +export function setupEndpointsSearch(router: Router): void { + router.get(["/v1/search", "/v2/search"], + auth(true, ['read:search']), + async (ctx) => { + const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query), ['resolve', 'following', 'exclude_unreviewed'])); + ctx.body = await SearchHelpers.search(args.q, args.type, args.resolve, args.following, args.account_id, args['exclude_unreviewed'], args.max_id, args.min_id, args.limit, args.offset, ctx); + + if (ctx.path === "/v1/search") { + ctx.body = { + ...ctx.body, + hashtags: ctx.body.hashtags.map((p: MastodonEntity.Tag) => p.name), + }; + } + } + ); +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/status.ts b/packages/backend/src/server/api/mastodon/endpoints/status.ts new file mode 100644 index 0000000..8379b08 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/status.ts @@ -0,0 +1,241 @@ +import Router from "@koa/router"; +import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { NoteHelpers } from "@/server/api/mastodon/helpers/note.js"; +import { limitToInt, normalizeUrlQuery } from "@/server/api/mastodon/endpoints/timeline.js"; +import { UserConverter } from "@/server/api/mastodon/converters/user.js"; +import { PollHelpers } from "@/server/api/mastodon/helpers/poll.js"; +import { toArray } from "@/prelude/array.js"; +import { auth } from "@/server/api/mastodon/middleware/auth.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { filterContext } from "@/server/api/mastodon/middleware/filter-context.js"; + +export function setupEndpointsStatus(router: Router): void { + router.post("/v1/statuses", + auth(true, ['write:statuses']), + async (ctx) => { + const key = NoteHelpers.getIdempotencyKey(ctx); + if (key !== null) { + const result = await NoteHelpers.getFromIdempotencyCache(key); + + if (result) { + ctx.body = result; + return; + } + } + + let request = NoteHelpers.normalizeComposeOptions(ctx.request.body); + ctx.body = await NoteHelpers.createNote(request, ctx) + .then(p => NoteConverter.encode(p, ctx)); + + if (key !== null) NoteHelpers.postIdempotencyCache.set(key, { status: ctx.body }); + } + ); + router.put("/v1/statuses/:id", + auth(true, ['write:statuses']), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + let request = NoteHelpers.normalizeEditOptions(ctx.request.body); + ctx.body = await NoteHelpers.editNote(request, note, ctx) + .then(p => NoteConverter.encode(p, ctx)); + } + ); + router.get<{ Params: { id: string } }>("/v1/statuses/:id", + auth(false, ["read:statuses"]), + filterContext('thread'), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + + ctx.body = await NoteConverter.encode(note, ctx); + } + ); + router.delete<{ Params: { id: string } }>("/v1/statuses/:id", + auth(true, ['write:statuses']), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + ctx.body = await NoteHelpers.deleteNote(note, ctx); + } + ); + + router.get<{ Params: { id: string } }>( + "/v1/statuses/:id/context", + auth(false, ["read:statuses"]), + filterContext('thread'), + async (ctx) => { + //FIXME: determine final limits within helper functions instead of here + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + const ancestors = await NoteHelpers.getNoteAncestors(note, ctx.user ? 4096 : 60, ctx) + .then(n => NoteConverter.encodeMany(n, ctx)); + const descendants = await NoteHelpers.getNoteDescendants(note, ctx.user ? 4096 : 40, ctx.user ? 4096 : 20, ctx) + .then(n => NoteConverter.encodeMany(n, ctx)); + + ctx.body = { + ancestors, + descendants, + }; + } + ); + router.get<{ Params: { id: string } }>( + "/v1/statuses/:id/history", + auth(false, ["read:statuses"]), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + ctx.body = await NoteHelpers.getNoteEditHistory(note, ctx); + } + ); + router.get<{ Params: { id: string } }>( + "/v1/statuses/:id/source", + auth(true, ["read:statuses"]), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + ctx.body = NoteHelpers.getNoteSource(note); + } + ); + router.get<{ Params: { id: string } }>( + "/v1/statuses/:id/reblogged_by", + auth(false, ["read:statuses"]), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + const args = normalizeUrlQuery(limitToInt(ctx.query as any)); + const res = await NoteHelpers.getNoteRebloggedBy(note, args.max_id, args.since_id, args.min_id, args.limit, ctx); + ctx.body = await UserConverter.encodeMany(res, ctx); + } + ); + router.get<{ Params: { id: string } }>( + "/v1/statuses/:id/favourited_by", + auth(false, ["read:statuses"]), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + const args = normalizeUrlQuery(limitToInt(ctx.query as any)); + const res = await NoteHelpers.getNoteFavoritedBy(note, args.max_id, args.since_id, args.min_id, args.limit, ctx); + ctx.body = await UserConverter.encodeMany(res, ctx); + } + ); + router.post<{ Params: { id: string } }>( + "/v1/statuses/:id/favourite", + auth(true, ["write:favourites"]), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + const reaction = await NoteHelpers.getDefaultReaction(); + + ctx.body = await NoteHelpers.reactToNote(note, reaction, ctx) + .then(p => NoteConverter.encode(p, ctx)); + } + ); + router.post<{ Params: { id: string } }>( + "/v1/statuses/:id/unfavourite", + auth(true, ["write:favourites"]), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + + ctx.body = await NoteHelpers.removeReactFromNote(note, ctx) + .then(p => NoteConverter.encode(p, ctx)); + }, + ); + + router.post<{ Params: { id: string } }>( + "/v1/statuses/:id/reblog", + auth(true, ["write:statuses"]), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + + ctx.body = await NoteHelpers.reblogNote(note, ctx) + .then(p => NoteConverter.encode(p, ctx)); + }, + ); + + router.post<{ Params: { id: string } }>( + "/v1/statuses/:id/unreblog", + auth(true, ["write:statuses"]), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + + ctx.body = await NoteHelpers.unreblogNote(note, ctx) + .then(p => NoteConverter.encode(p, ctx)); + }, + ); + + router.post<{ Params: { id: string } }>( + "/v1/statuses/:id/bookmark", + auth(true, ["write:bookmarks"]), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + + ctx.body = await NoteHelpers.bookmarkNote(note, ctx) + .then(p => NoteConverter.encode(p, ctx)); + }, + ); + + router.post<{ Params: { id: string } }>( + "/v1/statuses/:id/unbookmark", + auth(true, ["write:bookmarks"]), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + + ctx.body = await NoteHelpers.unbookmarkNote(note, ctx) + .then(p => NoteConverter.encode(p, ctx)); + }, + ); + + router.post<{ Params: { id: string } }>( + "/v1/statuses/:id/pin", + auth(true, ["write:accounts"]), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + + ctx.body = await NoteHelpers.pinNote(note, ctx) + .then(p => NoteConverter.encode(p, ctx)); + }, + ); + + router.post<{ Params: { id: string } }>( + "/v1/statuses/:id/unpin", + auth(true, ["write:accounts"]), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + + ctx.body = await NoteHelpers.unpinNote(note, ctx) + .then(p => NoteConverter.encode(p, ctx)); + }, + ); + + router.post<{ Params: { id: string; name: string } }>( + "/v1/statuses/:id/react/:name", + auth(true, ["write:favourites"]), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + + ctx.body = await NoteHelpers.reactToNote(note, ctx.params.name, ctx) + .then(p => NoteConverter.encode(p, ctx)); + }, + ); + + router.post<{ Params: { id: string; name: string } }>( + "/v1/statuses/:id/unreact/:name", + auth(true, ["write:favourites"]), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + + ctx.body = await NoteHelpers.removeReactFromNote(note, ctx) + .then(p => NoteConverter.encode(p, ctx)); + }, + ); + router.get<{ Params: { id: string } }>("/v1/polls/:id", + auth(false, ["read:statuses"]), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + ctx.body = await PollHelpers.getPoll(note, ctx); + }); + router.post<{ Params: { id: string } }>( + "/v1/polls/:id/votes", + auth(true, ["write:statuses"]), + async (ctx) => { + const note = await NoteHelpers.getNoteOr404(ctx.params.id, ctx); + + const body: any = ctx.request.body; + const choices = toArray(body.choices ?? []).map(p => parseInt(p)); + if (choices.length < 1) throw new MastoApiError(400, "Must vote for at least one option"); + + ctx.body = await PollHelpers.voteInPoll(choices, note, ctx); + }, + ); +} diff --git a/packages/backend/src/server/api/mastodon/endpoints/streaming.ts b/packages/backend/src/server/api/mastodon/endpoints/streaming.ts new file mode 100644 index 0000000..df378bf --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/streaming.ts @@ -0,0 +1,7 @@ +import Router from "@koa/router"; + +export function setupEndpointsStreaming(router: Router): void { + router.get("/v1/streaming/health", async (ctx) => { + ctx.body = "OK"; + }); +} \ No newline at end of file diff --git a/packages/backend/src/server/api/mastodon/endpoints/timeline.ts b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts new file mode 100644 index 0000000..9849cf8 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/endpoints/timeline.ts @@ -0,0 +1,102 @@ +import Router from "@koa/router"; +import { ParsedUrlQuery } from "querystring"; +import { TimelineHelpers } from "@/server/api/mastodon/helpers/timeline.js"; +import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { UserLists } from "@/models/index.js"; +import { auth } from "@/server/api/mastodon/middleware/auth.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { filterContext } from "@/server/api/mastodon/middleware/filter-context.js"; + +//TODO: Move helper functions to a helper class +export function limitToInt(q: ParsedUrlQuery, additional: string[] = []) { + let object: any = q; + if (q.limit) + if (typeof q.limit === "string") object.limit = parseInt(q.limit, 10); + if (q.offset) + if (typeof q.offset === "string") object.offset = parseInt(q.offset, 10); + for (const key of additional) + if (typeof q[key] === "string") object[key] = parseInt(q[key], 10); + return object; +} + +export function argsToBools(q: ParsedUrlQuery, additional: string[] = []) { + // Values taken from https://docs.joinmastodon.org/client/intro/#boolean + const toBoolean = (value: string) => + !["0", "f", "F", "false", "FALSE", "off", "OFF"].includes(value); + + // Keys taken from: + // - https://docs.joinmastodon.org/methods/accounts/#statuses + // - https://docs.joinmastodon.org/methods/timelines/#public + // - https://docs.joinmastodon.org/methods/timelines/#tag + let keys = ['only_media', 'exclude_replies', 'exclude_reblogs', 'pinned', 'local', 'remote'].concat(additional); + let object: any = q; + + for (const key of keys) + if (q[key] && typeof q[key] === "string") + object[key] = toBoolean(q[key]); + + return object; +} + +export function normalizeUrlQuery(q: ParsedUrlQuery, arrayKeys: string[] = []): any { + const dict: any = {}; + + for (const k in q) { + if (arrayKeys.includes(k)) + dict[k] = Array.isArray(q[k]) ? q[k] : [q[k]]; + else + dict[k] = Array.isArray(q[k]) ? q[k]?.at(-1) : q[k]; + } + + return dict; +} + +export function setupEndpointsTimeline(router: Router): void { + router.get("/v1/timelines/public", + auth(true, ['read:statuses']), + filterContext('public'), + async (ctx, reply) => { + const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query))); + const res = await TimelineHelpers.getPublicTimeline(args.max_id, args.since_id, args.min_id, args.limit, args.only_media, args.local, args.remote, ctx); + ctx.body = await NoteConverter.encodeMany(res, ctx); + }); + router.get<{ Params: { hashtag: string } }>( + "/v1/timelines/tag/:hashtag", + auth(false, ['read:statuses']), + filterContext('public'), + async (ctx, reply) => { + const tag = (ctx.params.hashtag ?? '').trim().toLowerCase(); + const args = normalizeUrlQuery(argsToBools(limitToInt(ctx.query)), ['any[]', 'all[]', 'none[]']); + const res = await TimelineHelpers.getTagTimeline(tag, args.max_id, args.since_id, args.min_id, args.limit, args['any[]'] ?? [], args['all[]'] ?? [], args['none[]'] ?? [], args.only_media, args.local, args.remote, ctx); + ctx.body = await NoteConverter.encodeMany(res, ctx); + }, + ); + router.get("/v1/timelines/home", + auth(true, ['read:statuses']), + filterContext('home'), + async (ctx, reply) => { + const args = normalizeUrlQuery(limitToInt(ctx.query)); + const res = await TimelineHelpers.getHomeTimeline(args.max_id, args.since_id, args.min_id, args.limit, ctx); + ctx.body = await NoteConverter.encodeMany(res, ctx); + }); + router.get<{ Params: { listId: string } }>( + "/v1/timelines/list/:listId", + auth(true, ['read:lists']), + filterContext('home'), + async (ctx, reply) => { + const list = await UserLists.findOneBy({ userId: ctx.user.id, id: ctx.params.listId }); + if (!list) throw new MastoApiError(404); + + const args = normalizeUrlQuery(limitToInt(ctx.query)); + const res = await TimelineHelpers.getListTimeline(list, args.max_id, args.since_id, args.min_id, args.limit, ctx); + ctx.body = await NoteConverter.encodeMany(res, ctx); + }, + ); + router.get("/v1/conversations", + auth(true, ['read:statuses']), + async (ctx, reply) => { + const args = normalizeUrlQuery(limitToInt(ctx.query)); + ctx.body = await TimelineHelpers.getConversations(args.max_id, args.since_id, args.min_id, args.limit, ctx); + } + ); +} diff --git a/packages/backend/src/server/api/mastodon/entities/account.ts b/packages/backend/src/server/api/mastodon/entities/account.ts new file mode 100644 index 0000000..68ebb4a --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/account.ts @@ -0,0 +1,38 @@ +/// +/// +/// +namespace MastodonEntity { + export type Account = { + id: string; + username: string; + acct: string; + fqn: string; + display_name: string; + locked: boolean; + created_at: string; + followers_count: number; + following_count: number; + statuses_count: number; + note: string; + url: string; + avatar: string; + avatar_static: string; + header: string; + header_static: string; + emojis: Array; + moved: Account | null; + fields: Array; + bot: boolean | null; + discoverable: boolean; + source?: Source; + }; + + export type MutedAccount = Account | { + mute_expires_at: string | null; + } + + export type SuggestedAccount = { + source: "staff" | "past_interactions" | "global", + account: Account + } +} diff --git a/packages/backend/src/server/api/mastodon/entities/activity.ts b/packages/backend/src/server/api/mastodon/entities/activity.ts new file mode 100644 index 0000000..af53b9b --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/activity.ts @@ -0,0 +1,8 @@ +namespace MastodonEntity { + export type Activity = { + week: string; + statuses: string; + logins: string; + registrations: string; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/announcement.ts b/packages/backend/src/server/api/mastodon/entities/announcement.ts new file mode 100644 index 0000000..0876d8a --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/announcement.ts @@ -0,0 +1,34 @@ +/// +/// +/// + +namespace MastodonEntity { + export type Announcement = { + id: string; + content: string; + starts_at: string | null; + ends_at: string | null; + published: boolean; + all_day: boolean; + published_at: string; + updated_at: string; + read?: boolean; + mentions: Array; + statuses: Array; + tags: Array; + emojis: Array; + reactions: Array; + }; + + export type AnnouncementAccount = { + id: string; + username: string; + url: string; + acct: string; + }; + + export type AnnouncementStatus = { + id: string; + url: string; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/application.ts b/packages/backend/src/server/api/mastodon/entities/application.ts new file mode 100644 index 0000000..cdb97df --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/application.ts @@ -0,0 +1,7 @@ +namespace MastodonEntity { + export type Application = { + name: string; + website?: string | null; + vapid_key?: string | null; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/async_attachment.ts b/packages/backend/src/server/api/mastodon/entities/async_attachment.ts new file mode 100644 index 0000000..32d01dd --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/async_attachment.ts @@ -0,0 +1,14 @@ +/// +namespace MastodonEntity { + export type AsyncAttachment = { + id: string; + type: "unknown" | "image" | "gifv" | "video" | "audio"; + url: string | null; + remote_url: string | null; + preview_url: string; + text_url: string | null; + meta: Meta | null; + description: string | null; + blurhash: string | null; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/attachment.ts b/packages/backend/src/server/api/mastodon/entities/attachment.ts new file mode 100644 index 0000000..0945097 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/attachment.ts @@ -0,0 +1,49 @@ +namespace MastodonEntity { + export type Sub = { + // For Image, Gifv, and Video + width?: number; + height?: number; + size?: string; + aspect?: number; + + // For Gifv and Video + frame_rate?: string; + + // For Audio, Gifv, and Video + duration?: number; + bitrate?: number; + }; + + export type Focus = { + x: number; + y: number; + }; + + export type Meta = { + original?: Sub; + small?: Sub; + focus?: Focus; + length?: string; + duration?: number; + fps?: number; + size?: string; + width?: number; + height?: number; + aspect?: number; + audio_encode?: string; + audio_bitrate?: string; + audio_channel?: string; + }; + + export type Attachment = { + id: string; + type: "unknown" | "image" | "gifv" | "video" | "audio"; + url: string; + remote_url: string | null; + preview_url: string; + text_url: string | null; + meta: Meta | null; + description: string | null; + blurhash: string | null; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/card.ts b/packages/backend/src/server/api/mastodon/entities/card.ts new file mode 100644 index 0000000..75d3ba8 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/card.ts @@ -0,0 +1,16 @@ +namespace MastodonEntity { + export type Card = { + url: string; + title: string; + description: string; + type: "link" | "photo" | "video" | "rich"; + image?: string; + author_name?: string; + author_url?: string; + provider_name?: string; + provider_url?: string; + html?: string; + width?: number; + height?: number; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/context.ts b/packages/backend/src/server/api/mastodon/entities/context.ts new file mode 100644 index 0000000..401257c --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/context.ts @@ -0,0 +1,8 @@ +/// + +namespace MastodonEntity { + export type Context = { + ancestors: Array; + descendants: Array; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/conversation.ts b/packages/backend/src/server/api/mastodon/entities/conversation.ts new file mode 100644 index 0000000..81399d9 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/conversation.ts @@ -0,0 +1,11 @@ +/// +/// + +namespace MastodonEntity { + export type Conversation = { + id: string; + accounts: Array; + last_status: Status | null; + unread: boolean; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/emoji.ts b/packages/backend/src/server/api/mastodon/entities/emoji.ts new file mode 100644 index 0000000..7a6133f --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/emoji.ts @@ -0,0 +1,9 @@ +namespace MastodonEntity { + export type Emoji = { + shortcode: string; + static_url: string; + url: string; + visible_in_picker: boolean; + category: string | undefined; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/featured_tag.ts b/packages/backend/src/server/api/mastodon/entities/featured_tag.ts new file mode 100644 index 0000000..5b3f965 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/featured_tag.ts @@ -0,0 +1,8 @@ +namespace MastodonEntity { + export type FeaturedTag = { + id: string; + name: string; + statuses_count: number; + last_status_at: string; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/field.ts b/packages/backend/src/server/api/mastodon/entities/field.ts new file mode 100644 index 0000000..ed9ec03 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/field.ts @@ -0,0 +1,7 @@ +namespace MastodonEntity { + export type Field = { + name: string; + value: string; + verified_at: string | null; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/filter.ts b/packages/backend/src/server/api/mastodon/entities/filter.ts new file mode 100644 index 0000000..3d76c2c --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/filter.ts @@ -0,0 +1,13 @@ +namespace MastodonEntity { + export type Filter = { + id: string; + title: string; + context: Array; + expires_at: string | null; + filter_action: 'warn' | 'hide'; + keywords: FilterKeyword[]; + statuses: FilterStatus[]; + }; + + export type FilterContext = 'home' | 'notifications' | 'public' | 'thread' | 'account'; +} diff --git a/packages/backend/src/server/api/mastodon/entities/filter_keyword.ts b/packages/backend/src/server/api/mastodon/entities/filter_keyword.ts new file mode 100644 index 0000000..558026e --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/filter_keyword.ts @@ -0,0 +1,7 @@ +namespace MastodonEntity { + export type FilterKeyword = { + id: string; + keyword: string; + whole_word: boolean; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/filter_result.ts b/packages/backend/src/server/api/mastodon/entities/filter_result.ts new file mode 100644 index 0000000..c3f39c4 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/filter_result.ts @@ -0,0 +1,7 @@ +namespace MastodonEntity { + export type FilterResult = { + filter: Filter; + keyword_matches?: string[]; + status_matches?: string[]; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/filter_status.ts b/packages/backend/src/server/api/mastodon/entities/filter_status.ts new file mode 100644 index 0000000..bb585cc --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/filter_status.ts @@ -0,0 +1,6 @@ +namespace MastodonEntity { + export type FilterStatus = { + id: string; + status_id: string; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/history.ts b/packages/backend/src/server/api/mastodon/entities/history.ts new file mode 100644 index 0000000..ddb0d9d --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/history.ts @@ -0,0 +1,7 @@ +namespace MastodonEntity { + export type History = { + day: string; + uses: number; + accounts: number; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/identity_proof.ts b/packages/backend/src/server/api/mastodon/entities/identity_proof.ts new file mode 100644 index 0000000..07a4e33 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/identity_proof.ts @@ -0,0 +1,9 @@ +namespace MastodonEntity { + export type IdentityProof = { + provider: string; + provider_username: string; + updated_at: string; + proof_url: string; + profile_url: string; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/instance.ts b/packages/backend/src/server/api/mastodon/entities/instance.ts new file mode 100644 index 0000000..0d0e4f7 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/instance.ts @@ -0,0 +1,41 @@ +/// +/// +/// + +namespace MastodonEntity { + export type Instance = { + uri: string; + title: string; + description: string; + email: string; + version: string; + thumbnail: string | null; + urls: URLs; + stats: Stats; + languages: Array; + contact_account: Account | null; + max_toot_chars?: number; + registrations?: boolean; + configuration?: { + statuses: { + max_characters: number; + max_media_attachments: number; + characters_reserved_per_url: number; + }; + media_attachments: { + supported_mime_types: Array; + image_size_limit: number; + image_matrix_limit: number; + video_size_limit: number; + video_frame_limit: number; + video_matrix_limit: number; + }; + polls: { + max_options: number; + max_characters_per_option: number; + min_expiration: number; + max_expiration: number; + }; + }; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/list.ts b/packages/backend/src/server/api/mastodon/entities/list.ts new file mode 100644 index 0000000..ae6b07c --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/list.ts @@ -0,0 +1,7 @@ +namespace MastodonEntity { + export type List = { + id: string; + title: string; + exclusive: boolean; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/marker.ts b/packages/backend/src/server/api/mastodon/entities/marker.ts new file mode 100644 index 0000000..7ee1bb4 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/marker.ts @@ -0,0 +1,15 @@ +namespace MastodonEntity { + export type Marker = { + home?: { + last_read_id: string; + version: number; + updated_at: string; + }; + notifications?: { + last_read_id: string; + version: number; + updated_at: string; + unread_count?: number; + }; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/mention.ts b/packages/backend/src/server/api/mastodon/entities/mention.ts new file mode 100644 index 0000000..5c9e8e4 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/mention.ts @@ -0,0 +1,8 @@ +namespace MastodonEntity { + export type Mention = { + id: string; + username: string; + url: string; + acct: string; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/notification.ts b/packages/backend/src/server/api/mastodon/entities/notification.ts new file mode 100644 index 0000000..753317e --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/notification.ts @@ -0,0 +1,23 @@ +/// +/// + +namespace MastodonEntity { + export type Notification = { + account: Account; + created_at: string; + id: string; + status?: Status; + reaction?: Reaction; + type: NotificationType; + }; + + export type NotificationType = + 'follow' + | 'favourite' + | 'reblog' + | 'mention' + | 'reaction' + | 'follow_request' + | 'status' + | 'poll'; +} diff --git a/packages/backend/src/server/api/mastodon/entities/oauth/oauth.ts b/packages/backend/src/server/api/mastodon/entities/oauth/oauth.ts new file mode 100644 index 0000000..a8498f7 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/oauth/oauth.ts @@ -0,0 +1,89 @@ +/** + * OAuth + * Response data when oauth request. + **/ +namespace OAuth { + export type Application = { + id: string; + name: string; + website: string | null; + redirect_uri: string; + vapid_key: string | undefined; + client_id: string; + client_secret: string; + }; + + export type TokenDataFromServer = { + access_token: string; + token_type: string; + scope: string; + created_at: number; + expires_in: number | null; + refresh_token: string | null; + }; + + export class TokenData { + public _scope: string; + + constructor( + public access_token: string, + public token_type: string, + scope: string, + public created_at: number, + public expires_in: number | null = null, + public refresh_token: string | null = null, + ) { + this._scope = scope; + } + + /** + * Serialize raw token data from server + * @param raw from server + */ + static from(raw: TokenDataFromServer) { + return new this( + raw.access_token, + raw.token_type, + raw.scope, + raw.created_at, + raw.expires_in, + raw.refresh_token, + ); + } + + /** + * OAuth Aceess Token + */ + get accessToken() { + return this.access_token; + } + + get tokenType() { + return this.token_type; + } + + get scope() { + return this._scope; + } + + /** + * Application ID + */ + get createdAt() { + return this.created_at; + } + + get expiresIn() { + return this.expires_in; + } + + /** + * OAuth Refresh Token + */ + get refreshToken() { + return this.refresh_token; + } + } +} + +export default OAuth; diff --git a/packages/backend/src/server/api/mastodon/entities/poll.ts b/packages/backend/src/server/api/mastodon/entities/poll.ts new file mode 100644 index 0000000..6eefdbc --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/poll.ts @@ -0,0 +1,15 @@ +/// + +namespace MastodonEntity { + export type Poll = { + id: string; + expires_at: string | null; + expired: boolean; + multiple: boolean; + votes_count: number; + options: Array; + voted: boolean; + own_votes: Array; + emojis: Array; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/poll_option.ts b/packages/backend/src/server/api/mastodon/entities/poll_option.ts new file mode 100644 index 0000000..ac197b8 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/poll_option.ts @@ -0,0 +1,6 @@ +namespace MastodonEntity { + export type PollOption = { + title: string; + votes_count: number | null; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/preferences.ts b/packages/backend/src/server/api/mastodon/entities/preferences.ts new file mode 100644 index 0000000..e0b8546 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/preferences.ts @@ -0,0 +1,9 @@ +namespace MastodonEntity { + export type Preferences = { + "posting:default:visibility": "public" | "unlisted" | "private" | "direct"; + "posting:default:sensitive": boolean; + "posting:default:language": string | null; + "reading:expand:media": "default" | "show_all" | "hide_all"; + "reading:expand:spoilers": boolean; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/push_subscription.ts b/packages/backend/src/server/api/mastodon/entities/push_subscription.ts new file mode 100644 index 0000000..379ecff --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/push_subscription.ts @@ -0,0 +1,16 @@ +namespace MastodonEntity { + export type Alerts = { + follow: boolean; + favourite: boolean; + mention: boolean; + reblog: boolean; + poll: boolean; + }; + + export type PushSubscription = { + id: string; + endpoint: string; + server_key: string; + alerts: Alerts; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/reaction.ts b/packages/backend/src/server/api/mastodon/entities/reaction.ts new file mode 100644 index 0000000..79e18cd --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/reaction.ts @@ -0,0 +1,12 @@ +/// + +namespace MastodonEntity { + export type Reaction = { + count: number; + me: boolean; + name: string; + url?: string; + static_url?: string; + accounts?: Array; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/relationship.ts b/packages/backend/src/server/api/mastodon/entities/relationship.ts new file mode 100644 index 0000000..f96b4ac --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/relationship.ts @@ -0,0 +1,17 @@ +namespace MastodonEntity { + export type Relationship = { + id: string; + following: boolean; + followed_by: boolean; + blocking: boolean; + blocked_by: boolean; + muting: boolean; + muting_notifications: boolean; + requested: boolean; + domain_blocking: boolean; + showing_reblogs: boolean; + endorsed: boolean; + notifying: boolean; + note: string; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/report.ts b/packages/backend/src/server/api/mastodon/entities/report.ts new file mode 100644 index 0000000..22682ff --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/report.ts @@ -0,0 +1,9 @@ +namespace MastodonEntity { + export type Report = { + id: string; + action_taken: string; + comment: string; + account_id: string; + status_ids: Array; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/results.ts b/packages/backend/src/server/api/mastodon/entities/results.ts new file mode 100644 index 0000000..f9f6768 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/results.ts @@ -0,0 +1,11 @@ +/// +/// +/// + +namespace MastodonEntity { + export type Search = { + accounts: Array; + statuses: Array; + hashtags: Array; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/scheduled_status.ts b/packages/backend/src/server/api/mastodon/entities/scheduled_status.ts new file mode 100644 index 0000000..362228a --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/scheduled_status.ts @@ -0,0 +1,10 @@ +/// +/// +namespace MastodonEntity { + export type ScheduledStatus = { + id: string; + scheduled_at: string; + params: StatusParams; + media_attachments: Array; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/source.ts b/packages/backend/src/server/api/mastodon/entities/source.ts new file mode 100644 index 0000000..29dcffb --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/source.ts @@ -0,0 +1,10 @@ +/// +namespace MastodonEntity { + export type Source = { + privacy: string | null; + sensitive: boolean | null; + language: string | null; + note: string; + fields: Array; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/stats.ts b/packages/backend/src/server/api/mastodon/entities/stats.ts new file mode 100644 index 0000000..827b178 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/stats.ts @@ -0,0 +1,7 @@ +namespace MastodonEntity { + export type Stats = { + user_count: number; + status_count: number; + domain_count: number; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/status.ts b/packages/backend/src/server/api/mastodon/entities/status.ts new file mode 100644 index 0000000..95816f6 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/status.ts @@ -0,0 +1,84 @@ +/// +/// +/// +/// +/// +/// +/// +/// +/// + +namespace MastodonEntity { + export type Status = { + id: string; + uri: string; + url: string; + account: Account; + in_reply_to_id: string | null; + in_reply_to_account_id: string | null; + reblog: Status | null; + content: string | undefined; + content_type: string; + text: string | null | undefined; + created_at: string; + emojis: Emoji[]; + replies_count: number; + reblogs_count: number; + favourites_count: number; + reblogged: boolean | null; + favourited: boolean | null; + muted: boolean | null; + sensitive: boolean; + spoiler_text: string; + visibility: "public" | "unlisted" | "private" | "direct"; + media_attachments: Array; + mentions: Array; + tags: Array; + card: Card | null; + poll: Poll | null; + application: Application | null; + language: string | null; + pinned: boolean | undefined; + reactions: Array; + quote: { state: string, quoted_status: Status } & Status | null; + quote_id: string | null; + bookmarked: boolean; + edited_at: string | null; + filtered: Array | null; + quote_approval: { + automatic: string[]; + manual: string[]; + current_user: string; + }; + }; + + export type StatusCreationRequest = { + text?: string, + media_ids?: string[], + poll?: { + options: string[], + expires_in: number, + multiple: boolean + }, + in_reply_to_id?: string, + quote_id?: string, + sensitive?: boolean, + spoiler_text?: string, + visibility?: string, + language?: string, + scheduled_at?: Date + } + + export type StatusEditRequest = { + text?: string, + media_ids?: string[], + poll?: { + options: string[], + expires_in: number, + multiple: boolean + }, + sensitive?: boolean, + spoiler_text?: string, + language?: string + } +} diff --git a/packages/backend/src/server/api/mastodon/entities/status_edit.ts b/packages/backend/src/server/api/mastodon/entities/status_edit.ts new file mode 100644 index 0000000..5958954 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/status_edit.ts @@ -0,0 +1,22 @@ +/// +/// +/// +/// +/// +/// +/// +/// +/// + +namespace MastodonEntity { + export type StatusEdit = { + account: Account; + content: string; + created_at: string; + emojis: Emoji[]; + sensitive: boolean; + spoiler_text: string; + media_attachments: Array; + poll: Poll | null; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/status_params.ts b/packages/backend/src/server/api/mastodon/entities/status_params.ts new file mode 100644 index 0000000..8d44d25 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/status_params.ts @@ -0,0 +1,12 @@ +namespace MastodonEntity { + export type StatusParams = { + text: string; + in_reply_to_id: string | null; + media_ids: Array | null; + sensitive: boolean | null; + spoiler_text: string | null; + visibility: "public" | "unlisted" | "private" | "direct"; + scheduled_at: string | null; + application_id: string; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/status_source.ts b/packages/backend/src/server/api/mastodon/entities/status_source.ts new file mode 100644 index 0000000..ec85ec1 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/status_source.ts @@ -0,0 +1,8 @@ +namespace MastodonEntity { + export type StatusSource = { + id: string; + text: string; + spoiler_text: string; + content_type: string; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/tag.ts b/packages/backend/src/server/api/mastodon/entities/tag.ts new file mode 100644 index 0000000..3ef0028 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/tag.ts @@ -0,0 +1,10 @@ +/// + +namespace MastodonEntity { + export type Tag = { + name: string; + url: string; + history: Array | null; + following?: boolean; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/token.ts b/packages/backend/src/server/api/mastodon/entities/token.ts new file mode 100644 index 0000000..08a6803 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/token.ts @@ -0,0 +1,8 @@ +namespace MastodonEntity { + export type Token = { + access_token: string; + token_type: string; + scope: string; + created_at: number; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entities/urls.ts b/packages/backend/src/server/api/mastodon/entities/urls.ts new file mode 100644 index 0000000..c391ef9 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entities/urls.ts @@ -0,0 +1,5 @@ +namespace MastodonEntity { + export type URLs = { + streaming_api: string; + }; +} diff --git a/packages/backend/src/server/api/mastodon/entity.ts b/packages/backend/src/server/api/mastodon/entity.ts new file mode 100644 index 0000000..350f0b3 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/entity.ts @@ -0,0 +1,38 @@ +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// +/// + +export default MastodonEntity; diff --git a/packages/backend/src/server/api/mastodon/helpers/auth.ts b/packages/backend/src/server/api/mastodon/helpers/auth.ts new file mode 100644 index 0000000..ae6c132 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/auth.ts @@ -0,0 +1,222 @@ +import OAuth from "@/server/api/mastodon/entities/oauth/oauth.js"; +import { secureRndstr } from "@/misc/secure-rndstr.js"; +import { OAuthApps, OAuthTokens } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { MastoContext } from "@/server/api/mastodon/index.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { difference, toSingleLast, unique } from "@/prelude/array.js"; +import { ILocalUser } from "@/models/entities/user.js"; + +export class AuthHelpers { + public static async registerApp(ctx: MastoContext): Promise { + const body: any = ctx.request.body || ctx.request.query; + const scopes = (typeof body.scopes === "string" ? body.scopes.split(' ') : body.scopes) ?? ['read']; + const redirect_uris = body.redirect_uris?.split('\n') as string[] | undefined; + const client_name = body.client_name; + const website = body.website; + + if (client_name == null) throw new MastoApiError(400, 'Missing client_name param'); + if (redirect_uris == null || redirect_uris.length < 1) throw new MastoApiError(400, 'Missing redirect_uris param'); + + try { + redirect_uris.every(u => this.validateRedirectUri(u)); + } catch { + throw new MastoApiError(400, 'Invalid redirect_uris'); + } + + const app = await OAuthApps.insert({ + id: genId(), + clientId: secureRndstr(32), + clientSecret: secureRndstr(32), + createdAt: new Date(), + name: client_name, + website: website, + scopes: scopes, + redirectUris: redirect_uris, + }).then((x) => OAuthApps.findOneByOrFail(x.identifiers[0])); + + return { + id: app.id, + name: app.name, + website: app.website, + redirect_uri: app.redirectUris.join('\n'), + client_id: app.clientId, + client_secret: app.clientSecret, + vapid_key: await fetchMeta().then(meta => meta.swPublicKey), + }; + } + + public static async getAuthCode(ctx: MastoContext) { + const user = ctx.miauth[0] as ILocalUser; + if (!user) throw new MastoApiError(401, "Unauthorized"); + + const body = ctx.request.body as any; + const scopes: string[] = (typeof body.scopes === "string" ? body.scopes.split(' ') : body.scopes) ?? ['read']; + const clientId = toSingleLast(body.client_id); + + if (clientId == null) throw new MastoApiError(400, "Invalid client_id"); + + const app = await OAuthApps.findOneBy({ clientId: clientId }); + + this.validateRedirectUri(body.redirect_uri); + if (!app) throw new MastoApiError(400, "Invalid client_id"); + if (!scopes.every(p => app.scopes.includes(p))) throw new MastoApiError(400, "Cannot request more scopes than application"); + if (!app.redirectUris.includes(body.redirect_uri)) throw new MastoApiError(400, "Redirect URI not in list"); + + const token = await OAuthTokens.insert({ + id: genId(), + active: false, + code: secureRndstr(32), + token: secureRndstr(32), + appId: app.id, + userId: user.id, + createdAt: new Date(), + scopes: scopes, + redirectUri: body.redirect_uri, + }).then((x) => OAuthTokens.findOneByOrFail(x.identifiers[0])); + + return { code: token.code }; + } + + public static async getAppInfo(ctx: MastoContext) { + const body = ctx.request.body as any; + const clientId = toSingleLast(body.client_id); + + if (clientId == null) throw new MastoApiError(400, "Invalid client_id"); + + const app = await OAuthApps.findOneBy({ clientId: clientId }); + + if (!app) throw new MastoApiError(400, "Invalid client_id"); + + return { name: app.name }; + } + + public static async getAuthToken(ctx: MastoContext) { + const body: any = ctx.request.body || ctx.request.query; + const scopes: string[] = (typeof body.scope === "string" ? body.scope.split(' ') : body.scope) ?? ['read']; + const clientId = toSingleLast(body.client_id); + const code = toSingleLast(body.code); + + const invalidScopeError = new MastoApiError(400, "invalid_scope", "The requested scope is invalid, unknown, or malformed."); + const invalidClientError = new MastoApiError(401, "invalid_client", "Client authentication failed due to unknown client, no client authentication included, or unsupported authentication method."); + + if (clientId == null) throw invalidClientError; + if (code == null) throw new MastoApiError(401, "Invalid code"); + + const app = await OAuthApps.findOneBy({ clientId: clientId }); + const token = await OAuthTokens.findOneBy({ code: code }); + + this.validateRedirectUri(body.redirect_uri); + if (body.grant_type !== 'authorization_code') throw new MastoApiError(400, "Invalid grant_type"); + if (!app || body.client_secret !== app.clientSecret) throw invalidClientError; + if (!token || app.id !== token.appId) throw new MastoApiError(401, "Invalid code"); + if (difference(scopes, app.scopes).length > 0) throw invalidScopeError; + if (!app.redirectUris.includes(body.redirect_uri)) throw new MastoApiError(400, "Redirect URI not in list"); + + await OAuthTokens.update(token.id, { active: true }); + + return { + "access_token": token.token, + "token_type": "Bearer", + "scope": token.scopes.join(' '), + "created_at": Math.floor(token.createdAt.getTime() / 1000) + }; + } + + public static async revokeAuthToken(ctx: MastoContext) { + const error = new MastoApiError(403, "unauthorized_client", "You are not authorized to revoke this token"); + const body: any = ctx.request.body || ctx.request.query; + const clientId = toSingleLast(body.client_id); + const clientSecret = toSingleLast(body.client_secret); + const token = toSingleLast(body.token); + + if (clientId == null || clientSecret == null || token == null) throw error; + + const app = await OAuthApps.findOneBy({ clientId: clientId, clientSecret: clientSecret }); + const oatoken = await OAuthTokens.findOneBy({ token: token }); + + if (!app || !oatoken || app.id !== oatoken.appId) throw error; + + await OAuthTokens.delete(oatoken.id); + + return {}; + } + + public static async verifyAppCredentials(ctx: MastoContext) { + console.log(ctx.appId); + if (!ctx.appId) throw new MastoApiError(401, "The access token is invalid"); + const app = await OAuthApps.findOneByOrFail({ id: ctx.appId }); + return { + name: app.name, + website: app.website, + vapid_key: await fetchMeta().then(meta => meta.swPublicKey ?? undefined), + } + } + + private static validateRedirectUri(redirectUri: string): void { + const error = new MastoApiError(400, "Invalid redirect_uri"); + if (redirectUri == null) throw error; + if (redirectUri === 'urn:ietf:wg:oauth:2.0:oob') return; + try { + const url = new URL(redirectUri); + if (["javascript:", "file:", "data:", "mailto:", "tel:"].includes(url.protocol)) throw error; + } catch { + throw error; + } + } + + private static readScopes = [ + "read:accounts", + "read:blocks", + "read:bookmarks", + "read:favourites", + "read:filters", + "read:follows", + "read:lists", + "read:mutes", + "read:notifications", + "read:search", + "read:statuses", + ]; + private static writeScopes = [ + "write:accounts", + "write:blocks", + "write:bookmarks", + "write:conversations", + "write:favourites", + "write:filters", + "write:follows", + "write:lists", + "write:media", + "write:mutes", + "write:notifications", + "write:reports", + "write:statuses", + ]; + private static followScopes = [ + "read:follows", + "read:blocks", + "read:mutes", + "write:follows", + "write:blocks", + "write:mutes", + ]; + + public static expandScopes(scopes: string[]): string[] { + const res: string[] = []; + + for (const scope of scopes) { + if (scope === "read") + res.push(...this.readScopes); + else if (scope === "write") + res.push(...this.writeScopes); + else if (scope === "follow") + res.push(...this.followScopes); + + res.push(scope); + } + + return unique(res); + } +} diff --git a/packages/backend/src/server/api/mastodon/helpers/list.ts b/packages/backend/src/server/api/mastodon/helpers/list.ts new file mode 100644 index 0000000..d2819d1 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/list.ts @@ -0,0 +1,184 @@ +import { ILocalUser, User } from "@/models/entities/user.js"; +import { Blockings, Followings, UserListJoinings, UserLists, Users } from "@/models/index.js"; +import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; +import { UserList } from "@/models/entities/user-list.js"; +import { pushUserToUserList } from "@/services/user-list/push.js"; +import { genId } from "@/misc/gen-id.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { MastoContext } from "@/server/api/mastodon/index.js"; +import { pullUserFromUserList } from "@/services/user-list/pull.js"; +import { publishUserEvent } from "@/services/stream.js"; + +export class ListHelpers { + public static async getLists(ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + + return UserLists.findBy({ userId: user.id }).then(p => p.map(list => { + return { + id: list.id, + title: list.name, + exclusive: list.hideFromHomeTl + } + })); + } + + public static async getList(id: string, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + + return UserLists.findOneByOrFail({ userId: user.id, id: id }).then(list => { + return { + id: list.id, + title: list.name, + exclusive: list.hideFromHomeTl + } + }); + } + + public static async getListOr404(id: string, ctx: MastoContext): Promise { + return this.getList(id, ctx).catch(_ => { + throw new MastoApiError(404); + }) + } + + public static async getListUsers(id: string, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40, ctx: MastoContext): Promise { + if (limit > 80) limit = 80; + const user = ctx.user as ILocalUser; + const list = await UserLists.findOneBy({ userId: user.id, id: id }); + if (!list) throw new MastoApiError(404); + const query = PaginationHelpers.makePaginationQuery( + UserListJoinings.createQueryBuilder('member'), + sinceId, + maxId, + minId + ) + .andWhere("member.userListId = :listId", { listId: list.id }) + .innerJoinAndSelect("member.user", "user"); + + return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx) + .then(members => { + return members + .map(p => p.user) + .filter(p => p) as User[]; + }); + } + + public static async deleteList(list: UserList, ctx: MastoContext) { + const user = ctx.user as ILocalUser; + if (user.id != list.userId) throw new Error("List is not owned by user"); + await UserLists.delete(list.id); + } + + public static async addToList(list: UserList, usersToAdd: User[], ctx: MastoContext) { + const localUser = ctx.user as ILocalUser; + if (localUser.id != list.userId) throw new Error("List is not owned by user"); + for (const user of usersToAdd) { + if (user.id !== localUser.id) { + const isBlocked = await Blockings.exist({ + where: { + blockerId: user.id, + blockeeId: localUser.id, + }, + }); + const isFollowed = await Followings.exist({ + where: { + followeeId: user.id, + followerId: localUser.id, + }, + }); + if (isBlocked) throw Error("Can't add users you've been blocked by to list"); + if (!isFollowed) throw Error("Can't add users you're not following to list"); + } + + const exist = await UserListJoinings.exist({ + where: { + userListId: list.id, + userId: user.id, + }, + }); + + if (exist) continue; + await pushUserToUserList(user, list); + } + } + + public static async removeFromList(list: UserList, usersToRemove: User[], ctx: MastoContext) { + const localUser = ctx.user as ILocalUser; + if (localUser.id != list.userId) throw new Error("List is not owned by user"); + for (const user of usersToRemove) { + const exist = await UserListJoinings.exist({ + where: { + userListId: list.id, + userId: user.id, + }, + }); + + if (!exist) continue; + await pullUserFromUserList(user, list); + } + } + + public static async createList(title: string, ctx: MastoContext): Promise { + if (title.length < 1) throw new MastoApiError(400, "Title must not be empty"); + + const user = ctx.user as ILocalUser; + const list = await UserLists.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + name: title, + }).then(async res => await UserLists.findOneByOrFail(res.identifiers[0])); + + return { + id: list.id, + title: list.name, + exclusive: list.hideFromHomeTl + }; + } + + public static async updateList(list: UserList, title: string, exclusive: boolean | undefined, ctx: MastoContext): Promise { + if (title.length < 1 && exclusive === undefined) throw new MastoApiError(400, "Either title or exclusive must be set"); + + const user = ctx.user as ILocalUser; + if (user.id != list.userId) throw new Error("List is not owned by user"); + + const name = title.length > 0 ? title : undefined; + const partial = { name: name, hideFromHomeTl: exclusive }; + const result = await UserLists.update(list.id, partial) + .then(async _ => await UserLists.findOneByOrFail({ id: list.id })); + + if (exclusive !== undefined) { + UserListJoinings.findBy({ userListId: list.id }) + .then(members => { + for (const member of members) { + publishUserEvent(list.userId, exclusive ? "userHidden" : "userUnhidden", member.userId); + } + }); + } + + return { + id: result.id, + title: result.name, + exclusive: result.hideFromHomeTl + }; + } + + public static async getListsByMember(member: User, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + const joinQuery = UserListJoinings.createQueryBuilder('member') + .select("member.userListId") + .where("member.userId = :memberId"); + const query = UserLists.createQueryBuilder('list') + .where("list.userId = :userId", { userId: user.id }) + .andWhere(`list.id IN (${joinQuery.getQuery()})`) + .setParameters({ memberId: member.id }); + + return query.getMany() + .then(results => results.map(result => { + return { + id: result.id, + title: result.name, + exclusive: result.hideFromHomeTl + } + })); + } +} diff --git a/packages/backend/src/server/api/mastodon/helpers/media.ts b/packages/backend/src/server/api/mastodon/helpers/media.ts new file mode 100644 index 0000000..8f9881e --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/media.ts @@ -0,0 +1,77 @@ +import { addFile } from "@/services/drive/add-file.js"; +import { ILocalUser } from "@/models/entities/user.js"; +import { DriveFiles } from "@/models/index.js"; +import { Packed } from "@/misc/schema.js"; +import { DriveFile } from "@/models/entities/drive-file.js"; +import { File, Files } from "formidable"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { MastoContext } from "@/server/api/mastodon/index.js"; +import { toSingleLast } from "@/prelude/array.js"; + +export class MediaHelpers { + public static async uploadMedia(ctx: MastoContext): Promise> { + const files = ctx.request.files as Files | undefined; + const file = toSingleLast(files?.file); + const user = ctx.user as ILocalUser + const body = ctx.request.body as any; + + if (!file) throw new MastoApiError(400, "Validation failed: File content type is invalid, File is invalid"); + + return addFile({ + user: user, + path: file.filepath, + name: file.originalFilename !== null && file.originalFilename !== 'file' ? file.originalFilename : undefined, + comment: body?.description ?? undefined, + sensitive: false, //FIXME: this needs to be updated on from composing a post with the media attached + }) + .then(p => DriveFiles.pack(p)); + } + + public static async uploadMediaBasic(file: File, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + + return addFile({ + user: user, + path: file.filepath, + name: file.originalFilename !== null && file.originalFilename !== 'file' ? file.originalFilename : undefined, + sensitive: false + }) + } + + public static async updateMedia(file: DriveFile, ctx: MastoContext): Promise> { + const user = ctx.user as ILocalUser; + const body = ctx.request.body as any; + + await DriveFiles.update(file.id, { + comment: body?.description ?? undefined + }); + + return DriveFiles.findOneByOrFail({ id: file.id, userId: user.id }) + .then(p => DriveFiles.pack(p)); + } + + public static async getMediaPacked(id: string, ctx: MastoContext): Promise | null> { + const user = ctx.user as ILocalUser; + return this.getMedia(id, ctx) + .then(p => p ? DriveFiles.pack(p) : null); + } + + public static async getMediaPackedOr404(id: string, ctx: MastoContext): Promise> { + return this.getMediaPacked(id, ctx).then(p => { + if (p) return p; + throw new MastoApiError(404); + }); + } + + public static async getMedia(id: string, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + return DriveFiles.findOneBy({ id: id, userId: user.id }); + } + + public static async getMediaOr404(id: string, ctx: MastoContext): Promise { + return this.getMedia(id, ctx).then(p => { + if (p) return p; + throw new MastoApiError(404); + }); + } +} diff --git a/packages/backend/src/server/api/mastodon/helpers/mfm.ts b/packages/backend/src/server/api/mastodon/helpers/mfm.ts new file mode 100644 index 0000000..98077e1 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/mfm.ts @@ -0,0 +1,245 @@ +import { IMentionedRemoteUsers } from "@/models/entities/note.js"; +import { Window as HappyDom } from "happy-dom"; +import config from "@/config/index.js"; +import { intersperse } from "@/prelude/array.js"; +import mfm from "mfm-js"; +import { resolveMentionFromCache } from "@/remote/resolve-user.js"; + +export class MfmHelpers { + public static async toHtml( + nodes: mfm.MfmNode[] | null, + mentionedRemoteUsers: IMentionedRemoteUsers = [], + objectHost: string | null, + inline: boolean = false, + quoteUri: string | null = null + ) { + if (nodes == null) { + return null; + } + + const window = new HappyDom(); + + const doc = window.document; + + function appendTextWithGlyphs(text: string, targetElement: Element): void { + const regexp = /;([^:;\s]{1,100});/g; + let last = 0; + + for (const match of text.matchAll(regexp)) { + if (match.index! > last) { + targetElement.appendChild(doc.createTextNode(text.slice(last, match.index))); + } + + targetElement.appendChild(doc.createTextNode(`\u200B:${match[1]}:\u200B`)); + last = match.index! + match[0].length; + } + + if (last < text.length) { + targetElement.appendChild(doc.createTextNode(text.slice(last))); + } + } + + async function appendChildren(children: mfm.MfmNode[], targetElement: any): Promise { + if (children) { + for (const child of await Promise.all(children.map(async (x) => await (handlers as any)[x.type](x)))) + targetElement.appendChild(child); + } + } + + const handlers: { + [K in mfm.MfmNode["type"]]: (node: mfm.NodeType) => any; + } = { + async bold(node) { + const el = doc.createElement("span"); + el.textContent = '**'; + await appendChildren(node.children, el); + el.textContent += '**'; + return el; + }, + + async small(node) { + const el = doc.createElement("small"); + await appendChildren(node.children, el); + return el; + }, + + async strike(node) { + const el = doc.createElement("span"); + el.textContent = '~~'; + await appendChildren(node.children, el); + el.textContent += '~~'; + return el; + }, + + async italic(node) { + const el = doc.createElement("span"); + el.textContent = '*'; + await appendChildren(node.children, el); + el.textContent += '*'; + return el; + }, + + async fn(node) { + const el = doc.createElement("span"); + el.textContent = '*'; + await appendChildren(node.children, el); + el.textContent += '*'; + return el; + }, + + blockCode(node) { + const pre = doc.createElement("pre"); + const inner = doc.createElement("code"); + + const nodes = node.props.code + .split(/\r\n|\r|\n/) + .map((x) => doc.createTextNode(x)); + + for (const x of intersperse("br", nodes)) { + inner.appendChild(x === "br" ? doc.createElement("br") : x); + } + + pre.appendChild(inner); + return pre; + }, + + async center(node) { + const el = doc.createElement("div"); + await appendChildren(node.children, el); + return el; + }, + + emojiCode(node) { + return doc.createTextNode(`\u200B:${node.props.name}:\u200B`); + }, + + unicodeEmoji(node) { + return doc.createTextNode(node.props.emoji); + }, + + hashtag(node) { + const a = doc.createElement("a"); + a.setAttribute('href', `${config.url}/tags/${node.props.hashtag}`); + a.textContent = `#${node.props.hashtag}`; + a.setAttribute("rel", "tag"); + a.setAttribute("class", "hashtag"); + return a; + }, + + inlineCode(node) { + const el = doc.createElement("code"); + el.textContent = node.props.code; + return el; + }, + + mathInline(node) { + const el = doc.createElement("code"); + el.textContent = node.props.formula; + return el; + }, + + mathBlock(node) { + const el = doc.createElement("code"); + el.textContent = node.props.formula; + return el; + }, + + async link(node) { + const a = doc.createElement("a"); + a.setAttribute("rel", "nofollow noopener noreferrer"); + a.setAttribute("target", "_blank"); + a.setAttribute('href',node.props.url); + await appendChildren(node.children, a); + return a; + }, + + async mention(node) { + const { username, host, acct } = node.props; + const resolved = await resolveMentionFromCache(username, host, objectHost, mentionedRemoteUsers); + + const el = doc.createElement("span"); + if (resolved === null) { + el.textContent = acct; + } else { + el.setAttribute("class", "h-card"); + el.setAttribute("translate", "no"); + const a = doc.createElement("a"); + a.setAttribute('href',resolved.href); + a.className = "u-url mention"; + const span = doc.createElement("span"); + span.textContent = resolved.username; + a.textContent = '@'; + a.appendChild(span); + el.appendChild(a); + } + + return el; + }, + + async quote(node) { + const el = doc.createElement("blockquote"); + await appendChildren(node.children, el); + return el; + }, + + text(node) { + const el = doc.createElement("span"); + const lines = node.props.text.split(/\r\n|\r|\n/); + + for (const x of intersperse("br", lines)) { + if (x === "br") { + el.appendChild(doc.createElement("br")); + continue; + } + + appendTextWithGlyphs(x, el); + } + + return el; + }, + + url(node) { + const a = doc.createElement("a"); + a.setAttribute("rel", "nofollow noopener noreferrer"); + a.setAttribute("target", "_blank"); + a.setAttribute('href', node.props.url); + a.textContent = node.props.url.replace(/^https?:\/\//, ''); + return a; + }, + + search(node) { + const a = doc.createElement("a"); + a.setAttribute('href', `${config.searchEngine}${node.props.query}`); + a.textContent = node.props.content; + return a; + }, + + async plain(node) { + const el = doc.createElement("span"); + await appendChildren(node.children, el); + return el; + }, + }; + + await appendChildren(nodes, doc.body); + + if (quoteUri !== null) { + const a = doc.createElement("a"); + a.setAttribute('href', quoteUri); + a.textContent = quoteUri.replace(/^https?:\/\//, ''); + + const quote = doc.createElement("span"); + quote.setAttribute("class", "quote-inline"); + quote.appendChild(doc.createElement("br")); + quote.appendChild(doc.createElement("br")); + quote.innerHTML += 'RE: '; + quote.appendChild(a); + + doc.body.appendChild(quote); + } + + const html = inline ? doc.body.innerHTML : `

${doc.body.innerHTML}

`; + await window.happyDOM.close(); + return html; + } +} diff --git a/packages/backend/src/server/api/mastodon/helpers/misc.ts b/packages/backend/src/server/api/mastodon/helpers/misc.ts new file mode 100644 index 0000000..a1b61b0 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/misc.ts @@ -0,0 +1,255 @@ +import config from "@/config/index.js"; +import { FILE_TYPE_BROWSERSAFE, MAX_NOTE_TEXT_LENGTH } from "@/const.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { AnnouncementReads, Announcements, Emojis, Instances, Notes, UserProfiles, Users } from "@/models/index.js"; +import { IsNull } from "typeorm"; +import { awaitAll } from "@/prelude/await-all.js"; +import { UserConverter } from "@/server/api/mastodon/converters/user.js"; +import { Announcement } from "@/models/entities/announcement.js"; +import { ILocalUser, User } from "@/models/entities/user.js"; +import { AnnouncementConverter } from "@/server/api/mastodon/converters/announcement.js"; +import { genId } from "@/misc/gen-id.js"; +import * as Acct from "@/misc/acct.js"; +import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; +import { generateMutedUserQueryForUsers } from "@/server/api/common/generate-muted-user-query.js"; +import { generateBlockQueryForUsers } from "@/server/api/common/generate-block-query.js"; +import { uniqBy } from "@/prelude/array.js"; +import { EmojiConverter } from "@/server/api/mastodon/converters/emoji.js"; +import { populateEmojis } from "@/misc/populate-emojis.js"; +import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { VisibilityConverter } from "@/server/api/mastodon/converters/visibility.js"; +import { MastoContext } from "@/server/api/mastodon/index.js"; + +export class MiscHelpers { + public static async getInstance(ctx: MastoContext): Promise { + const userCount = Users.count({ where: { host: IsNull() } }); + const noteCount = Notes.count({ where: { userHost: IsNull() } }); + const instanceCount = Instances.count({ cache: 3600000 }); + const contact = await Users.findOne({ + where: { + host: IsNull(), + isAdmin: true, + isDeleted: false, + isSuspended: false, + }, + order: { id: "ASC" }, + }) + .then(p => p ? UserConverter.encode(p, ctx) : null); + const meta = await fetchMeta(true); + + const res = { + uri: config.domain, + title: meta.name || "FrozenFriendsYume", + short_description: + meta.description?.substring(0, 50) || "This is an FrozenFriendsYume instance. It doesn't seem to have a description.", + description: + meta.description || + "This is an FrozenFriendsYume instance. It doesn't seem to have a description.", + email: meta.maintainerEmail || "", + version: `4.2.1 (compatible; FrozenFriendsYume ${config.version})`, + urls: { + streaming_api: `${config.url.replace(/^http(?=s?:\/\/)/, "ws")}`, + }, + stats: awaitAll({ + user_count: userCount, + status_count: noteCount, + domain_count: instanceCount, + }), + max_toot_chars: MAX_NOTE_TEXT_LENGTH, + thumbnail: meta.bannerUrl || "/static-assets/transparent.png", + languages: meta.langs, + registrations: !meta.disableRegistration, + approval_required: meta.disableRegistration, + invites_enabled: meta.disableRegistration, + configuration: { + accounts: { + max_featured_tags: 20, + }, + statuses: { + supported_mime_types: ['text/x.misskeymarkdown'], + max_characters: MAX_NOTE_TEXT_LENGTH, + max_media_attachments: 16, + characters_reserved_per_url: 23, + }, + media_attachments: { + supported_mime_types: FILE_TYPE_BROWSERSAFE, + image_size_limit: 10485760, + image_matrix_limit: 16777216, + video_size_limit: 41943040, + video_frame_limit: 60, + video_matrix_limit: 2304000, + }, + polls: { + max_options: 10, + max_characters_per_option: 50, + min_expiration: 50, + max_expiration: 2629746, + }, + reactions: { + max_reactions: 1, + default_reaction: meta.defaultReaction, + }, + }, + contact_account: contact, + rules: [], + }; + + return awaitAll(res); + } + + public static async getAnnouncements(includeRead: boolean = false, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + + if (includeRead) { + const [announcements, reads] = await Promise.all([ + Announcements.createQueryBuilder("announcement") + .orderBy({ "announcement.id": "DESC" }) + .getMany(), + AnnouncementReads.findBy({ userId: user.id }) + .then(p => p.map(x => x.announcementId)) + ]); + + return Promise.all(announcements.map(async p => AnnouncementConverter.encode(p, reads.includes(p.id)))); + } + + const sq = AnnouncementReads.createQueryBuilder("reads") + .select("reads.announcementId") + .where("reads.userId = :userId"); + + const query = Announcements.createQueryBuilder("announcement") + .where(`announcement.id NOT IN (${sq.getQuery()})`) + .orderBy({ "announcement.id": "DESC" }) + .setParameter("userId", user.id); + + return query.getMany() + .then(p => Promise.all(p.map(async x => AnnouncementConverter.encode(x, false)))); + } + + public static async dismissAnnouncement(announcement: Announcement, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + const exists = await AnnouncementReads.exist({ where: { userId: user.id, announcementId: announcement.id } }); + if (!exists) { + await AnnouncementReads.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + announcementId: announcement.id + }); + } + } + + public static async getFollowSuggestions(limit: number, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + const results: Promise[] = []; + + const pinned = fetchMeta().then(meta => Promise.all( + meta.pinnedUsers + .map((acct) => Acct.parse(acct)) + .map((acct) => + Users.findOneBy({ + usernameLower: acct.username.toLowerCase(), + host: acct.host ?? IsNull(), + })) + ) + .then(p => p.filter(x => !!x) as User[]) + .then(p => UserConverter.encodeMany(p, ctx)) + .then(p => p.map(x => { + return { source: "staff", account: x } as MastodonEntity.SuggestedAccount + })) + ); + + const query = Users.createQueryBuilder("user") + .where("user.isExplorable = TRUE") + .andWhere("user.host IS NULL") + .orderBy("user.followersCount", "DESC") + .andWhere("user.updatedAt > :date", { + date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5), + }); + + generateMutedUserQueryForUsers(query, user); + generateBlockQueryForUsers(query, user); + + const global = query + .take(limit) + .getMany() + .then(p => UserConverter.encodeMany(p, ctx)) + .then(p => p.map(x => { + return { source: "global", account: x } as MastodonEntity.SuggestedAccount + })); + + results.push(pinned); + results.push(global); + + + return Promise.all(results).then(p => uniqBy(p.flat(), (x: MastodonEntity.SuggestedAccount) => x.account.id).slice(0, limit)); + } + + public static async getCustomEmoji() { + return Emojis.find({ + where: { + host: IsNull(), + }, + order: { + category: "ASC", + name: "ASC", + }, + cache: { + id: "meta_emojis", + milliseconds: 3600000, // 1 hour + } + } + ) + .then(dbRes => populateEmojis(dbRes.map(p => p.name), null) + .then(p => p.map(x => EmojiConverter.encode(x)) + .map(x => { + return { + ...x, + category: dbRes.find(y => y.name === x.shortcode)?.category ?? undefined + } + }) + ) + ); + } + + public static async getTrendingStatuses(limit: number = 20, offset: number = 0, ctx: MastoContext): Promise { + if (limit > 40) limit = 40; + const query = Notes.createQueryBuilder("note") + .addSelect("note.score") + .andWhere("note.score > 0") + .andWhere("note.createdAt > :date", { date: new Date(Date.now() - 1000 * 60 * 60 * 24) }) + .andWhere("note.visibility = 'public'") + .andWhere("note.userHost IS NULL") + .orderBy("note.score", "DESC"); + + return query + .skip(offset) + .take(limit) + .getMany() + .then(result => NoteConverter.encodeMany(result, ctx)); + } + + public static async getTrendingHashtags(limit: number = 10, offset: number = 0): Promise { + if (limit > 20) limit = 20; + return []; + //FIXME: This was already implemented in api/endpoints/hashtags/trend.ts, but the implementation is sketchy at best. Rewrite from scratch. + } + + public static getPreferences(ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + const profile = UserProfiles.findOneByOrFail({ userId: user.id }); + const sensitive = profile.then(p => p.alwaysMarkNsfw); + const language = profile.then(p => p.lang); + const privacy = UserHelpers.getDefaultNoteVisibility(ctx) + .then(p => VisibilityConverter.encode(p)); + + const res = { + "posting:default:visibility": privacy, + "posting:default:sensitive": sensitive, + "posting:default:language": language, + "reading:expand:media": "default" as "default" | "show_all" | "hide_all", //FIXME: see below + "reading:expand:spoilers": false //FIXME: store this on server instead of client + } + + return awaitAll(res); + } +} diff --git a/packages/backend/src/server/api/mastodon/helpers/note.ts b/packages/backend/src/server/api/mastodon/helpers/note.ts new file mode 100644 index 0000000..ae3d625 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/note.ts @@ -0,0 +1,467 @@ +import { makePaginationQuery } from "@/server/api/common/make-pagination-query.js"; +import { DriveFiles, Metas, NoteEdits, NoteFavorites, NoteReactions, Notes, UserNotePinings } from "@/models/index.js"; +import { generateVisibilityQuery } from "@/server/api/common/generate-visibility-query.js"; +import { generateMutedUserQuery } from "@/server/api/common/generate-muted-user-query.js"; +import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js"; +import { Note } from "@/models/entities/note.js"; +import { ILocalUser, User } from "@/models/entities/user.js"; +import { getNote } from "@/server/api/common/getters.js"; +import createReaction from "@/services/note/reaction/create.js"; +import deleteReaction from "@/services/note/reaction/delete.js"; +import createNote, { extractMentionedUsers } from "@/services/note/create.js"; +import editNote from "@/services/note/edit.js"; +import deleteNote from "@/services/note/delete.js"; +import { genId } from "@/misc/gen-id.js"; +import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; +import { UserConverter } from "@/server/api/mastodon/converters/user.js"; +import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; +import { addPinned, removePinned } from "@/services/i/pin.js"; +import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import { VisibilityConverter } from "@/server/api/mastodon/converters/visibility.js"; +import mfm from "mfm-js"; +import { FileConverter } from "@/server/api/mastodon/converters/file.js"; +import { MfmHelpers } from "@/server/api/mastodon/helpers/mfm.js"; +import { toArray, unique } from "@/prelude/array.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { Cache } from "@/misc/cache.js"; +import AsyncLock from "async-lock"; +import { IdentifiableError } from "@/misc/identifiable-error.js"; +import { IsNull } from "typeorm"; +import { getStubMastoContext, MastoContext } from "@/server/api/mastodon/index.js"; + +export class NoteHelpers { + public static postIdempotencyCache = new Cache<{ status?: MastodonEntity.Status }>('postIdempotencyCache', 60 * 60); + public static postIdempotencyLocks = new AsyncLock(); + + public static async getDefaultReaction(): Promise { + return Metas.createQueryBuilder() + .select('"defaultReaction"') + .execute() + .then(p => p[0].defaultReaction) + .then(p => { + if (p != null) return p; + throw new MastoApiError(500, "Failed to get default reaction"); + }); + } + + public static async reactToNote(note: Note, reaction: string, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + await createReaction(user, note, reaction).catch(e => { + if (e instanceof IdentifiableError && e.id == '51c42bb4-931a-456b-bff7-e5a8a70dd298') return; + throw e; + }); + return getNote(note.id, user); + } + + public static async removeReactFromNote(note: Note, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + await deleteReaction(user, note); + return getNote(note.id, user); + } + + public static async reblogNote(note: Note, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + const existingRenote = await Notes.findOneBy({ + userId: user.id, + renoteId: note.id, + text: IsNull(), + }); + if (existingRenote) return existingRenote; + const data = { + createdAt: new Date(), + files: [], + renote: note + }; + return await createNote(user, data); + } + + public static async unreblogNote(note: Note, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + return Notes.findBy({ + userId: user.id, + renoteId: note.id, + }) + .then(p => p.map(n => deleteNote(user, n))) + .then(p => Promise.all(p)) + .then(_ => getNote(note.id, user)); + } + + public static async bookmarkNote(note: Note, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + const bookmarked = await NoteFavorites.exist({ + where: { + noteId: note.id, + userId: user.id, + }, + }); + + if (!bookmarked) { + await NoteFavorites.insert({ + id: genId(), + createdAt: new Date(), + noteId: note.id, + userId: user.id, + }); + } + + return note; + } + + public static async unbookmarkNote(note: Note, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + return NoteFavorites.findOneBy({ + noteId: note.id, + userId: user.id, + }) + .then(p => p !== null ? NoteFavorites.delete(p.id) : null) + .then(_ => note); + } + + public static async pinNote(note: Note, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + const pinned = await UserNotePinings.exist({ + where: { + userId: user.id, + noteId: note.id + } + }); + + if (!pinned) { + await addPinned(user, note.id); + } + + return note; + } + + public static async unpinNote(note: Note, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + const pinned = await UserNotePinings.exist({ + where: { + userId: user.id, + noteId: note.id + } + }); + + if (pinned) { + await removePinned(user, note.id); + } + + return note; + } + + public static async deleteNote(note: Note, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + if (user.id !== note.userId) throw new MastoApiError(404); + const status = await NoteConverter.encode(note, ctx); + await deleteNote(user, note); + status.content = undefined; + return status; + } + + public static async getNoteFavoritedBy(note: Note, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40, ctx: MastoContext): Promise { + if (limit > 80) limit = 80; + const query = PaginationHelpers.makePaginationQuery( + NoteReactions.createQueryBuilder("reaction"), + sinceId, + maxId, + minId + ) + .andWhere("reaction.noteId = :noteId", { noteId: note.id }) + .innerJoinAndSelect("reaction.user", "user"); + + return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx) + .then(reactions => { + return reactions + .map(p => p.user) + .filter(p => p) as User[]; + }); + } + + public static async getNoteEditHistory(note: Note, ctx: MastoContext): Promise { + const user = Promise.resolve(note.user ?? await UserHelpers.getUserCached(note.userId, ctx)); + const account = user.then(p => UserConverter.encode(p, ctx)); + const edits = await NoteEdits.find({ where: { noteId: note.id }, order: { id: "ASC" } }); + const history: Promise[] = []; + + const curr = { + id: note.id, + noteId: note.id, + note: note, + text: note.text, + cw: note.cw, + fileIds: note.fileIds, + updatedAt: note.updatedAt ?? note.createdAt + } + + edits.push(curr); + + let lastDate = note.createdAt; + for (const edit of edits) { + const files = DriveFiles.packMany(edit.fileIds); + const item = { + account: account, + content: MfmHelpers.toHtml(mfm.parse(edit.text ?? ''), JSON.parse(note.mentionedRemoteUsers), note.userHost).then(p => p ?? ''), + created_at: lastDate.toISOString(), + emojis: [], + sensitive: files.then(files => files.length > 0 ? files.some((f) => f.isSensitive) : false), + spoiler_text: edit.cw ?? '', + poll: null, + media_attachments: files.then(files => files.length > 0 ? files.map((f) => FileConverter.encode(f)) : []) + }; + lastDate = edit.updatedAt; + history.push(awaitAll(item)); + } + + return Promise.all(history); + } + + public static getNoteSource(note: Note): MastodonEntity.StatusSource { + return { + id: note.id, + text: note.text ?? '', + spoiler_text: note.cw ?? '', + content_type: 'text/x.misskeymarkdown' + } + } + + public static async getNoteRebloggedBy(note: Note, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40, ctx: MastoContext): Promise { + if (limit > 80) limit = 80; + const user = ctx.user as ILocalUser | null; + const query = PaginationHelpers.makePaginationQuery( + Notes.createQueryBuilder("note"), + sinceId, + maxId, + minId + ) + .andWhere("note.renoteId = :noteId", { noteId: note.id }) + .andWhere("note.text IS NULL") // We don't want to count quotes as renotes + .andWhere('note.hasPoll = FALSE') + .andWhere("note.fileIds = '{}'") + .innerJoinAndSelect("note.user", "user"); + + generateVisibilityQuery(query, user); + + return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx) + .then(renotes => { + return renotes + .map(p => p.user) + .filter(p => p) as User[]; + }); + } + + public static async getNoteDescendants(note: Note | string, limit: number = 10, depth: number = 2, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser | null; + const noteId = typeof note === "string" ? note : note.id; + const query = makePaginationQuery(Notes.createQueryBuilder("note")) + .andWhere( + "note.id IN (SELECT id FROM note_replies(:noteId, :depth, :limit))", + { noteId, depth, limit }, + ); + + generateVisibilityQuery(query, user); + if (user) { + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + } + + return query.getMany().then(p => p.reverse()); + } + + public static async getNoteAncestors(rootNote: Note, limit: number = 10, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser | null; + const notes = new Array; + for (let i = 0; i < limit; i++) { + const currentNote = notes.at(-1) ?? rootNote; + if (!currentNote.replyId) break; + const nextNote = await getNote(currentNote.replyId, user).catch((e) => { + if (e.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24") return null; + throw e; + }); + if (nextNote && await Notes.isVisibleForMe(nextNote, user?.id ?? null)) notes.push(nextNote); + else break; + } + + return notes.reverse(); + } + + public static async createNote(request: MastodonEntity.StatusCreationRequest, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + const files = request.media_ids && request.media_ids.length > 0 + ? DriveFiles.findByIds(request.media_ids) + : []; + + const reply = request.in_reply_to_id ? await getNote(request.in_reply_to_id, user) : undefined; + const renote = request.quote_id ? await getNote(request.quote_id, user) : undefined; + const visibility = request.visibility ?? UserHelpers.getDefaultNoteVisibility(ctx); + + const data = { + createdAt: new Date(), + files: files, + poll: request.poll + ? { + choices: request.poll.options, + multiple: request.poll.multiple, + expiresAt: request.poll.expires_in && request.poll.expires_in > 0 ? new Date(new Date().getTime() + (request.poll.expires_in * 1000)) : null, + } + : undefined, + text: request.text, + reply: reply, + renote: renote, + cw: request.spoiler_text, + visibility: visibility, + visibleUsers: Promise.resolve(visibility).then(p => p === 'specified' ? this.extractMentions(request.text ?? '', ctx) : undefined) + } + + return createNote(user, await awaitAll(data)); + } + + public static async editNote(request: MastodonEntity.StatusEditRequest, note: Note, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + const files = request.media_ids && request.media_ids.length > 0 + ? DriveFiles.findByIds(request.media_ids) + : []; + + const data = { + files: files, + poll: request.poll + ? { + choices: request.poll.options, + multiple: request.poll.multiple, + expiresAt: request.poll.expires_in && request.poll.expires_in > 0 ? new Date(new Date().getTime() + (request.poll.expires_in * 1000)) : null, + } + : null, + text: request.text, + cw: request.spoiler_text + } + + return editNote(user, note, await awaitAll(data)); + } + + public static async extractMentions(text: string, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + return extractMentionedUsers(user, mfm.parse(text)!); + } + + public static normalizeComposeOptions(body: any): MastodonEntity.StatusCreationRequest { + const result: MastodonEntity.StatusCreationRequest = {}; + + if (body.status != null && body.status.trim().length > 0) + result.text = body.status; + if (body.spoiler_text != null && body.spoiler_text.trim().length > 0) + result.spoiler_text = body.spoiler_text; + if (body.visibility != null) + result.visibility = VisibilityConverter.decode(body.visibility); + if (body.language != null) + result.language = body.language; + if (body.scheduled_at != null) + result.scheduled_at = new Date(Date.parse(body.scheduled_at)); + if (body.in_reply_to_id) + result.in_reply_to_id = body.in_reply_to_id; + if (body.quoted_status_id ?? body.quote_id) + result.quote_id = body.quoted_status_id ?? body.quote_id; + if (body.media_ids) + result.media_ids = body.media_ids && body.media_ids.length > 0 + ? toArray(body.media_ids) + : undefined; + + if (body.poll) { + result.poll = { + expires_in: parseInt(body.poll.expires_in, 10), + options: body.poll.options, + multiple: !!body.poll.multiple, + } + } + + result.sensitive = !!body.sensitive; + + return result; + } + + public static normalizeEditOptions(body: any): MastodonEntity.StatusEditRequest { + const result: MastodonEntity.StatusEditRequest = {}; + + if (body.status != null && body.status.trim().length > 0) + result.text = body.status; + if (body.spoiler_text != null && body.spoiler_text.trim().length > 0) + result.spoiler_text = body.spoiler_text; + if (body.language != null) + result.language = body.language; + if (body.media_ids) + result.media_ids = body.media_ids && body.media_ids.length > 0 + ? toArray(body.media_ids) + : undefined; + + if (body.poll) { + result.poll = { + expires_in: parseInt(body.poll.expires_in, 10), + options: body.poll.options, + multiple: !!body.poll.multiple, + } + } + + result.sensitive = !!body.sensitive; + + return result; + } + + public static async getNoteOr404(id: string, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser | null; + return getNote(id, user).catch(_ => { + throw new MastoApiError(404); + }); + } + + public static async getConversationFromEvent(noteId: string, user: ILocalUser): Promise { + const ctx = getStubMastoContext(user); + const note = await getNote(noteId, ctx.user); + const conversationId = note.threadId ?? note.id; + const userIds = unique([note.userId].concat(note.visibleUserIds).filter(p => p != ctx.user.id)); + const users = userIds.map(id => UserHelpers.getUserCached(id, ctx).catch(_ => null)); + const accounts = Promise.all(users).then(u => UserConverter.encodeMany(u.filter(u => u) as User[], ctx)); + const res = { + id: conversationId, + accounts: accounts.then(u => u.length > 0 ? u : UserConverter.encodeMany([ctx.user], ctx)), // failsafe to prevent apps from crashing case when all participant users have been deleted + last_status: NoteConverter.encode(note, ctx), + unread: true + }; + + return awaitAll(res); + } + + public static fixupEventNote(note: Note): Note { + note.createdAt = note.createdAt ? new Date(note.createdAt) : note.createdAt; + note.updatedAt = note.updatedAt ? new Date(note.updatedAt) : note.updatedAt; + note.reply = null; + note.renote = null; + note.user = null; + + return note; + } + + public static getIdempotencyKey(ctx: MastoContext): string | null { + const headers = ctx.headers; + const user = ctx.user as ILocalUser; + if (headers["idempotency-key"] === undefined || headers["idempotency-key"] === null) return null; + return `${user.id}-${Array.isArray(headers["idempotency-key"]) ? headers["idempotency-key"].at(-1)! : headers["idempotency-key"]}`; + } + + public static async getFromIdempotencyCache(key: string): Promise { + return this.postIdempotencyLocks.acquire(key, async (): Promise => { + if (await this.postIdempotencyCache.get(key) !== undefined) { + let i = 5; + while ((await this.postIdempotencyCache.get(key))?.status === undefined) { + if (++i > 5) throw new Error('Post is duplicate but unable to resolve original'); + await new Promise((resolve) => { + setTimeout(resolve, 500); + }); + } + + return (await this.postIdempotencyCache.get(key))?.status; + } else { + await this.postIdempotencyCache.set(key, {}); + return undefined; + } + }); + } +} diff --git a/packages/backend/src/server/api/mastodon/helpers/notification.ts b/packages/backend/src/server/api/mastodon/helpers/notification.ts new file mode 100644 index 0000000..7397a2d --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/notification.ts @@ -0,0 +1,93 @@ +import { ILocalUser } from "@/models/entities/user.js"; +import { Notes, Notifications } from "@/models/index.js"; +import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; +import { Notification } from "@/models/entities/notification.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { MastoContext } from "@/server/api/mastodon/index.js"; + +export class NotificationHelpers { + public static async getNotifications(maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40, types: string[] | undefined, excludeTypes: string[] | undefined, accountId: string | undefined, ctx: MastoContext): Promise { + if (limit > 80) limit = 80; + + const user = ctx.user as ILocalUser; + let requestedTypes = types + ? this.decodeTypes(types) + : ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest']; + + if (excludeTypes) { + const excludedTypes = this.decodeTypes(excludeTypes); + requestedTypes = requestedTypes.filter(p => !excludedTypes.includes(p)); + } + + const query = PaginationHelpers.makePaginationQuery( + Notifications.createQueryBuilder("notification"), + sinceId, + maxId, + minId + ) + .andWhere("notification.notifieeId = :userId", { userId: user.id }) + .andWhere("notification.type IN (:...types)", { types: requestedTypes }); + + if (accountId !== undefined) + query.andWhere("notification.notifierId = :notifierId", { notifierId: accountId }); + + query + .leftJoinAndSelect("notification.note", "note") + .leftJoinAndSelect("notification.notifier", "notifier") + .leftJoinAndSelect("notification.notifiee", "notifiee"); + + return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx); + } + + public static async getNotification(id: string, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + return Notifications.findOneBy({ id: id, notifieeId: user.id }); + } + + public static async getNotificationOr404(id: string, ctx: MastoContext): Promise { + return this.getNotification(id, ctx).then(p => { + if (p) return p; + throw new MastoApiError(404); + }); + } + + public static async dismissNotification(id: string, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + await Notifications.update({ id: id, notifieeId: user.id }, { isRead: true }); + } + + public static async clearAllNotifications(ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + await Notifications.update({ notifieeId: user.id }, { isRead: true }); + } + + public static async markConversationAsRead(id: string, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + const notesQuery = Notes.createQueryBuilder("note") + .select("note.id") + .andWhere("COALESCE(note.threadId, note.id) = :conversationId"); + + await Notifications.createQueryBuilder("notification") + .where(`notification."noteId" IN (${notesQuery.getQuery()})`) + .andWhere(`notification."notifieeId" = :userId`) + .andWhere(`notification."isRead" = FALSE`) + .andWhere("notification.type IN (:...types)") + .setParameter("userId", user.id) + .setParameter("conversationId", id) + .setParameter("types", ['reply', 'mention']) + .update() + .set({ isRead: true }) + .execute(); + } + + private static decodeTypes(types: string[]) { + const result: string[] = []; + if (types.includes('follow')) result.push('follow'); + if (types.includes('mention')) result.push('mention', 'reply'); + if (types.includes('reblog')) result.push('renote', 'quote'); + if (types.includes('favourite')) result.push('reaction'); + if (types.includes('poll')) result.push('pollEnded'); + if (types.includes('follow_request')) result.push('receiveFollowRequest'); + return result; + } +} diff --git a/packages/backend/src/server/api/mastodon/helpers/pagination.ts b/packages/backend/src/server/api/mastodon/helpers/pagination.ts new file mode 100644 index 0000000..f911645 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/pagination.ts @@ -0,0 +1,56 @@ +import { ObjectLiteral, SelectQueryBuilder } from "typeorm"; +import { MastoContext } from "@/server/api/mastodon/index.js"; +import { generatePaginationData } from "@/server/api/mastodon/middleware/pagination.js"; + +export class PaginationHelpers { + public static makePaginationQuery( + q: SelectQueryBuilder, + sinceId?: string, + maxId?: string, + minId?: string, + idField: string = `${q.alias}.id`, + ) { + if (sinceId && minId) throw new Error("Can't user both sinceId and minId params"); + + if (sinceId && maxId) { + q.andWhere(`${idField} > :sinceId`, { sinceId: sinceId }); + q.andWhere(`${idField} < :maxId`, { maxId: maxId }); + q.orderBy(`${idField}`, "DESC"); + } + if (minId && maxId) { + q.andWhere(`${idField} > :minId`, { minId: minId }); + q.andWhere(`${idField} < :maxId`, { maxId: maxId }); + q.orderBy(`${idField}`, "ASC"); + } else if (sinceId) { + q.andWhere(`${idField} > :sinceId`, { sinceId: sinceId }); + q.orderBy(`${idField}`, "DESC"); + } else if (minId) { + q.andWhere(`${idField} > :minId`, { minId: minId }); + q.orderBy(`${idField}`, "ASC"); + } else if (maxId) { + q.andWhere(`${idField} < :maxId`, { maxId: maxId }); + q.orderBy(`${idField}`, "DESC"); + } else { + q.orderBy(`${idField}`, "DESC"); + } + return q; + } + + /** + * + * @param query + * @param limit + * @param reverse whether the result needs to be .reverse()'d. Set this to true when the parameter minId is not undefined in the original request. + */ + public static async execQuery(query: SelectQueryBuilder, limit: number, reverse: boolean): Promise { + return query.take(limit).getMany().then(found => reverse ? found.reverse() : found); + } + + public static async execQueryLinkPagination(query: SelectQueryBuilder, limit: number, reverse: boolean, ctx: MastoContext): Promise { + return this.execQuery(query, limit, reverse) + .then(p => { + ctx.pagination = generatePaginationData(p.map(x => x.id), limit); + return p; + }); + } +} diff --git a/packages/backend/src/server/api/mastodon/helpers/poll.ts b/packages/backend/src/server/api/mastodon/helpers/poll.ts new file mode 100644 index 0000000..6eaf859 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/poll.ts @@ -0,0 +1,130 @@ +import { Note } from "@/models/entities/note.js"; +import { populatePoll } from "@/models/repositories/note.js"; +import { PollConverter } from "@/server/api/mastodon/converters/poll.js"; +import { ILocalUser, IRemoteUser } from "@/models/entities/user.js"; +import { Blockings, Notes, NoteWatchings, Polls, PollVotes, Users } from "@/models/index.js"; +import { genId } from "@/misc/gen-id.js"; +import { publishNoteStream } from "@/services/stream.js"; +import { createNotification } from "@/services/create-notification.js"; +import { deliver } from "@/queue/index.js"; +import { renderActivity } from "@/remote/activitypub/renderer/index.js"; +import renderVote from "@/remote/activitypub/renderer/vote.js"; +import { Not } from "typeorm"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { populateEmojis } from "@/misc/populate-emojis.js"; +import { EmojiConverter } from "@/server/api/mastodon/converters/emoji.js"; +import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; +import { MastoContext } from "@/server/api/mastodon/index.js"; + +export class PollHelpers { + public static async getPoll(note: Note, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser | null; + if (!await Notes.isVisibleForMe(note, user?.id ?? null)) + throw new Error('Cannot encode poll not visible for user'); + + const noteUser = note.user ?? UserHelpers.getUserCached(note.userId, ctx); + const host = Promise.resolve(noteUser).then(noteUser => noteUser.host ?? null); + const noteEmoji = await host + .then(async host => populateEmojis(note.emojis, host) + .then(noteEmoji => noteEmoji + .filter((e) => e.name.indexOf("@") === -1) + .map((e) => EmojiConverter.encode(e)))); + + return populatePoll(note, user?.id ?? null).then(p => PollConverter.encode(p, note.id, noteEmoji)); + } + + public static async voteInPoll(choices: number[], note: Note, ctx: MastoContext): Promise { + if (!note.hasPoll) throw new MastoApiError(404); + const user = ctx.user as ILocalUser; + + for (const choice of choices) { + const createdAt = new Date(); + + if (!note.hasPoll) throw new MastoApiError(404); + + // Check blocking + if (note.userId !== user.id) { + const block = await Blockings.findOneBy({ + blockerId: note.userId, + blockeeId: user.id, + }); + if (block) throw new Error('You are blocked by the poll author'); + } + + const poll = await Polls.findOneByOrFail({ noteId: note.id }); + + if (poll.expiresAt && poll.expiresAt < createdAt) throw new Error('Poll is expired'); + + if (poll.choices[choice] == null) throw new Error('Invalid choice'); + + // if already voted + const exist = await PollVotes.findBy({ + noteId: note.id, + userId: user.id, + }); + + if (exist.length) { + if (poll.multiple) { + if (exist.some((x) => x.choice === choice)) throw new Error('You already voted for this option'); + } else { + throw new Error('You already voted in this poll'); + } + } + + // Create vote + const vote = await PollVotes.insert({ + id: genId(), + createdAt, + noteId: note.id, + userId: user.id, + choice: choice, + }).then((x) => PollVotes.findOneByOrFail(x.identifiers[0])); + + // Increment votes count + const index = choice + 1; // In SQL, array index is 1 based + await Polls.query( + `UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`, + ); + + publishNoteStream(note.id, "pollVoted", { + choice: choice, + userId: user.id, + }); + + // Notify + createNotification(note.userId, "pollVote", { + notifierId: user.id, + noteId: note.id, + choice: choice, + }); + + // Fetch watchers + NoteWatchings.findBy({ + noteId: note.id, + userId: Not(user.id), + }).then((watchers) => { + for (const watcher of watchers) { + createNotification(watcher.userId, "pollVote", { + notifierId: user.id, + noteId: note.id, + choice: choice, + }); + } + }); + + // リモート投票の場合リプライ送信 + if (note.userHost != null) { + const pollOwner = (await Users.findOneByOrFail({ + id: note.userId, + })) as IRemoteUser; + + deliver( + user, + renderActivity(await renderVote(user, vote, note, poll, pollOwner)), + pollOwner.inbox, + ); + } + } + return this.getPoll(note, ctx); + } +} diff --git a/packages/backend/src/server/api/mastodon/helpers/search.ts b/packages/backend/src/server/api/mastodon/helpers/search.ts new file mode 100644 index 0000000..b1f2598 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/search.ts @@ -0,0 +1,198 @@ +import { Followings, Hashtags, Notes, Users } from "@/models/index.js"; +import { sqlLikeEscape } from "@/misc/sql-like-escape.js"; +import { generateVisibilityQuery } from "@/server/api/common/generate-visibility-query.js"; +import { generateMutedUserQuery } from "@/server/api/common/generate-muted-user-query.js"; +import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js"; +import { Note } from "@/models/entities/note.js"; +import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; +import { ILocalUser, User } from "@/models/entities/user.js"; +import { Brackets, IsNull } from "typeorm"; +import { awaitAll } from "@/prelude/await-all.js"; +import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import Resolver from "@/remote/activitypub/resolver.js"; +import { getApId, isActor, isPost } from "@/remote/activitypub/type.js"; +import DbResolver from "@/remote/activitypub/db-resolver.js"; +import { createPerson } from "@/remote/activitypub/models/person.js"; +import { UserConverter } from "@/server/api/mastodon/converters/user.js"; +import { resolveUser } from "@/remote/resolve-user.js"; +import { createNote } from "@/remote/activitypub/models/note.js"; +import config from "@/config/index.js"; +import { logger, MastoContext } from "@/server/api/mastodon/index.js"; +import { generateFtsQuery } from "@/server/api/common/generate-fts-query.js"; + +export class SearchHelpers { + public static async search(q: string | undefined, type: string | undefined, resolve: boolean = false, following: boolean = false, accountId: string | undefined, excludeUnreviewed: boolean = false, maxId: string | undefined, minId: string | undefined, limit: number = 20, offset: number | undefined, ctx: MastoContext): Promise { + if (q === undefined || q.trim().length === 0) throw new Error('Search query cannot be empty'); + if (limit > 40) limit = 40; + const user = ctx.user as ILocalUser; + const notes = type === 'statuses' || !type ? this.searchNotes(q, resolve, following, accountId, maxId, minId, limit, offset, ctx) : []; + const users = type === 'accounts' || !type ? this.searchUsers(q, resolve, following, maxId, minId, limit, offset, ctx) : []; + const tags = type === 'hashtags' || !type ? this.searchTags(q, excludeUnreviewed, limit, offset) : []; + + const result = { + statuses: Promise.resolve(notes).then(p => NoteConverter.encodeMany(p, ctx)), + accounts: Promise.resolve(users).then(p => UserConverter.encodeMany(p, ctx)), + hashtags: Promise.resolve(tags) + }; + + return awaitAll(result); + } + + private static async searchUsers(q: string, resolve: boolean, following: boolean, maxId: string | undefined, minId: string | undefined, limit: number, offset: number | undefined, ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + if (resolve) { + try { + if (q.startsWith('https://') || q.startsWith('http://')) { + // try resolving locally first + const dbResolver = new DbResolver(); + const dbResult = await dbResolver.getUserFromApId(q); + if (dbResult) return [dbResult]; + + // ask remote + const resolver = new Resolver(); + resolver.setUser(user); + const object = await resolver.resolve(q); + if (q !== object.id) { + const result = await dbResolver.getUserFromApId(getApId(object)); + if (result) return [result]; + } + return isActor(object) ? Promise.all([createPerson(getApId(object), resolver.reset())]) : []; + } else { + let match = q.match(/^@?(?[a-zA-Z0-9_]+)@(?[a-zA-Z0-9-.]+\.[a-zA-Z0-9-]+)$/); + if (!match) match = q.match(/^@(?[a-zA-Z0-9_]+)$/) + if (match) { + // check if user is already in database + const dbResult = await Users.findOneBy({ + usernameLower: match.groups!.user.toLowerCase(), + host: match.groups?.host ?? IsNull() + }); + if (dbResult) return [dbResult]; + + const result = await resolveUser(match.groups!.user.toLowerCase(), match.groups?.host ?? null); + if (result) return [result]; + + // no matches found + return []; + } + } + } catch (e: any) { + console.log(`[mastodon-client] resolve user '${q}' failed: ${e.message}`); + return []; + } + } + + const query = PaginationHelpers.makePaginationQuery( + Users.createQueryBuilder("user"), + undefined, + minId, + maxId, + ); + + if (following) { + const followingQuery = Followings.createQueryBuilder("following") + .select("following.followeeId") + .where("following.followerId = :followerId", { followerId: user.id }); + + query.andWhere( + new Brackets((qb) => { + qb.where(`user.id IN (${followingQuery.getQuery()} UNION ALL VALUES (:meId))`, { meId: user.id }); + }), + ); + } + + query.andWhere( + new Brackets((qb) => { + qb.where("user.name ILIKE :q", { q: `%${sqlLikeEscape(q)}%` }); + qb.orWhere("concat_ws('@', user.usernameLower, user.host) ILIKE :q", { q: `%${sqlLikeEscape(q)}%` }); + }) + ); + + query.orderBy({ 'user.notesCount': 'DESC' }); + + return query.skip(offset ?? 0).take(limit).getMany().then(p => minId ? p.reverse() : p); + } + + private static async searchNotes(q: string, resolve: boolean, following: boolean, accountId: string | undefined, maxId: string | undefined, minId: string | undefined, limit: number, offset: number | undefined, ctx: MastoContext): Promise { + if (accountId && following) throw new Error("The 'following' and 'accountId' parameters cannot be used simultaneously"); + const user = ctx.user as ILocalUser; + + if (resolve) { + try { + if (q.startsWith('https://') || q.startsWith('http://')) { + // try resolving locally first + const dbResolver = new DbResolver(); + const dbResult = await dbResolver.getNoteFromApId(q); + if (dbResult) return [dbResult]; + + // ask remote + const resolver = new Resolver(); + resolver.setUser(user); + const object = await resolver.resolve(q); + if (q !== object.id) { + const result = await dbResolver.getNoteFromApId(getApId(object)); + if (result) return [result]; + } + + return isPost(object) ? createNote(getApId(object), resolver.reset(), true).then(p => p ? [p] : []) : []; + } + } catch (e: any) { + logger.warn(`Resolving note '${q}' failed: ${e.message}`); + return []; + } + } + + const query = PaginationHelpers.makePaginationQuery( + Notes.createQueryBuilder("note"), + undefined, + minId, + maxId, + ); + + if (accountId) { + query.andWhere("note.userId = :userId", { userId: accountId }); + } + + if (following) { + const followingQuery = Followings.createQueryBuilder("following") + .select("following.followeeId") + .where("following.followerId = :followerId", { followerId: user.id }); + + query.andWhere( + new Brackets((qb) => { + qb.where(`note.userId IN (${followingQuery.getQuery()} UNION ALL VALUES (:meId))`, { meId: user.id }); + }), + ) + } + + query.leftJoinAndSelect("note.renote", "renote"); + + generateFtsQuery(query, q); + generateVisibilityQuery(query, user); + + if (!accountId) { + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + } + + query.setParameter("meId", user.id); + + return query.skip(offset ?? 0).take(limit).getMany().then(p => minId ? p.reverse() : p); + } + + private static async searchTags(q: string, excludeUnreviewed: boolean, limit: number, offset: number | undefined): Promise { + const tags = Hashtags.createQueryBuilder('tag') + .select('tag.name') + .distinctOn(['tag.name']) + .where("tag.name ILIKE :q", { q: `%${sqlLikeEscape(q)}%` }) + .orderBy({ 'tag.name': 'ASC' }) + .skip(offset ?? 0).take(limit).getMany(); + + return tags.then(p => p.map(tag => { + return { + name: tag.name, + url: `${config.url}/tags/${tag.name}`, + history: null + }; + })); + } +} diff --git a/packages/backend/src/server/api/mastodon/helpers/timeline.ts b/packages/backend/src/server/api/mastodon/helpers/timeline.ts new file mode 100644 index 0000000..21cc687 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/timeline.ts @@ -0,0 +1,220 @@ +import { Note } from "@/models/entities/note.js"; +import { ILocalUser, User } from "@/models/entities/user.js"; +import { Followings, Notes, Notifications, UserListJoinings } from "@/models/index.js"; +import { Brackets } from "typeorm"; +import { generateChannelQuery } from "@/server/api/common/generate-channel-query.js"; +import { generateRepliesQuery } from "@/server/api/common/generate-replies-query.js"; +import { generateVisibilityQuery } from "@/server/api/common/generate-visibility-query.js"; +import { generateMutedUserQuery } from "@/server/api/common/generate-muted-user-query.js"; +import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js"; +import { generateMutedUserRenotesQueryForNotes } from "@/server/api/common/generated-muted-renote-query.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; +import { UserList } from "@/models/entities/user-list.js"; +import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; +import { UserConverter } from "@/server/api/mastodon/converters/user.js"; +import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import { unique } from "@/prelude/array.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { generatePaginationData } from "@/server/api/mastodon/middleware/pagination.js"; +import { MastoContext } from "@/server/api/mastodon/index.js"; +import { generateListQuery } from "@/server/api/common/generate-list-query.js"; +import { generateFollowingQuery } from "@/server/api/common/generate-following-query.js"; + +export class TimelineHelpers { + public static async getHomeTimeline(maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, ctx: MastoContext): Promise { + if (limit > 40) limit = 40; + const user = ctx.user as ILocalUser; + + const query = PaginationHelpers.makePaginationQuery( + Notes.createQueryBuilder("note"), + sinceId, + maxId, + minId + ) + .leftJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.renote", "renote"); + + await generateFollowingQuery(query, user); + generateListQuery(query, user); + generateChannelQuery(query, user); + generateRepliesQuery(query, true, user); + generateVisibilityQuery(query, user); + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + generateMutedUserRenotesQueryForNotes(query, user); + + query.andWhere("note.visibility != 'hidden'"); + + return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx); + } + + public static async getPublicTimeline(maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, onlyMedia: boolean = false, local: boolean = false, remote: boolean = false, ctx: MastoContext): Promise { + if (limit > 40) limit = 40; + const user = ctx.user as ILocalUser; + + if (local && remote) { + throw new Error("local and remote are mutually exclusive options"); + } + + if (!local) { + const m = await fetchMeta(); + if (m.disableGlobalTimeline) { + if (user == null || !(user.isAdmin || user.isModerator)) { + throw new Error("global timeline is disabled"); + } + } + } + + const query = PaginationHelpers.makePaginationQuery( + Notes.createQueryBuilder("note"), + sinceId, + maxId, + minId + ) + .andWhere("note.visibility = 'public'"); + + if (remote) query.andWhere("note.userHost IS NOT NULL"); + if (local) query.andWhere("note.userHost IS NULL"); + if (!local) query.andWhere("note.channelId IS NULL"); + + query + .leftJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.renote", "renote"); + + generateRepliesQuery(query, true, user); + if (user) { + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + generateMutedUserRenotesQueryForNotes(query, user); + } + + if (onlyMedia) query.andWhere("note.fileIds != '{}'"); + + return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx); + } + + public static async getListTimeline(list: UserList, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, ctx: MastoContext): Promise { + if (limit > 40) limit = 40; + const user = ctx.user as ILocalUser; + if (user.id != list.userId) throw new Error("List is not owned by user"); + + const listQuery = UserListJoinings.createQueryBuilder("member") + .select("member.userId", 'userId') + .where("member.userListId = :listId"); + + const query = PaginationHelpers.makePaginationQuery( + Notes.createQueryBuilder("note"), + sinceId, + maxId, + minId + ) + .andWhere(`note.userId IN (${listQuery.getQuery()})`) + .andWhere("note.visibility != 'specified'") + .leftJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.renote", "renote") + .setParameters({ listId: list.id }); + + generateVisibilityQuery(query, user); + + return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx); + } + + public static async getTagTimeline(tag: string, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, any: string[], all: string[], none: string[], onlyMedia: boolean = false, local: boolean = false, remote: boolean = false, ctx: MastoContext): Promise { + if (limit > 40) limit = 40; + const user = ctx.user as ILocalUser | null; + + if (tag.length < 1) throw new MastoApiError(400, "Tag cannot be empty"); + + if (local && remote) { + throw new Error("local and remote are mutually exclusive options"); + } + + const query = PaginationHelpers.makePaginationQuery( + Notes.createQueryBuilder("note"), + sinceId, + maxId, + minId + ) + .andWhere("note.visibility = 'public'") + .andWhere("note.tags @> array[:tag]::varchar[]", { tag: tag }); + + if (any.length > 0) query.andWhere("note.tags && array[:...any]::varchar[]", { any: any }); + if (all.length > 0) query.andWhere("note.tags @> array[:...all]::varchar[]", { all: all }); + if (none.length > 0) query.andWhere("NOT(note.tags @> array[:...none]::varchar[])", { none: none }); + + if (remote) query.andWhere("note.userHost IS NOT NULL"); + if (local) query.andWhere("note.userHost IS NULL"); + if (!local) query.andWhere("note.channelId IS NULL"); + + query + .leftJoinAndSelect("note.user", "user") + .leftJoinAndSelect("note.renote", "renote"); + + generateRepliesQuery(query, true, user); + if (user) { + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + generateMutedUserRenotesQueryForNotes(query, user); + } + + if (onlyMedia) query.andWhere("note.fileIds != '{}'"); + + return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx); + } + + public static async getConversations(maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, ctx: MastoContext): Promise { + if (limit > 40) limit = 40; + const user = ctx.user as ILocalUser; + const sq = Notes.createQueryBuilder("note") + .select("COALESCE(note.threadId, note.id)", "conversationId") + .addSelect("note.id", "latest") + .distinctOn(["COALESCE(note.threadId, note.id)"]) + .orderBy({ "COALESCE(note.threadId, note.id)": minId ? "ASC" : "DESC", "note.id": "DESC" }) + .andWhere("note.visibility = 'specified'") + .andWhere( + new Brackets(qb => { + qb.where("note.userId = :userId"); + qb.orWhere("note.visibleUserIds @> array[:userId]::varchar[]"); + })); + + const query = PaginationHelpers.makePaginationQuery( + Notes.createQueryBuilder("note"), + sinceId, + maxId, + minId + ) + .innerJoin(`(${sq.getQuery()})`, "sq", "note.id = sq.latest") + .setParameters({ userId: user.id }) + + return query.take(limit).getMany().then(p => { + if (minId !== undefined) p = p.reverse(); + const conversations = p.map(c => { + // Gather all unique IDs except for the local user + const userIds = unique([c.userId].concat(c.visibleUserIds).filter(p => p != user.id)); + const users = userIds.map(id => UserHelpers.getUserCached(id, ctx).catch(_ => null)); + const accounts = Promise.all(users).then(u => UserConverter.encodeMany(u.filter(u => u) as User[], ctx)); + const unread = Notifications.createQueryBuilder('notification') + .where("notification.noteId = :noteId") + .andWhere("notification.notifieeId = :userId") + .andWhere("notification.isRead = FALSE") + .andWhere("notification.type IN (:...types)") + .setParameter("noteId", c.id) + .setParameter("userId", user.id) + .setParameter("types", ['reply', 'mention']) + .getExists(); + + return { + id: c.threadId ?? c.id, + accounts: accounts.then(u => u.length > 0 ? u : UserConverter.encodeMany([user], ctx)), // failsafe to prevent apps from crashing case when all participant users have been deleted + last_status: NoteConverter.encode(c, ctx), + unread: unread + } + }); + + ctx.pagination = generatePaginationData(p.map(p => p.threadId ?? p.id), limit); + return Promise.all(conversations.map(c => awaitAll(c))); + }); + } +} diff --git a/packages/backend/src/server/api/mastodon/helpers/user.ts b/packages/backend/src/server/api/mastodon/helpers/user.ts new file mode 100644 index 0000000..4cc5271 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/helpers/user.ts @@ -0,0 +1,563 @@ +import { Note } from "@/models/entities/note.js"; +import { ILocalUser, IRemoteUser, User } from "@/models/entities/user.js"; +import { + Blockings, DriveFiles, + Followings, + FollowRequests, + Mutings, + NoteFavorites, + NoteReactions, + Notes, + NoteWatchings, + RegistryItems, + UserNotePinings, + UserProfiles, + Users +} from "@/models/index.js"; +import { generateVisibilityQuery } from "@/server/api/common/generate-visibility-query.js"; +import { generateMutedUserQuery } from "@/server/api/common/generate-muted-user-query.js"; +import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js"; +import AsyncLock from "async-lock"; +import { getUser } from "@/server/api/common/getters.js"; +import { PaginationHelpers } from "@/server/api/mastodon/helpers/pagination.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import createFollowing from "@/services/following/create.js"; +import deleteFollowing from "@/services/following/delete.js"; +import cancelFollowRequest from "@/services/following/requests/cancel.js"; +import createBlocking from "@/services/blocking/create.js"; +import deleteBlocking from "@/services/blocking/delete.js"; +import { genId } from "@/misc/gen-id.js"; +import { Muting } from "@/models/entities/muting.js"; +import { publishUserEvent } from "@/services/stream.js"; +import { UserConverter } from "@/server/api/mastodon/converters/user.js"; +import acceptFollowRequest from "@/services/following/requests/accept.js"; +import { rejectFollowRequest } from "@/services/following/reject.js"; +import { Brackets, IsNull } from "typeorm"; +import { IceshrimpVisibility, VisibilityConverter } from "@/server/api/mastodon/converters/visibility.js"; +import { Files } from "formidable"; +import { toSingleLast } from "@/prelude/array.js"; +import { MediaHelpers } from "@/server/api/mastodon/helpers/media.js"; +import { UserProfile } from "@/models/entities/user-profile.js"; +import { verifyLink } from "@/services/fetch-rel-me.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { MastoContext } from "@/server/api/mastodon/index.js"; +import { resolveUser } from "@/remote/resolve-user.js"; +import { updatePerson } from "@/remote/activitypub/models/person.js"; +import { promiseEarlyReturn } from "@/prelude/promise.js"; +import { updateUserProfileData } from "@/services/i/update.js"; + +export type AccountCache = { + locks: AsyncLock; + accounts: MastodonEntity.Account[]; + users: User[]; +}; + +export type updateCredsData = { + display_name: string; + note: string; + locked: boolean; + bot: boolean; + discoverable: boolean; + fields_attributes?: { name: string, value: string }[]; +} + +type RelationshipType = 'followers' | 'following'; + +export class UserHelpers { + public static async followUser(target: User, reblogs: boolean, notify: boolean, ctx: MastoContext): Promise { + //FIXME: implement reblogs & notify params + const localUser = ctx.user as ILocalUser; + const following = await Followings.exist({ where: { followerId: localUser.id, followeeId: target.id } }); + const requested = await FollowRequests.exist({ where: { followerId: localUser.id, followeeId: target.id } }); + if (!following && !requested) + await createFollowing(localUser, target); + + return this.getUserRelationshipTo(target.id, localUser.id); + } + + public static async unfollowUser(target: User, ctx: MastoContext): Promise { + const localUser = ctx.user as ILocalUser; + const following = await Followings.exist({ where: { followerId: localUser.id, followeeId: target.id } }); + const requested = await FollowRequests.exist({ where: { followerId: localUser.id, followeeId: target.id } }); + if (following) + await deleteFollowing(localUser, target); + if (requested) + await cancelFollowRequest(target, localUser); + + return this.getUserRelationshipTo(target.id, localUser.id); + } + + public static async blockUser(target: User, ctx: MastoContext): Promise { + const localUser = ctx.user as ILocalUser; + const blocked = await Blockings.exist({ where: { blockerId: localUser.id, blockeeId: target.id } }); + if (!blocked) + await createBlocking(localUser, target); + + return this.getUserRelationshipTo(target.id, localUser.id); + } + + public static async unblockUser(target: User, ctx: MastoContext): Promise { + const localUser = ctx.user as ILocalUser; + const blocked = await Blockings.exist({ where: { blockerId: localUser.id, blockeeId: target.id } }); + if (blocked) + await deleteBlocking(localUser, target); + + return this.getUserRelationshipTo(target.id, localUser.id); + } + + public static async muteUser(target: User, notifications: boolean = true, duration: number = 0, ctx: MastoContext): Promise { + //FIXME: respect notifications parameter + const localUser = ctx.user as ILocalUser; + const muted = await Mutings.exist({ where: { muterId: localUser.id, muteeId: target.id } }); + if (!muted) { + await Mutings.insert({ + id: genId(), + createdAt: new Date(), + expiresAt: duration === 0 ? null : new Date(new Date().getTime() + (duration * 1000)), + muterId: localUser.id, + muteeId: target.id, + } as Muting); + + publishUserEvent(localUser.id, "mute", target); + + NoteWatchings.delete({ + userId: localUser.id, + noteUserId: target.id, + }); + } + + return this.getUserRelationshipTo(target.id, localUser.id); + } + + public static async unmuteUser(target: User, ctx: MastoContext): Promise { + const localUser = ctx.user as ILocalUser; + const muting = await Mutings.findOneBy({ muterId: localUser.id, muteeId: target.id }); + if (muting) { + await Mutings.delete({ + id: muting.id, + }); + + publishUserEvent(localUser.id, "unmute", target); + } + + return this.getUserRelationshipTo(target.id, localUser.id); + } + + public static async acceptFollowRequest(target: User, ctx: MastoContext): Promise { + const localUser = ctx.user as ILocalUser; + const pending = await FollowRequests.exist({ where: { followerId: target.id, followeeId: localUser.id } }); + if (pending) + await acceptFollowRequest(localUser, target); + return this.getUserRelationshipTo(target.id, localUser.id); + } + + public static async rejectFollowRequest(target: User, ctx: MastoContext): Promise { + const localUser = ctx.user as ILocalUser; + const pending = await FollowRequests.exist({ where: { followerId: target.id, followeeId: localUser.id } }); + if (pending) + await rejectFollowRequest(localUser, target); + return this.getUserRelationshipTo(target.id, localUser.id); + } + + public static async updateCredentials(ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + const files = (ctx.request as any).files as Files | undefined; + const formData = (ctx.request as any).body as updateCredsData; + + const updates: Partial = {}; + const profileUpdates: Partial = {}; + + const avatar = toSingleLast(files?.avatar); + const header = toSingleLast(files?.header); + + if (avatar) { + const file = await MediaHelpers.uploadMediaBasic(avatar, ctx); + updates.avatarId = file.id; + updates.avatarBlurhash = file.blurhash; + updates.avatarUrl = DriveFiles.getDatabasePrefetchUrl(file, true); + } + + if (header) { + const file = await MediaHelpers.uploadMediaBasic(header, ctx); + updates.bannerId = file.id; + updates.bannerBlurhash = file.blurhash; + updates.bannerUrl = DriveFiles.getDatabasePrefetchUrl(file, false); + } + + if (formData.fields_attributes) { + profileUpdates.fields = await Promise.all(formData.fields_attributes.map(async field => { + if (!(field.name.trim() === "" && field.value.trim() === "")) { + if (field.name.trim() === "") throw new MastoApiError(400, "Field name can not be empty"); + if (field.value.trim() === "") throw new MastoApiError(400, "Field value can not be empty"); + } + const verified = field.value.startsWith("http") + ? (await promiseEarlyReturn(verifyLink(field.value, user.username), 1500)) ?? false + : undefined; + return { + ...field, + verified + }; + })).then(p => p.filter(field => field.name.trim().length > 0 && field.value.length > 0)); + } + + if (formData.display_name) updates.name = formData.display_name; + if (formData.note) profileUpdates.description = formData.note; + if (formData.locked) updates.isLocked = formData.locked; + if (formData.bot) updates.isBot = formData.bot; + if (formData.discoverable) updates.isExplorable = formData.discoverable; + + await updateUserProfileData(user, null, updates, profileUpdates, false); + + return this.verifyCredentials(ctx); + } + + public static async verifyCredentials(ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + const acct = UserConverter.encode(user, ctx); + const profile = UserProfiles.findOneByOrFail({ userId: user.id }); + const followRequests = FollowRequests.count({ where: { followeeId: user.id } }); + const privacy = this.getDefaultNoteVisibility(ctx); + const fields = profile.then(profile => profile.fields.map(field => { + return { + name: field.name, + value: field.value + } as MastodonEntity.Field; + })); + return acct.then(acct => { + const source = { + note: profile.then(profile => profile.description ?? ''), + fields: fields, + privacy: privacy.then(p => VisibilityConverter.encode(p)), + sensitive: profile.then(p => p.alwaysMarkNsfw), + language: profile.then(p => p.lang ?? ''), + follow_requests_count: followRequests, + }; + + const result = { + ...acct, + source: awaitAll(source) + }; + + return awaitAll(result); + }); + } + + public static async getUserFromAcct(acct: string): Promise { + const split = acct.toLowerCase().split('@'); + if (split.length > 2) throw new Error('Invalid acct'); + return split[1] == null + ? Users.findOneBy({ usernameLower: split[0], host: split[1] ?? IsNull() }) + .then(p => { + if (p) return p; + throw new MastoApiError(404); + }) + : resolveUser(split[0], split[1], 'no-refresh').catch(() => { + throw new MastoApiError(404); + }); + } + + public static async getUserMutes(maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40, ctx: MastoContext): Promise { + if (limit > 80) limit = 80; + + const user = ctx.user as ILocalUser; + const query = PaginationHelpers.makePaginationQuery( + Mutings.createQueryBuilder("muting"), + sinceId, + maxId, + minId + ); + + query.andWhere("muting.muterId = :userId", { userId: user.id }) + .innerJoinAndSelect("muting.mutee", "mutee"); + + return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx) + .then(async mutes => { + const users = mutes + .map(p => p.mutee) + .filter(p => p) as User[]; + + return await UserConverter.encodeMany(users, ctx) + .then(res => res.map(m => { + const muting = mutes.find(acc => acc.muteeId === m.id); + return { + ...m, + mute_expires_at: muting?.expiresAt?.toISOString() ?? null + } as MastodonEntity.MutedAccount + })); + }); + } + + public static async getUserBlocks(maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40, ctx: MastoContext): Promise { + if (limit > 80) limit = 80; + + const user = ctx.user as ILocalUser; + const query = PaginationHelpers.makePaginationQuery( + Blockings.createQueryBuilder("blocking"), + sinceId, + maxId, + minId + ); + + query.andWhere("blocking.blockerId = :userId", { userId: user.id }) + .innerJoinAndSelect("blocking.blockee", "blockee"); + + return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx) + .then(blocks => { + return blocks + .map(p => p.blockee) + .filter(p => p) as User[]; + }); + } + + public static async getUserFollowRequests(maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40, ctx: MastoContext): Promise { + if (limit > 80) limit = 80; + + const user = ctx.user as ILocalUser; + const query = PaginationHelpers.makePaginationQuery( + FollowRequests.createQueryBuilder("request"), + sinceId, + maxId, + minId + ); + + query.andWhere("request.followeeId = :userId", { userId: user.id }) + .innerJoinAndSelect("request.follower", "follower"); + + return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx) + .then(requests => { + return requests + .map(p => p.follower) + .filter(p => p) as User[]; + }); + } + + public static async getUserStatuses(user: User, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, onlyMedia: boolean = false, excludeReplies: boolean = false, excludeReblogs: boolean = false, pinned: boolean = false, tagged: string | undefined, ctx: MastoContext): Promise { + if (limit > 40) limit = 40; + const localUser = ctx.user as ILocalUser | null; + + if (tagged !== undefined && tagged.length > 0) { + //FIXME respect tagged + return []; + } + + const query = PaginationHelpers.makePaginationQuery( + Notes.createQueryBuilder("note"), + sinceId, + maxId, + minId + ) + .andWhere("note.userId = :userId"); + + if (pinned) { + const sq = UserNotePinings.createQueryBuilder("pin") + .select("pin.noteId") + .where("pin.userId = :userId"); + query.andWhere(`note.id IN (${sq.getQuery()})`); + } + + if (excludeReblogs) { + query.andWhere( + new Brackets(qb => { + qb.where('note.renoteId IS NULL') + .orWhere('note.text IS NOT NULL') + .orWhere('note.hasPoll = TRUE') + .orWhere("note.fileIds != '{}'"); + })); + } + + if (excludeReplies) { + query.leftJoin("note", "thread", "note.threadId = thread.id") + .andWhere( + new Brackets(qb => { + qb.where("note.replyId IS NULL") + .orWhere(new Brackets(qb => { + qb.where('note.mentions = :mentions', { mentions: [] }) + .andWhere('thread.userId = :userId') + })); + })); + } + + query.leftJoinAndSelect("note.renote", "renote"); + + generateVisibilityQuery(query, localUser); + if (localUser) { + generateMutedUserQuery(query, localUser, user); + generateBlockedUserQuery(query, localUser); + } + + if (onlyMedia) query.andWhere("note.fileIds != '{}'"); + + query.andWhere("note.visibility != 'hidden'"); + query.andWhere("note.visibility != 'specified'"); + + query.setParameters({ userId: user.id }); + + return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx); + } + + public static async getUserBookmarks(maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, ctx: MastoContext): Promise { + if (limit > 40) limit = 40; + + const localUser = ctx.user as ILocalUser; + const query = PaginationHelpers.makePaginationQuery( + NoteFavorites.createQueryBuilder("favorite"), + sinceId, + maxId, + minId + ) + .andWhere("favorite.userId = :meId", { meId: localUser.id }) + .leftJoinAndSelect("favorite.note", "note"); + + generateVisibilityQuery(query, localUser); + + return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx) + .then(res => res.map(p => p.note as Note)); + } + + public static async getUserFavorites(maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 20, ctx: MastoContext): Promise { + if (limit > 40) limit = 40; + + const localUser = ctx.user as ILocalUser; + const query = PaginationHelpers.makePaginationQuery( + NoteReactions.createQueryBuilder("reaction"), + sinceId, + maxId, + minId + ) + .andWhere("reaction.userId = :meId", { meId: localUser.id }) + .leftJoinAndSelect("reaction.note", "note"); + + generateVisibilityQuery(query, localUser); + + return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx) + .then(res => res.map(p => p.note as Note)); + } + + private static async getUserRelationships(type: RelationshipType, user: User, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40, ctx: MastoContext): Promise { + if (limit > 80) limit = 80; + + const localUser = ctx.user as ILocalUser | null; + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + if (profile.ffVisibility === "private") { + if (!localUser || user.id !== localUser.id) return []; + } else if (profile.ffVisibility === "followers") { + if (!localUser) return []; + if (user.id !== localUser.id) { + const isFollowed = await Followings.exist({ + where: { + followeeId: user.id, + followerId: localUser.id, + }, + }); + if (!isFollowed) return []; + } + } + + const query = PaginationHelpers.makePaginationQuery( + Followings.createQueryBuilder("following"), + sinceId, + maxId, + minId + ); + + if (type === "followers") { + query.andWhere("following.followeeId = :userId", { userId: user.id }) + .innerJoinAndSelect("following.follower", "follower"); + } else { + query.andWhere("following.followerId = :userId", { userId: user.id }) + .innerJoinAndSelect("following.followee", "followee"); + } + + return PaginationHelpers.execQueryLinkPagination(query, limit, minId !== undefined, ctx) + .then(relations => relations + .map(p => type === "followers" ? p.follower : p.followee) + .filter(p => p) as User[]); + } + + public static async getUserFollowers(user: User, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40, ctx: MastoContext): Promise { + return this.getUserRelationships('followers', user, maxId, sinceId, minId, limit, ctx); + } + + public static async getUserFollowing(user: User, maxId: string | undefined, sinceId: string | undefined, minId: string | undefined, limit: number = 40, ctx: MastoContext): Promise { + return this.getUserRelationships('following', user, maxId, sinceId, minId, limit, ctx); + } + + public static async getUserRelationhipToMany(targetIds: string[], localUserId: string): Promise { + return Promise.all(targetIds.map(targetId => this.getUserRelationshipTo(targetId, localUserId))); + } + + public static async getUserRelationshipTo(targetId: string, localUserId: string): Promise { + const relation = await Users.getRelation(localUserId, targetId); + const response = { + id: targetId, + following: relation.isFollowing, + followed_by: relation.isFollowed, + blocking: relation.isBlocking, + blocked_by: relation.isBlocked, + muting: relation.isMuted, + muting_notifications: relation.isMuted, + requested: relation.hasPendingFollowRequestFromYou, + domain_blocking: false, //FIXME + showing_reblogs: !relation.isRenoteMuted, + endorsed: false, + notifying: false, //FIXME + note: '' //FIXME + } + + return awaitAll(response); + } + + public static async getUserCached(id: string, ctx: MastoContext): Promise { + const cache = ctx.cache as AccountCache; + return cache.locks.acquire(id, async () => { + const cacheHit = cache.users.find(p => p.id == id); + if (cacheHit) return cacheHit; + return getUser(id).then(p => { + cache.users.push(p); + return p; + }); + }); + } + + public static async getUserCachedOr404(id: string, ctx: MastoContext): Promise { + return this.getUserCached(id, ctx).catch(_ => { + throw new MastoApiError(404); + }); + } + + public static async getUserOr404(id: string): Promise { + return getUser(id).catch(_ => { + throw new MastoApiError(404); + }); + } + + public static async updateUserInBackground(user: User) { + if (Users.isLocalUser(user)) return; + if (user.lastFetchedAt != null && Date.now() - user.lastFetchedAt.getTime() < 1000 * 60 * 60 * 24) return; + + await Users.update(user.id, { + lastFetchedAt: new Date(), + }); + + // noinspection ES6MissingAwait + updatePerson(user.uri!, undefined, undefined, user as IRemoteUser); + } + + public static getFreshAccountCache(): AccountCache { + return { + locks: new AsyncLock(), + accounts: [], + users: [], + }; + } + + public static async getDefaultNoteVisibility(ctx: MastoContext): Promise { + const user = ctx.user as ILocalUser; + return RegistryItems.findOneBy({ + domain: IsNull(), + userId: user.id, + key: 'defaultNoteVisibility', + scope: '{client,base}' + }).then(p => p?.value ?? 'public') + } +} diff --git a/packages/backend/src/server/api/mastodon/index.ts b/packages/backend/src/server/api/mastodon/index.ts new file mode 100644 index 0000000..365e6ab --- /dev/null +++ b/packages/backend/src/server/api/mastodon/index.ts @@ -0,0 +1,59 @@ +import { DefaultContext } from "koa"; +import Router, { RouterContext } from "@koa/router"; +import { setupEndpointsAuth } from "./endpoints/auth.js"; +import { setupEndpointsAccount } from "./endpoints/account.js"; +import { setupEndpointsStatus } from "./endpoints/status.js"; +import { setupEndpointsFilter } from "./endpoints/filter.js"; +import { setupEndpointsTimeline } from "./endpoints/timeline.js"; +import { setupEndpointsNotifications } from "./endpoints/notifications.js"; +import { setupEndpointsSearch } from "./endpoints/search.js"; +import { setupEndpointsMedia } from "@/server/api/mastodon/endpoints/media.js"; +import { setupEndpointsMisc } from "@/server/api/mastodon/endpoints/misc.js"; +import { setupEndpointsList } from "@/server/api/mastodon/endpoints/list.js"; +import { AuthMiddleware } from "@/server/api/mastodon/middleware/auth.js"; +import { CatchErrorsMiddleware } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { apiLogger } from "@/server/api/logger.js"; +import { CacheMiddleware } from "@/server/api/mastodon/middleware/cache.js"; +import { KoaBodyMiddleware } from "@/server/api/mastodon/middleware/koa-body.js"; +import { NormalizeQueryMiddleware } from "@/server/api/mastodon/middleware/normalize-query.js"; +import { PaginationMiddleware } from "@/server/api/mastodon/middleware/pagination.js"; +import { SetHeadersMiddleware } from "@/server/api/mastodon/middleware/set-headers.js"; +import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; +import { ILocalUser } from "@/models/entities/user.js"; +import { setupEndpointsStreaming } from "@/server/api/mastodon/endpoints/streaming.js"; + +export const logger = apiLogger.createSubLogger("mastodon"); +export type MastoContext = RouterContext & DefaultContext; + +export function setupMastodonApi(router: Router): void { + setupMiddleware(router); + setupEndpointsAuth(router); + setupEndpointsAccount(router); + setupEndpointsStatus(router); + setupEndpointsFilter(router); + setupEndpointsTimeline(router); + setupEndpointsNotifications(router); + setupEndpointsStreaming(router); + setupEndpointsSearch(router); + setupEndpointsMedia(router); + setupEndpointsList(router); + setupEndpointsMisc(router); +} + +function setupMiddleware(router: Router): void { + router.use(KoaBodyMiddleware()); + router.use(SetHeadersMiddleware); + router.use(CatchErrorsMiddleware); + router.use(NormalizeQueryMiddleware); + router.use(PaginationMiddleware); + router.use(AuthMiddleware); + router.use(CacheMiddleware); +} + +export function getStubMastoContext(user: ILocalUser | null | undefined, filterContext?: string): any { + return { + user: user ?? null, + cache: UserHelpers.getFreshAccountCache(), + filterContext: filterContext, + }; +} diff --git a/packages/backend/src/server/api/mastodon/middleware/auth.ts b/packages/backend/src/server/api/mastodon/middleware/auth.ts new file mode 100644 index 0000000..8b97c6d --- /dev/null +++ b/packages/backend/src/server/api/mastodon/middleware/auth.ts @@ -0,0 +1,59 @@ +import { ILocalUser } from "@/models/entities/user.js"; +import { MastoContext } from "@/server/api/mastodon/index.js"; +import { MastoApiError } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { OAuthTokens } from "@/models/index.js"; +import { OAuthToken } from "@/models/entities/oauth-token.js"; +import authenticate from "@/server/api/authenticate.js"; +import { AuthHelpers } from "@/server/api/mastodon/helpers/auth.js"; + +export async function AuthMiddleware(ctx: MastoContext, next: () => Promise) { + const token = await getTokenFromOAuth(ctx.headers.authorization); + + ctx.appId = token?.appId; + ctx.user = token?.user ?? null as ILocalUser | null; + ctx.scopes = token?.scopes ?? [] as string[]; + + await next(); +} + +export async function getTokenFromOAuth(authorization: string | undefined): Promise { + if (authorization == null) return null; + + if (authorization.substring(0, 7).toLowerCase() === "bearer ") + authorization = authorization.substring(7); + + return OAuthTokens.findOne({ + where: { token: authorization, active: true }, + relations: ['user'], + }).then(token => { + if (!token) return null; + + return { + ...token, + scopes: AuthHelpers.expandScopes(token.scopes), + } + }); +} + +export function auth(required: boolean, scopes: string[] = []) { + return async function auth(ctx: MastoContext, next: () => Promise) { + if (required && !ctx.user) throw new MastoApiError(401, "This method requires an authenticated user"); + + if (!scopes.every(p => ctx.scopes.includes(p))) { + if (required) throw new MastoApiError(403, "This action is outside the authorized scopes") + + ctx.user = null; + ctx.scopes = []; + } + + await next(); + }; +} + +export function MiAuth(required: boolean) { + return async function MiAuth(ctx: MastoContext, next: () => Promise) { + ctx.miauth = (await authenticate(ctx.headers.authorization, null, true).catch(_ => [null, null])); + if (required && !ctx.miauth[0]) throw new MastoApiError(401, "Unauthorized"); + await next(); + }; +} diff --git a/packages/backend/src/server/api/mastodon/middleware/cache.ts b/packages/backend/src/server/api/mastodon/middleware/cache.ts new file mode 100644 index 0000000..ab338c3 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/middleware/cache.ts @@ -0,0 +1,7 @@ +import { MastoContext } from "@/server/api/mastodon/index.js"; +import { UserHelpers } from "@/server/api/mastodon/helpers/user.js"; + +export async function CacheMiddleware(ctx: MastoContext, next: () => Promise) { + ctx.cache = UserHelpers.getFreshAccountCache(); + await next(); +} diff --git a/packages/backend/src/server/api/mastodon/middleware/catch-errors.ts b/packages/backend/src/server/api/mastodon/middleware/catch-errors.ts new file mode 100644 index 0000000..6f7ddf2 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/middleware/catch-errors.ts @@ -0,0 +1,52 @@ +import { logger, MastoContext } from "@/server/api/mastodon/index.js"; +import { IdentifiableError } from "@/misc/identifiable-error.js"; +import { ApiError } from "@/server/api/error.js"; + +export class MastoApiError extends Error { + statusCode: number; + errorDescription?: string; + + constructor(statusCode: number, message?: string, description?: string) { + if (message == null) { + switch (statusCode) { + case 404: + message = 'Record not found'; + break; + default: + message = 'Unknown error occurred'; + break; + } + } + super(message); + this.errorDescription = description; + this.statusCode = statusCode; + } +} + +export async function CatchErrorsMiddleware(ctx: MastoContext, next: () => Promise) { + try { + await next(); + } catch (e: any) { + if (e instanceof MastoApiError) { + ctx.status = e.statusCode; + ctx.body = { error: e.message, error_description: e.errorDescription }; + return; + } else if (e instanceof IdentifiableError) { + if (e.message.length < 1) e.message = e.id; + ctx.status = 400; + } else if (e instanceof ApiError) { + ctx.status = e.httpStatusCode ?? 500; + } else { + logger.error(`Error occured in ${ctx.method} ${ctx.path}:`); + if (e instanceof Error) { + if (e.stack) logger.error(e.stack); + else logger.error(`${e.name}: ${e.message}`); + } else { + logger.error(e); + } + ctx.status = 500; + } + ctx.body = { error: e.message ?? e }; + return; + } +} diff --git a/packages/backend/src/server/api/mastodon/middleware/filter-context.ts b/packages/backend/src/server/api/mastodon/middleware/filter-context.ts new file mode 100644 index 0000000..b8719aa --- /dev/null +++ b/packages/backend/src/server/api/mastodon/middleware/filter-context.ts @@ -0,0 +1,8 @@ +import { MastoContext } from "@/server/api/mastodon/index.js"; + +export function filterContext(context: string) { + return async function filterContext(ctx: MastoContext, next: () => Promise) { + ctx.filterContext = context; + await next(); + }; +} diff --git a/packages/backend/src/server/api/mastodon/middleware/koa-body.ts b/packages/backend/src/server/api/mastodon/middleware/koa-body.ts new file mode 100644 index 0000000..a227563 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/middleware/koa-body.ts @@ -0,0 +1,12 @@ +import { Middleware } from "@koa/router"; +import { HttpMethodEnum, koaBody } from "koa-body"; + +export function KoaBodyMiddleware(): Middleware { + const options = { + multipart: true, + urlencoded: true, + parsedMethods: [HttpMethodEnum.POST, HttpMethodEnum.PUT, HttpMethodEnum.PATCH, HttpMethodEnum.DELETE] // dear god mastodon why + }; + + return koaBody(options); +} diff --git a/packages/backend/src/server/api/mastodon/middleware/normalize-query.ts b/packages/backend/src/server/api/mastodon/middleware/normalize-query.ts new file mode 100644 index 0000000..bc73f4f --- /dev/null +++ b/packages/backend/src/server/api/mastodon/middleware/normalize-query.ts @@ -0,0 +1,12 @@ +import { MastoContext } from "@/server/api/mastodon/index.js"; + +export async function NormalizeQueryMiddleware(ctx: MastoContext, next: () => Promise) { + if (ctx.request.query) { + if (!ctx.request.body || Object.keys(ctx.request.body).length === 0) { + ctx.request.body = ctx.request.query; + } else { + ctx.request.body = { ...ctx.request.body, ...ctx.request.query }; + } + } + await next(); +} diff --git a/packages/backend/src/server/api/mastodon/middleware/pagination.ts b/packages/backend/src/server/api/mastodon/middleware/pagination.ts new file mode 100644 index 0000000..908ac6e --- /dev/null +++ b/packages/backend/src/server/api/mastodon/middleware/pagination.ts @@ -0,0 +1,37 @@ +import { MastoContext } from "@/server/api/mastodon/index.js"; +import config from "@/config/index.js"; + +type PaginationData = { + limit: number; + maxId?: string | undefined; + minId?: string | undefined; +} + +export async function PaginationMiddleware(ctx: MastoContext, next: () => Promise) { + await next(); + if (!ctx.pagination) return; + + const link: string[] = []; + const limit = ctx.pagination.limit; + if (ctx.pagination.maxId) { + const l = `<${config.url}/api${ctx.path}?limit=${limit}&max_id=${ctx.pagination.maxId}>; rel="next"`; + link.push(l); + } + if (ctx.pagination.minId) { + const l = `<${config.url}/api${ctx.path}?limit=${limit}&min_id=${ctx.pagination.minId}>; rel="prev"`; + link.push(l); + } + if (link.length > 0) { + ctx.response.append('Link', link.join(', ')); + } +} + +export function generatePaginationData(ids: string[], limit: number): PaginationData | undefined { + if (ids.length < 1) return undefined; + + return { + limit: limit, + maxId: ids.length < limit ? undefined : ids.at(-1), + minId: ids.at(0) + } +} diff --git a/packages/backend/src/server/api/mastodon/middleware/set-headers.ts b/packages/backend/src/server/api/mastodon/middleware/set-headers.ts new file mode 100644 index 0000000..a56d95a --- /dev/null +++ b/packages/backend/src/server/api/mastodon/middleware/set-headers.ts @@ -0,0 +1,10 @@ +import { MastoContext } from "@/server/api/mastodon/index.js"; + +const headers = { + "Access-Control-Expose-Headers": "Link,Connection,Sec-Websocket-Accept,Upgrade" +}; + +export async function SetHeadersMiddleware(ctx: MastoContext, next: () => Promise) { + ctx.set(headers); + await next(); +} diff --git a/packages/backend/src/server/api/mastodon/streaming/channel.ts b/packages/backend/src/server/api/mastodon/streaming/channel.ts new file mode 100644 index 0000000..0aec8cd --- /dev/null +++ b/packages/backend/src/server/api/mastodon/streaming/channel.ts @@ -0,0 +1,52 @@ +import { MastodonStreamingConnection } from "."; + +export abstract class MastodonStream { + protected connection: MastodonStreamingConnection; + public readonly chName: string; + public static readonly shouldShare: boolean; + public static readonly requireCredential: boolean; + public static readonly requiredScopes: string[] = []; + + protected get user() { + return this.connection.user; + } + + protected get userProfile() { + return this.connection.userProfile; + } + + protected get following() { + return this.connection.following; + } + + protected get muting() { + return this.connection.muting; + } + + protected get renoteMuting() { + return this.connection.renoteMuting; + } + + protected get blocking() { + return this.connection.blocking; + } + + protected get hidden() { + return this.connection.hidden; + } + + protected get subscriber() { + return this.connection.subscriber; + } + + protected constructor(connection: MastodonStreamingConnection, name: string) { + this.chName = name; + this.connection = connection; + } + + public abstract init(params: any): void; + + public dispose?(): void; + + public onMessage?(type: string, body: any): void; +} diff --git a/packages/backend/src/server/api/mastodon/streaming/channels/direct.ts b/packages/backend/src/server/api/mastodon/streaming/channels/direct.ts new file mode 100644 index 0000000..70f29f0 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/streaming/channels/direct.ts @@ -0,0 +1,72 @@ +import { MastodonStream } from "../channel.js"; +import { Note } from "@/models/entities/note.js"; +import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { StreamMessages } from "@/server/api/stream/types.js"; +import { NoteHelpers } from "@/server/api/mastodon/helpers/note.js"; +import { Packed } from "@/misc/schema.js"; + +export class MastodonStreamDirect extends MastodonStream { + public static shouldShare = true; + public static requireCredential = true; + public static requiredScopes = ['read:statuses']; + + constructor(connection: MastodonStream["connection"], name: string) { + super(connection, name); + this.onNote = this.onNote.bind(this); + this.onNoteEvent = this.onNoteEvent.bind(this); + } + + override get user() { + return this.connection.user!; + } + + public async init() { + this.subscriber.on("notesStream", this.onNote); + this.subscriber.on("noteUpdatesStream", this.onNoteEvent); + } + + private async onNote(note: Note) { + if (!this.shouldProcessNote(note)) return; + + NoteConverter.encodeEvent(note, this.user).then(encoded => { + this.connection.send(this.chName, "update", encoded); + }); + + NoteHelpers.getConversationFromEvent(note.id, this.user).then(conversation => { + this.connection.send(this.chName, "conversation", conversation); + }); + } + + private async onNoteEvent(data: StreamMessages["noteUpdates"]["payload"]) { + const note = data.body; + if (!this.shouldProcessNote(note)) return; + + NoteHelpers.getConversationFromEvent(note.id, this.user).then(conversation => { + this.connection.send(this.chName, "conversation", conversation); + }); + + switch (data.type) { + case "updated": + NoteConverter.encodeEvent(note, this.user).then(encoded => { + this.connection.send(this.chName, "status.update", encoded); + }); + break; + case "deleted": + this.connection.send(this.chName, "delete", note.id); + break; + default: + break; + } + } + + private shouldProcessNote(note: Note | Packed<"Note">): boolean { + if (note.visibility !== "specified") return false; + if (note.userId !== this.user.id && !note.visibleUserIds?.includes(this.user.id)) return false; + return true; + } + + public dispose() { + this.subscriber.off("notesStream", this.onNote); + this.subscriber.off("noteUpdatesStream", this.onNoteEvent); + } +} diff --git a/packages/backend/src/server/api/mastodon/streaming/channels/list.ts b/packages/backend/src/server/api/mastodon/streaming/channels/list.ts new file mode 100644 index 0000000..20fe706 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/streaming/channels/list.ts @@ -0,0 +1,87 @@ +import { MastodonStream } from "../channel.js"; +import { Note } from "@/models/entities/note.js"; +import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { StreamMessages } from "@/server/api/stream/types.js"; +import { Packed } from "@/misc/schema.js"; +import { User } from "@/models/entities/user.js"; +import { UserListJoinings } from "@/models/index.js"; + +export class MastodonStreamList extends MastodonStream { + public static shouldShare = false; + public static requireCredential = true; + public static requiredScopes = ['read:statuses']; + private readonly listId: string; + private listUsers: User["id"][] = []; + private listUsersClock: NodeJS.Timer; + + constructor(connection: MastodonStream["connection"], name: string, list: string) { + super(connection, name); + this.listId = list; + this.onNote = this.onNote.bind(this); + this.onNoteEvent = this.onNoteEvent.bind(this); + this.updateListUsers = this.updateListUsers.bind(this); + } + + override get user() { + return this.connection.user!; + } + + public async init() { + if (!this.listId) return; + this.subscriber.on("notesStream", this.onNote); + this.subscriber.on("noteUpdatesStream", this.onNoteEvent); + + this.updateListUsers(); + this.listUsersClock = setInterval(this.updateListUsers, 5000); + } + + private async updateListUsers() { + const users = await UserListJoinings.find({ + where: { + userListId: this.listId, + }, + select: ["userId"], + }); + + this.listUsers = users.map((x) => x.userId); + } + + private async onNote(note: Note) { + if (!await this.shouldProcessNote(note)) return; + + const encoded = await NoteConverter.encodeEvent(note, this.user, 'home') + this.connection.send(this.chName, "update", encoded); + } + + private async onNoteEvent(data: StreamMessages["noteUpdates"]["payload"]) { + const note = data.body; + if (!await this.shouldProcessNote(note)) return; + + switch (data.type) { + case "updated": + const encoded = await NoteConverter.encodeEvent(note, this.user, 'home'); + this.connection.send(this.chName, "status.update", encoded); + break; + case "deleted": + this.connection.send(this.chName, "delete", note.id); + break; + default: + break; + } + } + + private async shouldProcessNote(note: Note | Packed<"Note">): Promise { + if (!this.listUsers.includes(note.userId)) return false; + if (note.channelId) return false; + if (note.renoteId !== null && !note.text && this.renoteMuting.has(note.userId)) return false; + if (note.visibility === "specified") return !!note.visibleUserIds?.includes(this.user.id); + if (note.visibility === "followers") return this.following.has(note.userId); + return true; + } + + public dispose() { + this.subscriber.off("notesStream", this.onNote); + this.subscriber.off("noteUpdatesStream", this.onNoteEvent); + clearInterval(this.listUsersClock); + } +} diff --git a/packages/backend/src/server/api/mastodon/streaming/channels/public.ts b/packages/backend/src/server/api/mastodon/streaming/channels/public.ts new file mode 100644 index 0000000..2ee82ca --- /dev/null +++ b/packages/backend/src/server/api/mastodon/streaming/channels/public.ts @@ -0,0 +1,82 @@ +import { MastodonStream } from "../channel.js"; +import { isUserRelated } from "@/misc/is-user-related.js"; +import { isInstanceMuted } from "@/misc/is-instance-muted.js"; +import { Note } from "@/models/entities/note.js"; +import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { StreamMessages } from "@/server/api/stream/types.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import isQuote from "@/misc/is-quote.js"; + +export class MastodonStreamPublic extends MastodonStream { + public static shouldShare = true; + public static requireCredential = false; + private readonly mediaOnly: boolean; + private readonly localOnly: boolean; + private readonly remoteOnly: boolean; + private readonly allowLocalOnly: boolean; + + constructor(connection: MastodonStream["connection"], name: string) { + super(connection, name); + this.mediaOnly = name.endsWith(":media"); + this.localOnly = name.startsWith("public:local"); + this.remoteOnly = name.startsWith("public:remote"); + this.allowLocalOnly = name.startsWith("public:allow_local_only"); + this.onNote = this.onNote.bind(this); + this.onNoteEvent = this.onNoteEvent.bind(this); + } + + public async init() { + const meta = await fetchMeta(); + if (meta.disableGlobalTimeline) { + if (this.user == null || !(this.user.isAdmin || this.user.isModerator)) + return; + } + + this.subscriber.on("notesStream", this.onNote); + this.subscriber.on("noteUpdatesStream", this.onNoteEvent); + } + + private async onNote(note: Note) { + if (!await this.shouldProcessNote(note)) return; + + const encoded = await NoteConverter.encodeEvent(note, this.user, 'public') + this.connection.send(this.chName, "update", encoded); + } + + private async onNoteEvent(data: StreamMessages["noteUpdates"]["payload"]) { + const note = data.body; + if (!await this.shouldProcessNote(note)) return; + + switch (data.type) { + case "updated": + const encoded = await NoteConverter.encodeEvent(note, this.user, 'public'); + this.connection.send(this.chName, "status.update", encoded); + break; + case "deleted": + this.connection.send(this.chName, "delete", note.id); + break; + default: + break; + } + } + + private async shouldProcessNote(note: Note): Promise { + if (note.visibility !== "public") return false; + if (note.channelId != null) return false; + if (this.mediaOnly && note.fileIds.length < 1) return false; + if (this.localOnly && note.userHost !== null) return false; + if (this.remoteOnly && note.userHost === null) return false; + if (note.localOnly && !this.allowLocalOnly && !this.localOnly) return false; + if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return false; + if (isUserRelated(note, this.muting)) return false; + if (isUserRelated(note, this.blocking)) return false; + if (note.renoteId !== null && !isQuote(note) && this.renoteMuting.has(note.userId)) return false; + + return true; + } + + public dispose() { + this.subscriber.off("notesStream", this.onNote); + this.subscriber.off("noteUpdatesStream", this.onNoteEvent); + } +} diff --git a/packages/backend/src/server/api/mastodon/streaming/channels/tag.ts b/packages/backend/src/server/api/mastodon/streaming/channels/tag.ts new file mode 100644 index 0000000..7780ea5 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/streaming/channels/tag.ts @@ -0,0 +1,74 @@ +import { MastodonStream } from "../channel.js"; +import { isUserRelated } from "@/misc/is-user-related.js"; +import { isInstanceMuted } from "@/misc/is-instance-muted.js"; +import { Note } from "@/models/entities/note.js"; +import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { StreamMessages } from "@/server/api/stream/types.js"; +import isQuote from "@/misc/is-quote.js"; + +export class MastodonStreamTag extends MastodonStream { + public static shouldShare = false; + public static requireCredential = false; + private readonly localOnly: boolean; + private readonly tag: string; + + constructor(connection: MastodonStream["connection"], name: string, tag: string) { + super(connection, name); + this.tag = tag; + this.localOnly = name.startsWith("hashtag:local"); + this.onNote = this.onNote.bind(this); + this.onNoteEvent = this.onNoteEvent.bind(this); + } + + override get user() { + return this.connection.user!; + } + + public async init() { + if (!this.tag) return; + this.subscriber.on("notesStream", this.onNote); + this.subscriber.on("noteUpdatesStream", this.onNoteEvent); + } + + private async onNote(note: Note) { + if (!await this.shouldProcessNote(note)) return; + + const encoded = await NoteConverter.encodeEvent(note, this.user, 'public') + this.connection.send(this.chName, "update", encoded); + } + + private async onNoteEvent(data: StreamMessages["noteUpdates"]["payload"]) { + const note = data.body; + if (!await this.shouldProcessNote(note)) return; + + switch (data.type) { + case "updated": + const encoded = await NoteConverter.encodeEvent(note, this.user, 'public'); + this.connection.send(this.chName, "status.update", encoded); + break; + case "deleted": + this.connection.send(this.chName, "delete", note.id); + break; + default: + break; + } + } + + private async shouldProcessNote(note: Note): Promise { + if (note.visibility !== "public") return false; + if (note.channelId != null) return false; + if (this.localOnly && note.userHost !== null) return false; + if (!note.tags?.includes(this.tag)) return false; + if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return false; + if (isUserRelated(note, this.muting)) return false; + if (isUserRelated(note, this.blocking)) return false; + if (note.renoteId !== null && !isQuote(note) && this.renoteMuting.has(note.userId)) return false; + + return true; + } + + public dispose() { + this.subscriber.off("notesStream", this.onNote); + this.subscriber.off("noteUpdatesStream", this.onNoteEvent); + } +} diff --git a/packages/backend/src/server/api/mastodon/streaming/channels/user.ts b/packages/backend/src/server/api/mastodon/streaming/channels/user.ts new file mode 100644 index 0000000..ea632d7 --- /dev/null +++ b/packages/backend/src/server/api/mastodon/streaming/channels/user.ts @@ -0,0 +1,113 @@ +import { MastodonStream } from "../channel.js"; +import { isUserRelated } from "@/misc/is-user-related.js"; +import { isInstanceMuted } from "@/misc/is-instance-muted.js"; +import { Note } from "@/models/entities/note.js"; +import { NoteConverter } from "@/server/api/mastodon/converters/note.js"; +import { StreamMessages } from "@/server/api/stream/types.js"; +import { NotificationConverter } from "@/server/api/mastodon/converters/notification.js"; +import { AnnouncementConverter } from "@/server/api/mastodon/converters/announcement.js"; +import isQuote from "@/misc/is-quote.js"; + +export class MastodonStreamUser extends MastodonStream { + public static shouldShare = true; + public static requireCredential = true; + public static requiredScopes = ['read:statuses', 'read:notifications']; + private readonly notificationsOnly: boolean; + + constructor(connection: MastodonStream["connection"], name: string) { + super(connection, name); + this.notificationsOnly = name === "user:notification"; + this.onNote = this.onNote.bind(this); + this.onNoteEvent = this.onNoteEvent.bind(this); + this.onUserEvent = this.onUserEvent.bind(this); + this.onBroadcastEvent = this.onBroadcastEvent.bind(this); + } + + override get user() { + return this.connection.user!; + } + + public async init() { + this.subscriber.on(`mainStream:${this.user.id}`, this.onUserEvent); + if (!this.notificationsOnly) { + this.subscriber.on("notesStream", this.onNote); + this.subscriber.on("noteUpdatesStream", this.onNoteEvent); + this.subscriber.on("broadcast", this.onBroadcastEvent); + } + } + + private async onNote(note: Note) { + if (!await this.shouldProcessNote(note)) return; + + const encoded = await NoteConverter.encodeEvent(note, this.user, 'home') + this.connection.send(this.chName, "update", encoded); + } + + private async onNoteEvent(data: StreamMessages["noteUpdates"]["payload"]) { + const note = data.body; + if (!await this.shouldProcessNote(note)) return; + + switch (data.type) { + case "updated": + const encoded = await NoteConverter.encodeEvent(note, this.user, 'home'); + this.connection.send(this.chName, "status.update", encoded); + break; + case "deleted": + this.connection.send(this.chName, "delete", note.id); + break; + default: + break; + } + } + + private async onUserEvent(data: StreamMessages["main"]["payload"]) { + switch (data.type) { + case "notification": + const encoded = await NotificationConverter.encodeEvent(data.body.id, this.user, 'notifications'); + if (encoded) this.connection.send(this.chName, "notification", encoded); + break; + default: + break; + } + } + + private async onBroadcastEvent(data: StreamMessages["broadcast"]["payload"]) { + switch (data.type) { + case "announcementAdded": + // This shouldn't be necessary but is for some reason + data.body.createdAt = new Date(data.body.createdAt); + this.connection.send(this.chName, "announcement", await AnnouncementConverter.encode(data.body, false)); + break; + case "announcementDeleted": + this.connection.send(this.chName, "announcement.delete", data.body); + break; + default: + break; + } + } + + + private async shouldProcessNote(note: Note): Promise { + if (note.visibility === "hidden") return false; + if (note.userId === this.user.id) return true; + if (note.visibility === "specified") return note.visibleUserIds?.includes(this.user.id); + if (note.channelId) return false; + if (this.user!.id !== note.userId && !this.following.has(note.userId)) return false; + if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return false; + if (isUserRelated(note, this.muting)) return false; + if (isUserRelated(note, this.blocking)) return false; + if (isUserRelated(note, this.hidden)) return false; + if (note.renoteId !== null && !isQuote(note) && this.renoteMuting.has(note.userId)) return false; + + return true; + } + + public dispose() { + this.subscriber.off(`mainStream:${this.user.id}`, this.onUserEvent); + if (!this.notificationsOnly) { + this.subscriber.off("notesStream", this.onNote); + this.subscriber.off("noteUpdatesStream", this.onNoteEvent); + this.subscriber.off("broadcast", this.onBroadcastEvent); + } + } +} diff --git a/packages/backend/src/server/api/mastodon/streaming/index.ts b/packages/backend/src/server/api/mastodon/streaming/index.ts new file mode 100644 index 0000000..ee546bc --- /dev/null +++ b/packages/backend/src/server/api/mastodon/streaming/index.ts @@ -0,0 +1,285 @@ +import type { EventEmitter } from "events"; +import type * as websocket from "websocket"; +import type { ILocalUser, User } from "@/models/entities/user.js"; +import type { MastodonStream } from "./channel.js"; +import { Blockings, Followings, Mutings, RenoteMutings, UserListJoinings, UserProfiles, } from "@/models/index.js"; +import type { UserProfile } from "@/models/entities/user-profile.js"; +import { StreamEventEmitter, StreamMessages } from "@/server/api/stream/types.js"; +import { apiLogger } from "@/server/api/logger.js"; +import { MastodonStreamUser } from "@/server/api/mastodon/streaming/channels/user.js"; +import { MastodonStreamDirect } from "@/server/api/mastodon/streaming/channels/direct.js"; +import { MastodonStreamPublic } from "@/server/api/mastodon/streaming/channels/public.js"; +import { MastodonStreamList } from "@/server/api/mastodon/streaming/channels/list.js"; +import { ParsedUrlQuery } from "querystring"; +import { toSingleLast } from "@/prelude/array.js"; +import { MastodonStreamTag } from "@/server/api/mastodon/streaming/channels/tag.js"; +import { OAuthToken } from "@/models/entities/oauth-token.js"; + +const logger = apiLogger.createSubLogger("streaming").createSubLogger("mastodon"); +const channels: Record = { + "user": MastodonStreamUser, + "user:notification": MastodonStreamUser, + "direct": MastodonStreamDirect, + "list": MastodonStreamList, + "public": MastodonStreamPublic, + "public:media": MastodonStreamPublic, + "public:local": MastodonStreamPublic, + "public:local:media": MastodonStreamPublic, + "public:remote": MastodonStreamPublic, + "public:remote:media": MastodonStreamPublic, + "public:allow_local_only": MastodonStreamPublic, + "public:allow_local_only:media": MastodonStreamPublic, + "hashtag": MastodonStreamTag, + "hashtag:local": MastodonStreamTag, +} + +export class MastodonStreamingConnection { + public user?: ILocalUser; + public userProfile?: UserProfile | null; + public following: Set = new Set(); + public muting: Set = new Set(); + public renoteMuting: Set = new Set(); + public blocking: Set = new Set(); + public hidden: Set = new Set(); + public token?: OAuthToken; + private wsConnection: websocket.connection; + private channels: MastodonStream[] = []; + public subscriber: StreamEventEmitter; + + constructor( + wsConnection: websocket.connection, + subscriber: EventEmitter, + user: ILocalUser | null | undefined, + token: OAuthToken | null | undefined, + query: ParsedUrlQuery, + ) { + const channel = toSingleLast(query.stream); + logger.debug(`New connection on channel: ${channel}`); + this.wsConnection = wsConnection; + this.subscriber = subscriber; + if (user) this.user = user; + if (token) this.token = token; + + this.onMessage = this.onMessage.bind(this); + this.onUserEvent = this.onUserEvent.bind(this); + + this.wsConnection.on("message", this.onMessage); + + if (this.user) { + this.updateFollowing(); + this.updateMuting(); + this.updateRenoteMuting(); + this.updateBlocking(); + this.updateHidden(); + this.updateUserProfile(); + + this.subscriber.on(`user:${this.user.id}`, this.onUserEvent); + } + + if (channel) { + const list = toSingleLast(query.list); + const tag = toSingleLast(query.tag); + this.onMessage({ + type: "utf8", + utf8Data: JSON.stringify({ stream: channel, type: "subscribe", list, tag }), + }); + } + } + + private onUserEvent(data: StreamMessages["user"]["payload"]) { + switch (data.type) { + case "follow": + this.following.add(data.body.id); + break; + case "unfollow": + this.following.delete(data.body.id); + break; + case "mute": + this.muting.add(data.body.id); + break; + case "unmute": + this.muting.delete(data.body.id); + break; + case "userHidden": + this.hidden.add(data.body); + break; + case "userUnhidden": + this.hidden.delete(data.body); + break; + + // TODO: renote mute events + // TODO: block events + + case "updateUserProfile": + this.userProfile = data.body; + break; + case "terminate": + this.closeConnection(); + break; + default: + break; + } + } + + private async onMessage(data: websocket.Message) { + if (data.type !== "utf8") return; + if (data.utf8Data == null) return; + + let message: Record; + + try { + message = JSON.parse(data.utf8Data); + } catch (e) { + logger.error("Failed to parse json data, ignoring"); + return; + } + + const { stream, type, list, tag } = message; + + if (!message.stream || !message.type) { + logger.error("Invalid message received, ignoring"); + return; + } + + if (list ?? tag) + logger.info(`${type}: ${stream} ${list ?? tag}`); + else + logger.info(`${type}: ${stream}`); + + switch (type) { + case "subscribe": + this.connectChannel(stream, list, tag); + break; + case "unsubscribe": + this.disconnectChannel(stream); + break; + } + } + + public send(stream: string, event: string, payload: any) { + const json = JSON.stringify({ + stream: [stream], + event: event, + payload: typeof payload === "string" ? payload : JSON.stringify(payload), + }) + + this.wsConnection.send(json); + } + + public connectChannel(channel: string, list?: string, tag?: string) { + if (!channels[channel]) { + logger.info(`Ignoring connection to unknown channel ${channel}`); + return; + } + if (channels[channel].requireCredential) { + if (this.user == null) { + logger.info(`Refusing connection to channel ${channel} without authentication, terminating connection`); + this.closeConnection(); + return; + } else if (!channels[channel].requiredScopes.every((p: string) => this.token?.scopes?.includes(p))) { + logger.info(`Refusing connection to channel ${channel} without required OAuth scopes, terminating connection`); + this.closeConnection(); + return; + } + } + + if ( + channels[channel].shouldShare && + this.channels.some((c) => c.chName === channel) + ) { + return; + } + + let ch: MastodonStream; + + if (channel === "list") { + ch = new channels[channel](this, channel, list); + } else if (channel.startsWith("hashtag")) + ch = new channels[channel](this, channel, tag); + else + ch = new channels[channel](this, channel); + this.channels.push(ch); + ch.init(null); + } + + public disconnectChannel(channelName: string) { + const channel = this.channels.find((c) => c.chName === channelName); + + if (channel) { + if (channel.dispose) channel.dispose(); + this.channels = this.channels.filter((c) => c.chName !== channelName); + } + } + + private async updateFollowing() { + const followings = await Followings.find({ + where: { + followerId: this.user!.id, + }, + select: ["followeeId"], + }); + + this.following = new Set(followings.map((x) => x.followeeId)); + } + + private async updateMuting() { + const mutings = await Mutings.find({ + where: { + muterId: this.user!.id, + }, + select: ["muteeId"], + }); + + this.muting = new Set(mutings.map((x) => x.muteeId)); + } + + private async updateRenoteMuting() { + const renoteMutings = await RenoteMutings.find({ + where: { + muterId: this.user!.id, + }, + select: ["muteeId"], + }); + + this.renoteMuting = new Set(renoteMutings.map((x) => x.muteeId)); + } + + private async updateBlocking() { + const blockings = await Blockings.find({ + where: { + blockeeId: this.user!.id, + }, + select: ["blockerId"], + }); + + this.blocking = new Set(blockings.map((x) => x.blockerId)); + } + + private async updateHidden() { + const hidden = await UserListJoinings.find({ + where: { + userList: { userId: this.user!.id, hideFromHomeTl: true }, + }, + select: ["userId"], + }); + + this.hidden = new Set(hidden.map((x) => x.userId)); + } + + private async updateUserProfile() { + this.userProfile = await UserProfiles.findOneBy({ + userId: this.user!.id, + }); + } + + public closeConnection() { + this.wsConnection.close(); + this.dispose(); + } + + public dispose() { + for (const c of this.channels.filter((c) => c.dispose)) { + if (c.dispose) c.dispose(); + } + } +} diff --git a/packages/backend/src/server/api/openapi/errors.ts b/packages/backend/src/server/api/openapi/errors.ts new file mode 100644 index 0000000..9e7c77c --- /dev/null +++ b/packages/backend/src/server/api/openapi/errors.ts @@ -0,0 +1,70 @@ +export const errors = { + "400": { + INVALID_PARAM: { + value: { + error: { + message: "Invalid parameter.", + code: "INVALID_PARAM", + id: "3d81ceae-475f-4600-b2a8-2bc116157532", + }, + }, + }, + }, + "401": { + CREDENTIAL_REQUIRED: { + value: { + error: { + message: "Credential required.", + code: "CREDENTIAL_REQUIRED", + id: "1384574d-a912-4b81-8601-c7b1c4085df1", + }, + }, + }, + }, + "403": { + AUTHENTICATION_FAILED: { + value: { + error: { + message: "Authentication failed.", + code: "AUTHENTICATION_FAILED", + id: "b0a7f5f8-dc2f-4171-b91f-de88ad238e14", + }, + }, + }, + }, + "418": { + I_AM_CALC: { + value: { + error: { + message: + "You sent a request to Calc instead of the server. How did this happen?", + code: "I_AM_CALC", + id: "60c46cd1-f23a-46b1-bebe-5d2b73951a84", + }, + }, + }, + }, + "429": { + RATE_LIMIT_EXCEEDED: { + value: { + error: { + message: "Rate limit exceeded. Please try again later.", + code: "RATE_LIMIT_EXCEEDED", + id: "d5826d14-3982-4d2e-8011-b9e9f02499ef", + }, + }, + }, + }, + "500": { + INTERNAL_ERROR: { + value: { + error: { + message: + "Internal error occurred. Please contact us if the error persists.", + code: "INTERNAL_ERROR", + id: "5d37dbcb-891e-41ca-a3d6-e690c97775ac", + }, + }, + }, + }, +}; diff --git a/packages/backend/src/server/api/openapi/gen-spec.ts b/packages/backend/src/server/api/openapi/gen-spec.ts new file mode 100644 index 0000000..1010aff --- /dev/null +++ b/packages/backend/src/server/api/openapi/gen-spec.ts @@ -0,0 +1,226 @@ +import endpoints from "../endpoints.js"; +import config from "@/config/index.js"; +import { errors as basicErrors } from "./errors.js"; +import { schemas, convertSchemaToOpenApiSchema } from "./schemas.js"; + +export function genOpenapiSpec() { + const spec = { + openapi: "3.0.0", + + info: { + version: "v1", + title: "FrozenFriendsYume API", + "x-logo": { url: "/static-assets/api-doc.png" }, + }, + + externalDocs: { + description: "Repository", + url: "https://iceshrimp.dev/iceshrimp/iceshrimp", + }, + + servers: [ + { + url: config.apiUrl, + }, + ], + + paths: {} as any, + + components: { + schemas: schemas, + + securitySchemes: { + ApiKeyAuth: { + type: "apiKey", + in: "body", + name: "i", + }, + // TODO: change this to oauth2 when the remaining oauth stuff is set up + Bearer: { + type: "http", + scheme: "bearer", + }, + }, + }, + }; + + for (const endpoint of endpoints.filter((ep) => !ep.meta.secure)) { + const errors = {} as any; + + if (endpoint.meta.errors) { + for (const e of Object.values(endpoint.meta.errors)) { + errors[e.code] = { + value: { + error: e, + }, + }; + } + } + + const resSchema = endpoint.meta.res + ? convertSchemaToOpenApiSchema(endpoint.meta.res) + : {}; + + let desc = + (endpoint.meta.description + ? endpoint.meta.description + : "No description provided.") + "\n\n"; + desc += `**Credential required**: *${ + endpoint.meta.requireCredential ? "Yes" : "No" + }*`; + if (endpoint.meta.kind) { + const kind = endpoint.meta.kind; + desc += ` / **Permission**: *${kind}*`; + } + + const requestType = endpoint.meta.requireFile + ? "multipart/form-data" + : "application/json"; + const schema = endpoint.params; + + if (endpoint.meta.requireFile) { + schema.properties.file = { + type: "string", + format: "binary", + description: "The file contents.", + }; + schema.required.push("file"); + } + + const security = [ + { + ApiKeyAuth: [], + }, + { + Bearer: [], + }, + ]; + if (!endpoint.meta.requireCredential) { + // add this to make authentication optional + security.push({}); + } + + const info = { + operationId: endpoint.name, + summary: endpoint.name, + description: desc, + externalDocs: { + description: "Source code", + url: `https://iceshrimp.dev/iceshrimp/iceshrimp/src/branch/dev/packages/backend/src/server/api/endpoints/${endpoint.name}.ts`, + }, + tags: endpoint.meta.tags || undefined, + security, + requestBody: { + required: true, + content: { + [requestType]: { + schema, + }, + }, + }, + responses: { + ...(endpoint.meta.res + ? { + "200": { + description: "OK (with results)", + content: { + "application/json": { + schema: resSchema, + }, + }, + }, + } + : { + "204": { + description: "OK (without any results)", + }, + }), + "400": { + description: "Client error", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Error", + }, + examples: { ...errors, ...basicErrors["400"] }, + }, + }, + }, + "401": { + description: "Authentication error", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Error", + }, + examples: basicErrors["401"], + }, + }, + }, + "403": { + description: "Forbidden error", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Error", + }, + examples: basicErrors["403"], + }, + }, + }, + "418": { + description: "I'm Calc", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Error", + }, + examples: basicErrors["418"], + }, + }, + }, + ...(endpoint.meta.limit + ? { + "429": { + description: "Too many requests", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Error", + }, + examples: basicErrors["429"], + }, + }, + }, + } + : {}), + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { + $ref: "#/components/schemas/Error", + }, + examples: basicErrors["500"], + }, + }, + }, + }, + }; + + const path = { + post: info, + }; + if (endpoint.meta.allowGet) { + path.get = { ...info }; + // API Key authentication is not permitted for GET requests + path.get.security = path.get.security.filter( + (elem) => !Object.prototype.hasOwnProperty.call(elem, "ApiKeyAuth"), + ); + } + + spec.paths[`/${endpoint.name}`] = path; + } + + return spec; +} diff --git a/packages/backend/src/server/api/openapi/schemas.ts b/packages/backend/src/server/api/openapi/schemas.ts new file mode 100644 index 0000000..68b15d5 --- /dev/null +++ b/packages/backend/src/server/api/openapi/schemas.ts @@ -0,0 +1,66 @@ +import type { Schema } from "@/misc/schema.js"; +import { refs } from "@/misc/schema.js"; + +export function convertSchemaToOpenApiSchema(schema: Schema) { + const res: any = schema; + + if (schema.type === "object" && schema.properties) { + res.required = Object.entries(schema.properties) + .filter(([k, v]) => !v.optional) + .map(([k]) => k); + + for (const k of Object.keys(schema.properties)) { + res.properties[k] = convertSchemaToOpenApiSchema(schema.properties[k]); + } + } + + if (schema.type === "array" && schema.items) { + res.items = convertSchemaToOpenApiSchema(schema.items); + } + + if (schema.anyOf) res.anyOf = schema.anyOf.map(convertSchemaToOpenApiSchema); + if (schema.oneOf) res.oneOf = schema.oneOf.map(convertSchemaToOpenApiSchema); + if (schema.allOf) res.allOf = schema.allOf.map(convertSchemaToOpenApiSchema); + + if (schema.ref) { + res.$ref = `#/components/schemas/${schema.ref}`; + } + + return res; +} + +export const schemas = { + Error: { + type: "object", + properties: { + error: { + type: "object", + description: "An error object.", + properties: { + code: { + type: "string", + description: "An error code. Unique within the endpoint.", + }, + message: { + type: "string", + description: "An error message.", + }, + id: { + type: "string", + format: "uuid", + description: "An error ID. This ID is static.", + }, + }, + required: ["code", "id", "message"], + }, + }, + required: ["error"], + }, + + ...Object.fromEntries( + Object.entries(refs).map(([key, schema]) => [ + key, + convertSchemaToOpenApiSchema(schema), + ]), + ), +}; diff --git a/packages/backend/src/server/api/private/signin.ts b/packages/backend/src/server/api/private/signin.ts new file mode 100644 index 0000000..06d801a --- /dev/null +++ b/packages/backend/src/server/api/private/signin.ts @@ -0,0 +1,274 @@ +import type Koa from "koa"; +import * as OTPAuth from "otpauth"; +import signin from "../common/signin.js"; +import config from "@/config/index.js"; +import { + Users, + Signins, + UserProfiles, + UserSecurityKeys, + AttestationChallenges, +} from "@/models/index.js"; +import type { ILocalUser } from "@/models/entities/user.js"; +import { genId } from "@/misc/gen-id.js"; +import { + comparePassword, + hashPassword, + isOldAlgorithm, +} from "@/misc/password.js"; +import { verifyLogin, hash } from "../2fa.js"; +import { randomBytes } from "node:crypto"; +import { IsNull } from "typeorm"; +import { limiter } from "../limiter.js"; +import { getIpHash } from "@/misc/get-ip-hash.js"; + +export default async (ctx: Koa.Context) => { + ctx.set("Access-Control-Allow-Origin", config.url); + ctx.set("Access-Control-Allow-Credentials", "true"); + + const body = ctx.request.body as any; + const username = body["username"]; + const password = body["password"]; + const token = body["token"]; + + function error(status: number, error: { id: string }) { + ctx.status = status; + ctx.body = { error }; + } + + try { + // not more than 1 attempt per second and not more than 10 attempts per hour + await limiter( + { key: "signin", duration: 60 * 60 * 1000, max: 10, minInterval: 1000 }, + getIpHash(ctx.ip), + ); + } catch (err) { + ctx.status = 429; + ctx.body = { + error: { + message: "Too many failed attempts to sign in. Try again later.", + code: "TOO_MANY_AUTHENTICATION_FAILURES", + id: "22d05606-fbcf-421a-a2db-b32610dcfd1b", + }, + }; + return; + } + + if (typeof username !== "string") { + ctx.status = 400; + return; + } + + if (typeof password !== "string") { + ctx.status = 400; + return; + } + + if (token != null && typeof token !== "string") { + ctx.status = 400; + return; + } + + // Fetch user + const user = (await Users.findOneBy({ + usernameLower: username.toLowerCase(), + host: IsNull(), + })) as ILocalUser; + + if (user == null) { + error(404, { + id: "6cc579cc-885d-43d8-95c2-b8c7fc963280", + }); + return; + } + + if (user.isSuspended) { + error(403, { + id: "e03a5f46-d309-4865-9b69-56282d94e1eb", + }); + return; + } + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + // Compare password + const same = await comparePassword(password, profile.password!); + + if (same && isOldAlgorithm(profile.password!)) { + profile.password = await hashPassword(password); + await UserProfiles.save(profile); + } + + async function fail(status?: number, failure?: { id: string }) { + // Append signin history + await Signins.insert({ + id: genId(), + createdAt: new Date(), + userId: user.id, + ip: ctx.ip, + headers: ctx.headers, + success: false, + }); + + error( + status || 500, + failure || { id: "4e30e80c-e338-45a0-8c8f-44455efa3b76" }, + ); + } + + if (!profile.twoFactorEnabled) { + if (same) { + signin(ctx, user); + return; + } else { + await fail(403, { + id: "932c904e-9460-45b7-9ce6-7ed33be7eb2c", + }); + return; + } + } + + if (token) { + if (!same) { + await fail(403, { + id: "932c904e-9460-45b7-9ce6-7ed33be7eb2c", + }); + return; + } + + if (profile.twoFactorSecret == null) { + throw new Error("Attempted 2FA signin without 2FA enabled."); + } + + const delta = OTPAuth.TOTP.validate({ + secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret), + digits: 6, + token, + window: 1, + }); + + if (delta != null) { + signin(ctx, user); + return; + } else { + await fail(403, { + id: "cdf1235b-ac71-46d4-a3a6-84ccce48df6f", + }); + return; + } + } else if (body.credentialId) { + if (!(same || profile.usePasswordLessLogin)) { + await fail(403, { + id: "932c904e-9460-45b7-9ce6-7ed33be7eb2c", + }); + return; + } + + const clientDataJSON = Buffer.from(body.clientDataJSON, "hex"); + const clientData = JSON.parse(clientDataJSON.toString("utf-8")); + const challenge = await AttestationChallenges.findOneBy({ + userId: user.id, + id: body.challengeId, + registrationChallenge: false, + challenge: hash(clientData.challenge).toString("hex"), + }); + + if (!challenge) { + await fail(403, { + id: "2715a88a-2125-4013-932f-aa6fe72792da", + }); + return; + } + + await AttestationChallenges.delete({ + userId: user.id, + id: body.challengeId, + }); + + if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) { + await fail(403, { + id: "2715a88a-2125-4013-932f-aa6fe72792da", + }); + return; + } + + const securityKey = await UserSecurityKeys.findOneBy({ + id: Buffer.from( + body.credentialId.replace(/-/g, "+").replace(/_/g, "/"), + "base64", + ).toString("hex"), + }); + + if (!securityKey) { + await fail(403, { + id: "66269679-aeaf-4474-862b-eb761197e046", + }); + return; + } + + const isValid = verifyLogin({ + publicKey: Buffer.from(securityKey.publicKey, "hex"), + authenticatorData: Buffer.from(body.authenticatorData, "hex"), + clientDataJSON, + clientData, + signature: Buffer.from(body.signature, "hex"), + challenge: challenge.challenge, + }); + + if (isValid) { + signin(ctx, user); + return; + } else { + await fail(403, { + id: "93b86c4b-72f9-40eb-9815-798928603d1e", + }); + return; + } + } else { + if (!(same || profile.usePasswordLessLogin)) { + await fail(403, { + id: "932c904e-9460-45b7-9ce6-7ed33be7eb2c", + }); + return; + } + + const keys = await UserSecurityKeys.findBy({ + userId: user.id, + }); + + if (keys.length === 0) { + await fail(403, { + id: "f27fd449-9af4-4841-9249-1f989b9fa4a4", + }); + return; + } + + // 32 byte challenge + const challenge = randomBytes(32) + .toString("base64") + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + + const challengeId = genId(); + + await AttestationChallenges.insert({ + userId: user.id, + id: challengeId, + challenge: hash(Buffer.from(challenge, "utf-8")).toString("hex"), + createdAt: new Date(), + registrationChallenge: false, + }); + + ctx.body = { + challenge, + challengeId, + securityKeys: keys.map((key) => ({ + id: key.id, + })), + }; + ctx.status = 200; + return; + } + // never get here +}; diff --git a/packages/backend/src/server/api/private/signup-pending.ts b/packages/backend/src/server/api/private/signup-pending.ts new file mode 100644 index 0000000..c7fdcea --- /dev/null +++ b/packages/backend/src/server/api/private/signup-pending.ts @@ -0,0 +1,38 @@ +import type Koa from "koa"; +import { Users, UserPendings, UserProfiles } from "@/models/index.js"; +import { signup } from "../common/signup.js"; +import signin from "../common/signin.js"; + +export default async (ctx: Koa.Context) => { + const body = ctx.request.body; + + const code = body["code"]; + + try { + const pendingUser = await UserPendings.findOneByOrFail({ code }); + + const { account, secret } = await signup({ + username: pendingUser.username, + passwordHash: pendingUser.password, + }); + + UserPendings.delete({ + id: pendingUser.id, + }); + + const profile = await UserProfiles.findOneByOrFail({ userId: account.id }); + + await UserProfiles.update( + { userId: profile.userId }, + { + email: pendingUser.email, + emailVerified: true, + emailVerifyCode: null, + }, + ); + + signin(ctx, account); + } catch (e) { + ctx.throw(400, e); + } +}; diff --git a/packages/backend/src/server/api/private/signup.ts b/packages/backend/src/server/api/private/signup.ts new file mode 100644 index 0000000..440d0e3 --- /dev/null +++ b/packages/backend/src/server/api/private/signup.ts @@ -0,0 +1,128 @@ +import type Koa from "koa"; +import rndstr from "rndstr"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { verifyHcaptcha, verifyRecaptcha } from "@/misc/captcha.js"; +import { Users, RegistrationTickets, UserPendings } from "@/models/index.js"; +import { signup } from "../common/signup.js"; +import config from "@/config/index.js"; +import { sendEmail } from "@/services/send-email.js"; +import { genId } from "@/misc/gen-id.js"; +import { validateEmailForAccount } from "@/services/validate-email-for-account.js"; +import { hashPassword } from "@/misc/password.js"; + +export default async (ctx: Koa.Context) => { + const body = ctx.request.body; + + const instance = await fetchMeta(true); + + // Verify *Captcha + // ただしテスト時はこの機構は障害となるため無効にする + if (process.env.NODE_ENV !== "test") { + if (instance.enableHcaptcha && instance.hcaptchaSecretKey) { + await verifyHcaptcha( + instance.hcaptchaSecretKey, + body["hcaptcha-response"], + ).catch((e) => { + ctx.throw(400, e); + }); + } + + if (instance.enableRecaptcha && instance.recaptchaSecretKey) { + await verifyRecaptcha( + instance.recaptchaSecretKey, + body["g-recaptcha-response"], + ).catch((e) => { + ctx.throw(400, e); + }); + } + } + + const username = body["username"]; + const password = body["password"]; + const host: string | null = + process.env.NODE_ENV === "test" ? body["host"] || null : null; + const invitationCode = body["invitationCode"]; + const emailAddress = body["emailAddress"]; + + if (config.reservedUsernames?.includes(username.toLowerCase())) { + ctx.status = 400; + return; + } + + if (instance.emailRequiredForSignup) { + if (emailAddress == null || typeof emailAddress !== "string") { + ctx.status = 400; + return; + } + + const { available } = await validateEmailForAccount(emailAddress); + if (!available) { + ctx.status = 400; + return; + } + } + + if (instance.disableRegistration) { + if (invitationCode == null || typeof invitationCode !== "string") { + ctx.status = 400; + return; + } + + const ticket = await RegistrationTickets.findOneBy({ + code: invitationCode, + }); + + if (ticket == null) { + ctx.status = 400; + return; + } + + RegistrationTickets.delete(ticket.id); + } + + if (instance.emailRequiredForSignup) { + const code = rndstr("a-z0-9", 16); + + // Generate hash of password + const hash = await hashPassword(password); + + await UserPendings.insert({ + id: genId(), + createdAt: new Date(), + code, + email: emailAddress, + username: username, + password: hash, + }); + + const link = `${config.url}/signup-complete/${code}`; + + sendEmail( + emailAddress, + "Signup", + `To complete signup, please click this link:
${link}`, + `To complete signup, please click this link: ${link}`, + ); + + ctx.status = 204; + } else { + try { + const { account, secret } = await signup({ + username, + password, + host, + }); + + const res = await Users.pack(account, account, { + detail: true, + includeSecrets: true, + }); + + (res as any).token = secret; + + ctx.body = res; + } catch (e) { + ctx.throw(400, e); + } + } +}; diff --git a/packages/backend/src/server/api/private/verify-email.ts b/packages/backend/src/server/api/private/verify-email.ts new file mode 100644 index 0000000..e6c8295 --- /dev/null +++ b/packages/backend/src/server/api/private/verify-email.ts @@ -0,0 +1,38 @@ +import type Koa from "koa"; +import { Users, UserProfiles } from "@/models/index.js"; +import { publishMainStream } from "@/services/stream.js"; + +export default async (ctx: Koa.Context) => { + const body = ctx.request.body; + + const code = body["code"]; + + const profile = await UserProfiles.findOneByOrFail({ emailVerifyCode: code }); + + if (profile != null) { + ctx.body = "Verify succeeded!"; + + await UserProfiles.update( + { userId: profile.userId }, + { + emailVerified: true, + emailVerifyCode: null, + }, + ); + + publishMainStream( + profile.userId, + "meUpdated", + await Users.pack( + profile.userId, + { id: profile.userId }, + { + detail: true, + includeSecrets: true, + }, + ), + ); + } else { + ctx.throw(404); + } +}; diff --git a/packages/backend/src/server/api/service/discord.ts b/packages/backend/src/server/api/service/discord.ts new file mode 100644 index 0000000..848a70d --- /dev/null +++ b/packages/backend/src/server/api/service/discord.ts @@ -0,0 +1,333 @@ +import type Koa from "koa"; +import Router from "@koa/router"; +import { OAuth2 } from "oauth"; +import { v4 as uuid } from "uuid"; +import { IsNull } from "typeorm"; +import { getJson } from "@/misc/fetch.js"; +import config from "@/config/index.js"; +import { publishMainStream } from "@/services/stream.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { Users, UserProfiles } from "@/models/index.js"; +import type { ILocalUser } from "@/models/entities/user.js"; +import { redisClient } from "../../../db/redis.js"; +import signin from "../common/signin.js"; + +function getUserToken(ctx: Koa.BaseContext): string | null { + return ((ctx.headers["cookie"] || "").match(/igi=(\w+)/) || [null, null])[1]; +} + +function compareOrigin(ctx: Koa.BaseContext): boolean { + function normalizeUrl(url?: string): string { + return url ? (url.endsWith("/") ? url.slice(0, url.length - 1) : url) : ""; + } + + const referer = ctx.headers["referer"]; + + return normalizeUrl(referer) === normalizeUrl(config.url); +} + +// Init router +const router = new Router(); + +router.get("/disconnect/discord", async (ctx) => { + if (!compareOrigin(ctx)) { + ctx.throw(400, "invalid origin"); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, "signin required"); + return; + } + + const user = await Users.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + profile.integrations.discord = undefined; + + await UserProfiles.update(user.id, { + integrations: profile.integrations, + }); + + ctx.body = "Discordの連携を解除しました :v:"; + + // Publish i updated event + publishMainStream( + user.id, + "meUpdated", + await Users.pack(user, user, { + detail: true, + includeSecrets: true, + }), + ); +}); + +async function getOAuth2() { + const meta = await fetchMeta(true); + + if (meta.enableDiscordIntegration) { + return new OAuth2( + meta.discordClientId!, + meta.discordClientSecret!, + "https://discord.com/", + "api/oauth2/authorize", + "api/oauth2/token", + ); + } else { + return null; + } +} + +router.get("/connect/discord", async (ctx) => { + if (!compareOrigin(ctx)) { + ctx.throw(400, "invalid origin"); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, "signin required"); + return; + } + + const params = { + redirect_uri: `${config.url}/api/dc/cb`, + scope: ["identify"], + state: uuid(), + response_type: "code", + }; + + redisClient.set(userToken, JSON.stringify(params)); + + const oauth2 = await getOAuth2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get("/signin/discord", async (ctx) => { + const sessid = uuid(); + + const params = { + redirect_uri: `${config.url}/api/dc/cb`, + scope: ["identify"], + state: uuid(), + response_type: "code", + }; + + ctx.cookies.set("signin_with_discord_sid", sessid, { + path: "/", + secure: config.url.startsWith("https"), + httpOnly: true, + }); + + redisClient.set(sessid, JSON.stringify(params)); + + const oauth2 = await getOAuth2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get("/dc/cb", async (ctx) => { + const userToken = getUserToken(ctx); + + const oauth2 = await getOAuth2(); + + if (!userToken) { + const sessid = ctx.cookies.get("signin_with_discord_sid"); + + if (!sessid) { + ctx.throw(400, "invalid session"); + return; + } + + const code = ctx.query.code; + + if (!code || typeof code !== "string") { + ctx.throw(400, "invalid session"); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + redisClient.get(sessid, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, "invalid session"); + return; + } + + const { accessToken, refreshToken, expiresDate } = await new Promise( + (res, rej) => + oauth2!.getOAuthAccessToken( + code, + { + grant_type: "authorization_code", + redirect_uri, + }, + (err, accessToken, refreshToken, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ + accessToken, + refreshToken, + expiresDate: Date.now() + Number(result.expires_in) * 1000, + }); + } + }, + ), + ); + + const { id, username, discriminator } = (await getJson( + "https://discord.com/api/users/@me", + "*/*", + 10 * 1000, + { + Authorization: `Bearer ${accessToken}`, + }, + )) as Record; + + if ( + typeof id !== "string" || + typeof username !== "string" || + typeof discriminator !== "string" + ) { + ctx.throw(400, "invalid session"); + return; + } + + const profile = await UserProfiles.createQueryBuilder() + .where("\"integrations\"->'discord'->>'id' = :id", { id: id }) + .andWhere('"userHost" IS NULL') + .getOne(); + + if (profile == null) { + ctx.throw( + 404, + `@${username}#${discriminator}と連携しているMisskeyアカウントはありませんでした...`, + ); + return; + } + + await UserProfiles.update(profile.userId, { + integrations: { + ...profile.integrations, + discord: { + id: id, + accessToken: accessToken, + refreshToken: refreshToken, + expiresDate: expiresDate, + username: username, + discriminator: discriminator, + }, + }, + }); + + signin( + ctx, + (await Users.findOneBy({ id: profile.userId })) as ILocalUser, + true, + ); + } else { + const code = ctx.query.code; + + if (!code || typeof code !== "string") { + ctx.throw(400, "invalid session"); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + redisClient.get(userToken, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, "invalid session"); + return; + } + + const { accessToken, refreshToken, expiresDate } = await new Promise( + (res, rej) => + oauth2!.getOAuthAccessToken( + code, + { + grant_type: "authorization_code", + redirect_uri, + }, + (err, accessToken, refreshToken, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ + accessToken, + refreshToken, + expiresDate: Date.now() + Number(result.expires_in) * 1000, + }); + } + }, + ), + ); + + const { id, username, discriminator } = (await getJson( + "https://discord.com/api/users/@me", + "*/*", + 10 * 1000, + { + Authorization: `Bearer ${accessToken}`, + }, + )) as Record; + if ( + typeof id !== "string" || + typeof username !== "string" || + typeof discriminator !== "string" + ) { + ctx.throw(400, "invalid session"); + return; + } + + const user = await Users.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + await UserProfiles.update(user.id, { + integrations: { + ...profile.integrations, + discord: { + accessToken: accessToken, + refreshToken: refreshToken, + expiresDate: expiresDate, + id: id, + username: username, + discriminator: discriminator, + }, + }, + }); + + ctx.body = `Discord: @${username}#${discriminator} を、Misskey: @${user.username} に接続しました!`; + + // Publish i updated event + publishMainStream( + user.id, + "meUpdated", + await Users.pack(user, user, { + detail: true, + includeSecrets: true, + }), + ); + } +}); + +export default router; diff --git a/packages/backend/src/server/api/service/github.ts b/packages/backend/src/server/api/service/github.ts new file mode 100644 index 0000000..fd015fb --- /dev/null +++ b/packages/backend/src/server/api/service/github.ts @@ -0,0 +1,296 @@ +import type Koa from "koa"; +import Router from "@koa/router"; +import { OAuth2 } from "oauth"; +import { v4 as uuid } from "uuid"; +import { IsNull } from "typeorm"; +import { getJson } from "@/misc/fetch.js"; +import config from "@/config/index.js"; +import { publishMainStream } from "@/services/stream.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { Users, UserProfiles } from "@/models/index.js"; +import type { ILocalUser } from "@/models/entities/user.js"; +import { redisClient } from "../../../db/redis.js"; +import signin from "../common/signin.js"; + +function getUserToken(ctx: Koa.BaseContext): string | null { + return ((ctx.headers["cookie"] || "").match(/igi=(\w+)/) || [null, null])[1]; +} + +function compareOrigin(ctx: Koa.BaseContext): boolean { + function normalizeUrl(url?: string): string { + return url ? (url.endsWith("/") ? url.slice(0, url.length - 1) : url) : ""; + } + + const referer = ctx.headers["referer"]; + + return normalizeUrl(referer) === normalizeUrl(config.url); +} + +// Init router +const router = new Router(); + +router.get("/disconnect/github", async (ctx) => { + if (!compareOrigin(ctx)) { + ctx.throw(400, "invalid origin"); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, "signin required"); + return; + } + + const user = await Users.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + profile.integrations.github = undefined; + + await UserProfiles.update(user.id, { + integrations: profile.integrations, + }); + + ctx.body = "GitHubの連携を解除しました :v:"; + + // Publish i updated event + publishMainStream( + user.id, + "meUpdated", + await Users.pack(user, user, { + detail: true, + includeSecrets: true, + }), + ); +}); + +async function getOath2() { + const meta = await fetchMeta(true); + + if ( + meta.enableGithubIntegration && + meta.githubClientId && + meta.githubClientSecret + ) { + return new OAuth2( + meta.githubClientId, + meta.githubClientSecret, + "https://github.com/", + "login/oauth/authorize", + "login/oauth/access_token", + ); + } else { + return null; + } +} + +router.get("/connect/github", async (ctx) => { + if (!compareOrigin(ctx)) { + ctx.throw(400, "invalid origin"); + return; + } + + const userToken = getUserToken(ctx); + if (!userToken) { + ctx.throw(400, "signin required"); + return; + } + + const params = { + redirect_uri: `${config.url}/api/gh/cb`, + scope: ["read:user"], + state: uuid(), + }; + + redisClient.set(userToken, JSON.stringify(params)); + + const oauth2 = await getOath2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get("/signin/github", async (ctx) => { + const sessid = uuid(); + + const params = { + redirect_uri: `${config.url}/api/gh/cb`, + scope: ["read:user"], + state: uuid(), + }; + + ctx.cookies.set("signin_with_github_sid", sessid, { + path: "/", + secure: config.url.startsWith("https"), + httpOnly: true, + }); + + redisClient.set(sessid, JSON.stringify(params)); + + const oauth2 = await getOath2(); + ctx.redirect(oauth2!.getAuthorizeUrl(params)); +}); + +router.get("/gh/cb", async (ctx) => { + const userToken = getUserToken(ctx); + + const oauth2 = await getOath2(); + + if (!userToken) { + const sessid = ctx.cookies.get("signin_with_github_sid"); + + if (!sessid) { + ctx.throw(400, "invalid session"); + return; + } + + const code = ctx.query.code; + + if (!code || typeof code !== "string") { + ctx.throw(400, "invalid session"); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + redisClient.get(sessid, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, "invalid session"); + return; + } + + const { accessToken } = await new Promise((res, rej) => + oauth2!.getOAuthAccessToken( + code, + { + redirect_uri, + }, + (err, accessToken, refresh, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ accessToken }); + } + }, + ), + ); + + const { login, id } = (await getJson( + "https://api.github.com/user", + "application/vnd.github.v3+json", + 10 * 1000, + { + Authorization: `bearer ${accessToken}`, + }, + )) as Record; + if (typeof login !== "string" || typeof id !== "string") { + ctx.throw(400, "invalid session"); + return; + } + + const link = await UserProfiles.createQueryBuilder() + .where("\"integrations\"->'github'->>'id' = :id", { id: id }) + .andWhere('"userHost" IS NULL') + .getOne(); + + if (link == null) { + ctx.throw( + 404, + `@${login}と連携しているMisskeyアカウントはありませんでした...`, + ); + return; + } + + signin( + ctx, + (await Users.findOneBy({ id: link.userId })) as ILocalUser, + true, + ); + } else { + const code = ctx.query.code; + + if (!code || typeof code !== "string") { + ctx.throw(400, "invalid session"); + return; + } + + const { redirect_uri, state } = await new Promise((res, rej) => { + redisClient.get(userToken, async (_, state) => { + res(JSON.parse(state)); + }); + }); + + if (ctx.query.state !== state) { + ctx.throw(400, "invalid session"); + return; + } + + const { accessToken } = await new Promise((res, rej) => + oauth2!.getOAuthAccessToken( + code, + { redirect_uri }, + (err, accessToken, refresh, result) => { + if (err) { + rej(err); + } else if (result.error) { + rej(result.error); + } else { + res({ accessToken }); + } + }, + ), + ); + + const { login, id } = (await getJson( + "https://api.github.com/user", + "application/vnd.github.v3+json", + 10 * 1000, + { + Authorization: `bearer ${accessToken}`, + }, + )) as Record; + + if (typeof login !== "string" || typeof id !== "string") { + ctx.throw(400, "invalid session"); + return; + } + + const user = await Users.findOneByOrFail({ + host: IsNull(), + token: userToken, + }); + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + await UserProfiles.update(user.id, { + integrations: { + ...profile.integrations, + github: { + accessToken: accessToken, + id: id, + login: login, + }, + }, + }); + + ctx.body = `GitHub: @${login} を、Misskey: @${user.username} に接続しました!`; + + // Publish i updated event + publishMainStream( + user.id, + "meUpdated", + await Users.pack(user, user, { + detail: true, + includeSecrets: true, + }), + ); + } +}); + +export default router; diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts new file mode 100644 index 0000000..2c471f6 --- /dev/null +++ b/packages/backend/src/server/api/stream/channel.ts @@ -0,0 +1,103 @@ +import type Connection from "."; +import type { Note } from "@/models/entities/note.js"; +import { Notes } from "@/models/index.js"; +import type { Packed } from "@/misc/schema.js"; +import { IdentifiableError } from "@/misc/identifiable-error.js"; + +/** + * Stream channel + */ +export default abstract class Channel { + protected connection: Connection; + public id: string; + public abstract readonly chName: string; + public static readonly shouldShare: boolean; + public static readonly requireCredential: boolean; + + protected get user() { + return this.connection.user; + } + + protected get userProfile() { + return this.connection.userProfile; + } + + protected get following() { + return this.connection.following; + } + + protected get muting() { + return this.connection.muting; + } + + protected get renoteMuting() { + return this.connection.renoteMuting; + } + + protected get blocking() { + return this.connection.blocking; + } + + protected get hidden() { + return this.connection.hidden; + } + + protected get followingChannels() { + return this.connection.followingChannels; + } + + protected get subscriber() { + return this.connection.subscriber; + } + + constructor(id: string, connection: Connection) { + this.id = id; + this.connection = connection; + } + + public send(typeOrPayload: any, payload?: any) { + const type = payload === undefined ? typeOrPayload.type : typeOrPayload; + const body = payload === undefined ? typeOrPayload.body : payload; + + this.connection.sendMessageToWs("channel", { + id: this.id, + type: type, + body: body, + }); + } + + protected withPackedNote( + callback: (note: Packed<"Note">) => void, + ): (Note) => void { + return async (note: Note) => { + try { + // because `note` was previously JSON.stringify'ed, the fields that + // were objects before are now strings and have to be restored or + // removed from the object + note.createdAt = new Date(note.createdAt); + note.reply = undefined; + note.renote = undefined; + note.user = undefined; + note.channel = undefined; + + const packed = await Notes.pack(note, this.user, { detail: true }); + + callback(packed); + } catch (err) { + if ( + err instanceof IdentifiableError && + err.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24" + ) { + // skip: note not visible to user + return; + } else { + throw err; + } + } + }; + } + + public abstract init(params: any): void; + public dispose?(): void; + public onMessage?(type: string, body: any): void; +} diff --git a/packages/backend/src/server/api/stream/channels/admin.ts b/packages/backend/src/server/api/stream/channels/admin.ts new file mode 100644 index 0000000..59ae228 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/admin.ts @@ -0,0 +1,14 @@ +import Channel from "../channel.js"; + +export default class extends Channel { + public readonly chName = "admin"; + public static shouldShare = true; + public static requireCredential = true; + + public async init(params: any) { + // Subscribe admin stream + this.subscriber.on(`adminStream:${this.user!.id}`, (data) => { + this.send(data); + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts new file mode 100644 index 0000000..ec5a8b1 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -0,0 +1,63 @@ +import Channel from "../channel.js"; +import { Notes } from "@/models/index.js"; +import { isUserRelated } from "@/misc/is-user-related.js"; +import type { StreamMessages } from "../types.js"; +import { IdentifiableError } from "@/misc/identifiable-error.js"; + +export default class extends Channel { + public readonly chName = "antenna"; + public static shouldShare = false; + public static requireCredential = false; + private antennaId: string; + + constructor(id: string, connection: Channel["connection"]) { + super(id, connection); + this.onEvent = this.onEvent.bind(this); + } + + public async init(params: any) { + this.antennaId = params.antennaId as string; + + // Subscribe stream + this.subscriber.on(`antennaStream:${this.antennaId}`, this.onEvent); + } + + private async onEvent(data: StreamMessages["antenna"]["payload"]) { + if (data.type === "note") { + try { + const note = await Notes.pack(data.body.id, this.user, { + detail: true, + }); + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.blocking)) return; + + if (note.renote && !note.text && this.renoteMuting.has(note.userId)) + return; + + this.connection.cacheNote(note); + + this.send("note", note); + } catch (e) { + if ( + e instanceof IdentifiableError && + e.id === "9725d0ce-ba28-4dde-95a7-2cbb2c15de24" + ) { + // skip: note not visible to user + return; + } else { + throw e; + } + } + } else { + this.send(data.type, data.body); + } + } + + public dispose() { + // Unsubscribe events + this.subscriber.off(`antennaStream:${this.antennaId}`, this.onEvent); + } +} diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts new file mode 100644 index 0000000..2ff4e08 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -0,0 +1,84 @@ +import Channel from "../channel.js"; +import { Users } from "@/models/index.js"; +import { isUserRelated } from "@/misc/is-user-related.js"; +import type { User } from "@/models/entities/user.js"; +import type { StreamMessages } from "../types.js"; +import type { Packed } from "@/misc/schema.js"; + +export default class extends Channel { + public readonly chName = "channel"; + public static shouldShare = false; + public static requireCredential = false; + private channelId: string; + private typers: Map = new Map(); + private emitTypersIntervalId: ReturnType; + + constructor(id: string, connection: Channel["connection"]) { + super(id, connection); + this.onNote = this.withPackedNote(this.onNote.bind(this)); + this.emitTypers = this.emitTypers.bind(this); + } + + public async init(params: any) { + this.channelId = params.channelId as string; + + // Subscribe stream + this.subscriber.on("notesStream", this.onNote); + this.subscriber.on(`channelStream:${this.channelId}`, this.onEvent); + this.emitTypersIntervalId = setInterval(this.emitTypers, 5000); + } + + private async onNote(note: Packed<"Note">) { + if (note.visibility === "hidden") return; + if (note.channelId !== this.channelId) return; + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.blocking)) return; + + if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; + + this.connection.cacheNote(note); + + this.send("note", note); + } + + private onEvent(data: StreamMessages["channel"]["payload"]) { + if (data.type === "typing") { + const id = data.body; + const begin = !this.typers.has(id); + this.typers.set(id, new Date()); + if (begin) { + this.emitTypers(); + } + } + } + + private async emitTypers() { + const now = new Date(); + + // Remove not typing users + for (const [userId, date] of Object.entries(this.typers)) { + if (now.getTime() - date.getTime() > 5000) this.typers.delete(userId); + } + + const userIds = Array.from(this.typers.keys()); + const users = await Users.packMany(userIds, null, { + detail: false, + }); + + this.send({ + type: "typers", + body: users, + }); + } + + public dispose() { + // Unsubscribe events + this.subscriber.off("notesStream", this.onNote); + this.subscriber.off(`channelStream:${this.channelId}`, this.onEvent); + + clearInterval(this.emitTypersIntervalId); + } +} diff --git a/packages/backend/src/server/api/stream/channels/drive.ts b/packages/backend/src/server/api/stream/channels/drive.ts new file mode 100644 index 0000000..275730e --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/drive.ts @@ -0,0 +1,14 @@ +import Channel from "../channel.js"; + +export default class extends Channel { + public readonly chName = "drive"; + public static shouldShare = true; + public static requireCredential = true; + + public async init(params: any) { + // Subscribe drive stream + this.subscriber.on(`driveStream:${this.user!.id}`, (data) => { + this.send(data); + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts new file mode 100644 index 0000000..3a5fed7 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -0,0 +1,85 @@ +import Channel from "../channel.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { isInstanceMuted } from "@/misc/is-instance-muted.js"; +import { isUserRelated } from "@/misc/is-user-related.js"; +import type { Packed } from "@/misc/schema.js"; +import { isFiltered } from "@/misc/is-filtered.js"; +import { Note } from "@/models/entities/note.js"; + +export default class extends Channel { + public readonly chName = "globalTimeline"; + public static shouldShare = true; + public static requireCredential = false; + private withReplies: boolean; + + constructor(id: string, connection: Channel["connection"]) { + super(id, connection); + this.onNote = this.withPackedNote(this.onNote.bind(this)); + } + + public async init(params: any) { + const meta = await fetchMeta(); + if (meta.disableGlobalTimeline) { + if (this.user == null || !(this.user.isAdmin || this.user.isModerator)) + return; + } + + this.withReplies = params.withReplies as boolean; + + // Subscribe events + this.subscriber.on("notesStream", this.onNote); + } + + private async onNote(note: Packed<"Note">) { + if (note.visibility !== "public") return; + if (note.channelId != null) return; + + // 関係ない返信は除外 + if (note.reply && !this.withReplies) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if ( + reply.userId !== this.user!.id && + note.userId !== this.user!.id && + reply.userId !== note.userId + ) + return; + } + + // Ignore notes from instances the user has muted + if ( + isInstanceMuted( + note, + new Set(this.userProfile?.mutedInstances ?? []), + ) + ) + return; + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.blocking)) return; + + if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる + if ( + this.userProfile && + (await isFiltered(note as unknown as Note, this.user, this.userProfile)) + ) + return; + + this.connection.cacheNote(note); + + this.send("note", note); + } + + public dispose() { + // Unsubscribe events + this.subscriber.off("notesStream", this.onNote); + } +} diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts new file mode 100644 index 0000000..40e6005 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -0,0 +1,86 @@ +import Channel from "../channel.js"; +import { isUserRelated } from "@/misc/is-user-related.js"; +import { isInstanceMuted } from "@/misc/is-instance-muted.js"; +import type { Packed } from "@/misc/schema.js"; +import { isFiltered } from "@/misc/is-filtered.js"; +import { Note } from "@/models/entities/note.js"; + +export default class extends Channel { + public readonly chName = "homeTimeline"; + public static shouldShare = true; + public static requireCredential = true; + private withReplies: boolean; + + constructor(id: string, connection: Channel["connection"]) { + super(id, connection); + this.onNote = this.withPackedNote(this.onNote.bind(this)); + } + + public async init(params: any) { + this.withReplies = params.withReplies as boolean; + + // Subscribe events + this.subscriber.on("notesStream", this.onNote); + } + + private async onNote(note: Packed<"Note">) { + if (note.visibility === "hidden") return; + if (note.channelId) { + if (!this.followingChannels.has(note.channelId)) return; + } else { + // その投稿のユーザーをフォローしていなかったら弾く + if (this.user!.id !== note.userId && !this.following.has(note.userId)) + return; + } + + // Ignore notes from instances the user has muted + if ( + isInstanceMuted( + note, + new Set(this.userProfile?.mutedInstances ?? []), + ) + ) + return; + + // 関係ない返信は除外 + if (note.reply && !this.withReplies) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if ( + reply.userId !== this.user!.id && + note.userId !== this.user!.id && + reply.userId !== note.userId + ) + return; + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.blocking)) return; + // Members of lists with hideFromHome set + if (note.userId !== this.user!.id && isUserRelated(note, this.hidden)) return; + + if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる + if ( + this.userProfile && + (await isFiltered(note as unknown as Note, this.user, this.userProfile)) + ) + return; + + this.connection.cacheNote(note); + + this.send("note", note); + } + + public dispose() { + // Unsubscribe events + this.subscriber.off("notesStream", this.onNote); + } +} diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts new file mode 100644 index 0000000..fff2a03 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -0,0 +1,103 @@ +import Channel from "../channel.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { isUserRelated } from "@/misc/is-user-related.js"; +import { isInstanceMuted } from "@/misc/is-instance-muted.js"; +import type { Packed } from "@/misc/schema.js"; +import { isFiltered } from "@/misc/is-filtered.js"; +import { Note } from "@/models/entities/note.js"; + +export default class extends Channel { + public readonly chName = "hybridTimeline"; + public static shouldShare = true; + public static requireCredential = true; + private withReplies: boolean; + + constructor(id: string, connection: Channel["connection"]) { + super(id, connection); + this.onNote = this.withPackedNote(this.onNote.bind(this)); + } + + public async init(params: any) { + const meta = await fetchMeta(); + if ( + meta.disableLocalTimeline && + !this.user!.isAdmin && + !this.user!.isModerator + ) + return; + + this.withReplies = params.withReplies as boolean; + + // Subscribe events + this.subscriber.on("notesStream", this.onNote); + } + + private async onNote(note: Packed<"Note">) { + if (note.visibility === "hidden") return; + // チャンネルの投稿ではなく、自分自身の投稿 または + // チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または + // チャンネルの投稿ではなく、全体公開のローカルの投稿 または + // フォローしているチャンネルの投稿 の場合だけ + if ( + !( + (note.channelId == null && this.user!.id === note.userId) || + (note.channelId == null && this.following.has(note.userId)) || + (note.channelId == null && + note.user.host == null && + note.visibility === "public") || + (note.channelId != null && this.followingChannels.has(note.channelId)) + ) + ) + return; + + // Ignore notes from instances the user has muted + if ( + isInstanceMuted( + note, + new Set(this.userProfile?.mutedInstances ?? []), + ) + ) + return; + + // 関係ない返信は除外 + if (note.reply && !this.withReplies) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if ( + reply.userId !== this.user!.id && + note.userId !== this.user!.id && + reply.userId !== note.userId + ) + return; + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.blocking)) return; + // Members of lists with hideFromHome set + if (note.userId !== this.user!.id && isUserRelated(note, this.hidden)) return; + + if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる + if ( + this.userProfile && + (await isFiltered(note as unknown as Note, this.user, this.userProfile)) + ) + return; + + this.connection.cacheNote(note); + + this.send("note", note); + } + + public dispose() { + // Unsubscribe events + this.subscriber.off("notesStream", this.onNote); + } +} diff --git a/packages/backend/src/server/api/stream/channels/index.ts b/packages/backend/src/server/api/stream/channels/index.ts new file mode 100644 index 0000000..57315a2 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/index.ts @@ -0,0 +1,41 @@ +import main from "./main.js"; +import homeTimeline from "./home-timeline.js"; +import localTimeline from "./local-timeline.js"; +import hybridTimeline from "./hybrid-timeline.js"; +import recommendedTimeline from "./recommended-timeline.js"; +import globalTimeline from "./global-timeline.js"; +import serverStats from "./server-stats.js"; +import queueStats from "./queue-stats.js"; +import userList from "./user-list.js"; +import antenna from "./antenna.js"; +import messaging from "./messaging.js"; +import messagingIndex from "./messaging-index.js"; +import drive from "./drive.js"; +import channel from "./channel.js"; +import admin from "./admin.js"; +import reversi from "./reversi.js"; +import reversiGame from "./reversi-game.js"; +import shogi from "./shogi.js"; +import shogiGame from "./shogi-game.js"; + +export default { + main, + homeTimeline, + localTimeline, + recommendedTimeline, + hybridTimeline, + globalTimeline, + serverStats, + queueStats, + userList, + antenna, + messaging, + messagingIndex, + drive, + channel, + admin, + reversi, + reversiGame, + shogi, + shogiGame, +}; diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts new file mode 100644 index 0000000..39f59d4 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -0,0 +1,77 @@ +import Channel from "../channel.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { isUserRelated } from "@/misc/is-user-related.js"; +import type { Packed } from "@/misc/schema.js"; +import { isFiltered } from "@/misc/is-filtered.js"; +import { Note } from "@/models/entities/note.js"; + +export default class extends Channel { + public readonly chName = "localTimeline"; + public static shouldShare = true; + public static requireCredential = false; + private withReplies: boolean; + + constructor(id: string, connection: Channel["connection"]) { + super(id, connection); + this.onNote = this.withPackedNote(this.onNote.bind(this)); + } + + public async init(params: any) { + const meta = await fetchMeta(); + if (meta.disableLocalTimeline) { + if (this.user == null || !(this.user.isAdmin || this.user.isModerator)) + return; + } + + this.withReplies = params.withReplies as boolean; + + // Subscribe events + this.subscriber.on("notesStream", this.onNote); + } + + private async onNote(note: Packed<"Note">) { + if (note.user.host !== null) return; + if (note.visibility !== "public") return; + if (note.channelId != null && !this.followingChannels.has(note.channelId)) + return; + + // 関係ない返信は除外 + if (note.reply && !this.withReplies) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if ( + reply.userId !== this.user!.id && + note.userId !== this.user!.id && + reply.userId !== note.userId + ) + return; + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.blocking)) return; + + if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる + if ( + this.userProfile && + (await isFiltered(note as unknown as Note, this.user, this.userProfile)) + ) + return; + + this.connection.cacheNote(note); + + this.send("note", note); + } + + public dispose() { + // Unsubscribe events + this.subscriber.off("notesStream", this.onNote); + } +} diff --git a/packages/backend/src/server/api/stream/channels/main.ts b/packages/backend/src/server/api/stream/channels/main.ts new file mode 100644 index 0000000..b8c7244 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/main.ts @@ -0,0 +1,46 @@ +import Channel from "../channel.js"; +import { + isInstanceMuted, + isUserFromMutedInstance, +} from "@/misc/is-instance-muted.js"; + +export default class extends Channel { + public readonly chName = "main"; + public static shouldShare = true; + public static requireCredential = true; + + public async init(params: any) { + // Subscribe main stream channel + this.subscriber.on(`mainStream:${this.user!.id}`, async (data) => { + switch (data.type) { + case "notification": { + // Ignore notifications from instances the user has muted + if ( + isUserFromMutedInstance( + data.body, + new Set(this.userProfile?.mutedInstances ?? []), + ) + ) + return; + if (data.body.userId && this.muting.has(data.body.userId)) return; + + break; + } + case "mention": { + if ( + isInstanceMuted( + data.body, + new Set(this.userProfile?.mutedInstances ?? []), + ) + ) + return; + + if (this.muting.has(data.body.userId)) return; + break; + } + } + + this.send(data.type, data.body); + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/messaging-index.ts b/packages/backend/src/server/api/stream/channels/messaging-index.ts new file mode 100644 index 0000000..8165172 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/messaging-index.ts @@ -0,0 +1,14 @@ +import Channel from "../channel.js"; + +export default class extends Channel { + public readonly chName = "messagingIndex"; + public static shouldShare = true; + public static requireCredential = true; + + public async init(params: any) { + // Subscribe messaging index stream + this.subscriber.on(`messagingIndexStream:${this.user!.id}`, (data) => { + this.send(data); + }); + } +} diff --git a/packages/backend/src/server/api/stream/channels/messaging.ts b/packages/backend/src/server/api/stream/channels/messaging.ts new file mode 100644 index 0000000..0622bd4 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/messaging.ts @@ -0,0 +1,130 @@ +import { + readUserMessagingMessage, + readGroupMessagingMessage, + deliverReadActivity, +} from "../../common/read-messaging-message.js"; +import Channel from "../channel.js"; +import { UserGroupJoinings, Users, MessagingMessages } from "@/models/index.js"; +import type { User, ILocalUser, IRemoteUser } from "@/models/entities/user.js"; +import type { UserGroup } from "@/models/entities/user-group.js"; +import type { StreamMessages } from "../types.js"; + +export default class extends Channel { + public readonly chName = "messaging"; + public static shouldShare = false; + public static requireCredential = true; + + private otherpartyId: string | null; + private otherparty: User | null; + private groupId: string | null; + private subCh: + | `messagingStream:${User["id"]}-${User["id"]}` + | `messagingStream:${UserGroup["id"]}`; + private typers: Map = new Map(); + private emitTypersIntervalId: ReturnType; + + constructor(id: string, connection: Channel["connection"]) { + super(id, connection); + this.onEvent = this.onEvent.bind(this); + this.onMessage = this.onMessage.bind(this); + this.emitTypers = this.emitTypers.bind(this); + } + + public async init(params: any) { + this.otherpartyId = params.otherparty; + this.otherparty = this.otherpartyId + ? await Users.findOneByOrFail({ id: this.otherpartyId }) + : null; + this.groupId = params.group; + + // Check joining + if (this.groupId) { + const joining = await UserGroupJoinings.findOneBy({ + userId: this.user!.id, + userGroupId: this.groupId, + }); + + if (joining == null) { + return; + } + } + + this.emitTypersIntervalId = setInterval(this.emitTypers, 5000); + + this.subCh = this.otherpartyId + ? `messagingStream:${this.user!.id}-${this.otherpartyId}` + : `messagingStream:${this.groupId}`; + + // Subscribe messaging stream + this.subscriber.on(this.subCh, this.onEvent); + } + + private onEvent( + data: + | StreamMessages["messaging"]["payload"] + | StreamMessages["groupMessaging"]["payload"], + ) { + if (data.type === "typing") { + const id = data.body; + const begin = !this.typers.has(id); + this.typers.set(id, new Date()); + if (begin) { + this.emitTypers(); + } + } else { + this.send(data); + } + } + + public onMessage(type: string, body: any) { + switch (type) { + case "read": + if (this.otherpartyId) { + readUserMessagingMessage(this.user!.id, this.otherpartyId, [body.id]); + + // リモートユーザーからのメッセージだったら既読配信 + if ( + Users.isLocalUser(this.user!) && + Users.isRemoteUser(this.otherparty!) + ) { + MessagingMessages.findOneBy({ id: body.id }).then((message) => { + if (message) + deliverReadActivity( + this.user as ILocalUser, + this.otherparty as IRemoteUser, + message, + ); + }); + } + } else if (this.groupId) { + readGroupMessagingMessage(this.user!.id, this.groupId, [body.id]); + } + break; + } + } + + private async emitTypers() { + const now = new Date(); + + // Remove not typing users + for (const [userId, date] of this.typers.entries()) { + if (now.getTime() - date.getTime() > 5000) this.typers.delete(userId); + } + + const userIds = Array.from(this.typers.keys()); + const users = await Users.packMany(userIds, null, { + detail: false, + }); + + this.send({ + type: "typers", + body: users, + }); + } + + public dispose() { + this.subscriber.off(this.subCh, this.onEvent); + + clearInterval(this.emitTypersIntervalId); + } +} diff --git a/packages/backend/src/server/api/stream/channels/queue-stats.ts b/packages/backend/src/server/api/stream/channels/queue-stats.ts new file mode 100644 index 0000000..a5a93c3 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/queue-stats.ts @@ -0,0 +1,42 @@ +import Xev from "xev"; +import Channel from "../channel.js"; + +const ev = new Xev(); + +export default class extends Channel { + public readonly chName = "queueStats"; + public static shouldShare = true; + public static requireCredential = false; + + constructor(id: string, connection: Channel["connection"]) { + super(id, connection); + this.onStats = this.onStats.bind(this); + this.onMessage = this.onMessage.bind(this); + } + + public async init(params: any) { + ev.addListener("queueStats", this.onStats); + } + + private onStats(stats: any) { + this.send("stats", stats); + } + + public onMessage(type: string, body: any) { + switch (type) { + case "requestLog": + ev.once(`queueStatsLog:${body.id}`, (statsLog) => { + this.send("statsLog", statsLog); + }); + ev.emit("requestQueueStatsLog", { + id: body.id, + length: body.length, + }); + break; + } + } + + public dispose() { + ev.removeListener("queueStats", this.onStats); + } +} diff --git a/packages/backend/src/server/api/stream/channels/recommended-timeline.ts b/packages/backend/src/server/api/stream/channels/recommended-timeline.ts new file mode 100644 index 0000000..655bd6e --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/recommended-timeline.ts @@ -0,0 +1,99 @@ +import Channel from "../channel.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { isUserRelated } from "@/misc/is-user-related.js"; +import { isInstanceMuted } from "@/misc/is-instance-muted.js"; +import type { Packed } from "@/misc/schema.js"; +import { isFiltered } from "@/misc/is-filtered.js"; +import { Note } from "@/models/entities/note.js"; + +export default class extends Channel { + public readonly chName = "recommendedTimeline"; + public static shouldShare = true; + public static requireCredential = true; + private withReplies: boolean; + + constructor(id: string, connection: Channel["connection"]) { + super(id, connection); + this.onNote = this.withPackedNote(this.onNote.bind(this)); + } + + public async init(params: any) { + const meta = await fetchMeta(); + if ( + meta.disableRecommendedTimeline && + !this.user!.isAdmin && + !this.user!.isModerator + ) + return; + + this.withReplies = params.withReplies as boolean; + + // Subscribe events + this.subscriber.on("notesStream", this.onNote); + } + + private async onNote(note: Packed<"Note">) { + if (note.visibility === "hidden") return; + // チャンネルの投稿ではなく、自分自身の投稿 または + // チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または + // チャンネルの投稿ではなく、全体公開のローカルの投稿 または + // フォローしているチャンネルの投稿 の場合だけ + const meta = await fetchMeta(); + if ( + !( + note.user.host != null && + meta.recommendedInstances.includes(note.user.host) && + note.visibility === "public" + ) + ) + return; + + // Ignore notes from instances the user has muted + if ( + isInstanceMuted( + note, + new Set(this.userProfile?.mutedInstances ?? []), + ) + ) + return; + + // 関係ない返信は除外 + if (note.reply && !this.withReplies) { + const reply = note.reply; + // 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合 + if ( + reply.userId !== this.user!.id && + note.userId !== this.user!.id && + reply.userId !== note.userId + ) + return; + } + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.blocking)) return; + + if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; + + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる + if ( + this.userProfile && + (await isFiltered(note as unknown as Note, this.user, this.userProfile)) + ) + return; + + this.connection.cacheNote(note); + + this.send("note", note); + } + + public dispose() { + // Unsubscribe events + this.subscriber.off("notesStream", this.onNote); + } +} diff --git a/packages/backend/src/server/api/stream/channels/reversi-game.ts b/packages/backend/src/server/api/stream/channels/reversi-game.ts new file mode 100644 index 0000000..bb5f018 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts @@ -0,0 +1,69 @@ +import Channel from "../channel.js"; +import { + cancelGame, + checkTimeout, + gameReady, + isValidUpdateKey, + isValidUpdateValue, + putStone, + updateSettings, +} from "@/services/reversi/index.js"; +import type { StreamMessages } from "../types.js"; + +export default class extends Channel { + public readonly chName = "reversiGame"; + public static shouldShare = false; + public static requireCredential = false; + private gameId: string | null = null; + + constructor(id: string, connection: Channel["connection"]) { + super(id, connection); + this.onEvent = this.onEvent.bind(this); + } + + public async init(params: any) { + if (typeof params.gameId !== "string") return; + this.gameId = params.gameId; + this.subscriber.on(`reversiGameStream:${this.gameId}`, this.onEvent); + } + + private onEvent(data: StreamMessages["reversiGame"]["payload"]) { + this.send(data); + } + + public onMessage(type: string, body: any) { + if (!this.gameId) return; + switch (type) { + case "ready": + if (this.user && typeof body === "boolean") gameReady(this.gameId, this.user, body); + break; + case "updateSettings": + if ( + this.user && + body && + isValidUpdateKey(body.key) && + isValidUpdateValue(body.key, body.value) + ) { + updateSettings(this.gameId, this.user, body.key, body.value); + } + break; + case "cancel": + if (this.user) cancelGame(this.gameId, this.user); + break; + case "putStone": + if (this.user && body && typeof body.pos === "number") { + putStone(this.gameId, this.user, body.pos, typeof body.id === "string" ? body.id : null); + } + break; + case "claimTimeIsUp": + checkTimeout(this.gameId); + break; + } + } + + public dispose() { + if (this.gameId) { + this.subscriber.off(`reversiGameStream:${this.gameId}`, this.onEvent); + } + } +} diff --git a/packages/backend/src/server/api/stream/channels/reversi.ts b/packages/backend/src/server/api/stream/channels/reversi.ts new file mode 100644 index 0000000..7e1c26d --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/reversi.ts @@ -0,0 +1,25 @@ +import Channel from "../channel.js"; +import type { StreamMessages } from "../types.js"; + +export default class extends Channel { + public readonly chName = "reversi"; + public static shouldShare = true; + public static requireCredential = true; + + constructor(id: string, connection: Channel["connection"]) { + super(id, connection); + this.onEvent = this.onEvent.bind(this); + } + + public async init(params: any) { + this.subscriber.on(`reversiStream:${this.user!.id}`, this.onEvent); + } + + private onEvent(data: StreamMessages["reversi"]["payload"]) { + this.send(data); + } + + public dispose() { + this.subscriber.off(`reversiStream:${this.user!.id}`, this.onEvent); + } +} diff --git a/packages/backend/src/server/api/stream/channels/server-stats.ts b/packages/backend/src/server/api/stream/channels/server-stats.ts new file mode 100644 index 0000000..5865913 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/server-stats.ts @@ -0,0 +1,42 @@ +import Xev from "xev"; +import Channel from "../channel.js"; + +const ev = new Xev(); + +export default class extends Channel { + public readonly chName = "serverStats"; + public static shouldShare = true; + public static requireCredential = false; + + constructor(id: string, connection: Channel["connection"]) { + super(id, connection); + this.onStats = this.onStats.bind(this); + this.onMessage = this.onMessage.bind(this); + } + + public async init(params: any) { + ev.addListener("serverStats", this.onStats); + } + + private onStats(stats: any) { + this.send("stats", stats); + } + + public onMessage(type: string, body: any) { + switch (type) { + case "requestLog": + ev.once(`serverStatsLog:${body.id}`, (statsLog) => { + this.send("statsLog", statsLog); + }); + ev.emit("requestServerStatsLog", { + id: body.id, + length: body.length, + }); + break; + } + } + + public dispose() { + ev.removeListener("serverStats", this.onStats); + } +} diff --git a/packages/backend/src/server/api/stream/channels/shogi-game.ts b/packages/backend/src/server/api/stream/channels/shogi-game.ts new file mode 100644 index 0000000..ed3a750 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/shogi-game.ts @@ -0,0 +1,52 @@ +import Channel from "../channel.js"; +import { + cancelGame, + gameReady, + putMove, +} from "@/services/shogi/index.js"; +import type { StreamMessages } from "../types.js"; + +export default class extends Channel { + public readonly chName = "shogiGame"; + public static shouldShare = false; + public static requireCredential = false; + private gameId: string | null = null; + + constructor(id: string, connection: Channel["connection"]) { + super(id, connection); + this.onEvent = this.onEvent.bind(this); + } + + public async init(params: any) { + if (typeof params.gameId !== "string") return; + this.gameId = params.gameId; + this.subscriber.on(`shogiGameStream:${this.gameId}`, this.onEvent); + } + + private onEvent(data: StreamMessages["shogiGame"]["payload"]) { + this.send(data); + } + + public onMessage(type: string, body: any) { + if (!this.gameId) return; + switch (type) { + case "ready": + if (this.user && typeof body === "boolean") gameReady(this.gameId, this.user, body); + break; + case "cancel": + if (this.user) cancelGame(this.gameId, this.user); + break; + case "move": + if (this.user && body && typeof body.usi === "string") { + putMove(this.gameId, this.user, body.usi, typeof body.id === "string" ? body.id : null); + } + break; + } + } + + public dispose() { + if (this.gameId) { + this.subscriber.off(`shogiGameStream:${this.gameId}`, this.onEvent); + } + } +} diff --git a/packages/backend/src/server/api/stream/channels/shogi.ts b/packages/backend/src/server/api/stream/channels/shogi.ts new file mode 100644 index 0000000..58bb8e5 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/shogi.ts @@ -0,0 +1,25 @@ +import Channel from "../channel.js"; +import type { StreamMessages } from "../types.js"; + +export default class extends Channel { + public readonly chName = "shogi"; + public static shouldShare = true; + public static requireCredential = true; + + constructor(id: string, connection: Channel["connection"]) { + super(id, connection); + this.onEvent = this.onEvent.bind(this); + } + + public async init(params: any) { + this.subscriber.on(`shogiStream:${this.user!.id}`, this.onEvent); + } + + private onEvent(data: StreamMessages["shogi"]["payload"]) { + this.send(data); + } + + public dispose() { + this.subscriber.off(`shogiStream:${this.user!.id}`, this.onEvent); + } +} diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts new file mode 100644 index 0000000..d140319 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -0,0 +1,74 @@ +import Channel from "../channel.js"; +import { UserListJoinings, UserLists } from "@/models/index.js"; +import type { User } from "@/models/entities/user.js"; +import { isUserRelated } from "@/misc/is-user-related.js"; +import type { Packed } from "@/misc/schema.js"; + +export default class extends Channel { + public readonly chName = "userList"; + public static shouldShare = false; + public static requireCredential = false; + private listId: string; + public listUsers: User["id"][] = []; + private listUsersClock: NodeJS.Timer; + + constructor(id: string, connection: Channel["connection"]) { + super(id, connection); + this.updateListUsers = this.updateListUsers.bind(this); + this.onNote = this.withPackedNote(this.onNote.bind(this)); + } + + public async init(params: any) { + this.listId = params.listId as string; + + // Check existence and owner + const exist = await UserLists.exist({ + where: { + id: this.listId, + userId: this.user!.id, + }, + }); + if (!exist) return; + + // Subscribe stream + this.subscriber.on(`userListStream:${this.listId}`, this.send); + + this.subscriber.on("notesStream", this.onNote); + + this.updateListUsers(); + this.listUsersClock = setInterval(this.updateListUsers, 5000); + } + + private async updateListUsers() { + const users = await UserListJoinings.find({ + where: { + userListId: this.listId, + }, + select: ["userId"], + }); + + this.listUsers = users.map((x) => x.userId); + } + + private async onNote(note: Packed<"Note">) { + if (note.visibility === "hidden") return; + if (!this.listUsers.includes(note.userId)) return; + + // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.muting)) return; + // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する + if (isUserRelated(note, this.blocking)) return; + + if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; + + this.send("note", note); + } + + public dispose() { + // Unsubscribe events + this.subscriber.off(`userListStream:${this.listId}`, this.send); + this.subscriber.off("notesStream", this.onNote); + + clearInterval(this.listUsersClock); + } +} diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts new file mode 100644 index 0000000..15ca255 --- /dev/null +++ b/packages/backend/src/server/api/stream/index.ts @@ -0,0 +1,591 @@ +import type { EventEmitter } from "events"; +import type * as websocket from "websocket"; +import readNote from "@/services/note/read.js"; +import type { User } from "@/models/entities/user.js"; +import type { Channel as ChannelModel } from "@/models/entities/channel.js"; +import { + Users, + Followings, + Mutings, + RenoteMutings, + UserProfiles, + ChannelFollowings, + Blockings, CallBlockings, UserListJoinings, UserGroupJoinings, UserGroups, +} from "@/models/index.js"; +import type { AccessToken } from "@/models/entities/access-token.js"; +import type { UserProfile } from "@/models/entities/user-profile.js"; +import { + publishMainStream, + publishChannelStream, + publishGroupMessagingStream, + publishMessagingStream, +} from "@/services/stream.js"; +import type { UserGroup } from "@/models/entities/user-group.js"; +import type { Packed } from "@/misc/schema.js"; +import { readNotification } from "../common/read-notification.js"; +import channels from "./channels/index.js"; +import type Channel from "./channel.js"; +import type { StreamEventEmitter, StreamMessages } from "./types.js"; + +/** + * Main stream connection + */ +export default class Connection { + public user?: User; + public userProfile?: UserProfile | null; + public following: Set = new Set(); + public muting: Set = new Set(); + public renoteMuting: Set = new Set(); + public blocking: Set = new Set(); + public hidden: Set = new Set(); + public followingChannels: Set = new Set(); + public token?: AccessToken; + private wsConnection: websocket.connection; + public subscriber: StreamEventEmitter; + private channels: Channel[] = []; + private subscribingNotes: Map = new Map(); + private cachedNotes: Packed<"Note">[] = []; + private host: string; + private accessToken: string; + private currentSubscribe: string[][] = []; + + constructor( + wsConnection: websocket.connection, + subscriber: EventEmitter, + user: User | null | undefined, + token: AccessToken | null | undefined, + host: string, + accessToken: string, + prepareStream: string | undefined, + ) { + this.wsConnection = wsConnection; + this.subscriber = subscriber; + if (user) this.user = user; + if (token) this.token = token; + if (host) this.host = host; + if (accessToken) this.accessToken = accessToken; + + this.onWsConnectionMessage = this.onWsConnectionMessage.bind(this); + this.onUserEvent = this.onUserEvent.bind(this); + this.onNoteStreamMessage = this.onNoteStreamMessage.bind(this); + this.onBroadcastMessage = this.onBroadcastMessage.bind(this); + + this.wsConnection.on("message", this.onWsConnectionMessage); + + this.subscriber.on("broadcast", (data) => { + this.onBroadcastMessage(data); + }); + + if (this.user) { + this.updateFollowing(); + this.updateMuting(); + this.updateRenoteMuting(); + this.updateBlocking(); + this.updateHidden(); + this.updateFollowingChannels(); + this.updateUserProfile(); + + this.subscriber.on(`user:${this.user.id}`, this.onUserEvent); + } + if (prepareStream) { + this.onWsConnectionMessage({ + type: "utf8", + utf8Data: JSON.stringify({ stream: prepareStream, type: "subscribe" }), + }); + } + } + + private onUserEvent(data: StreamMessages["user"]["payload"]) { + // { type, body }と展開するとそれぞれ型が分離してしまう + switch (data.type) { + case "follow": + this.following.add(data.body.id); + break; + + case "unfollow": + this.following.delete(data.body.id); + break; + + case "mute": + this.muting.add(data.body.id); + break; + + case "unmute": + this.muting.delete(data.body.id); + break; + + // TODO: renote mute events + // TODO: block events + + case "followChannel": + this.followingChannels.add(data.body.id); + break; + + case "unfollowChannel": + this.followingChannels.delete(data.body.id); + break; + + case "userHidden": + this.hidden.add(data.body); + break; + + case "userUnhidden": + this.hidden.delete(data.body); + break; + + case "updateUserProfile": + this.userProfile = data.body; + break; + + case "terminate": + this.wsConnection.close(); + this.dispose(); + break; + + default: + break; + } + } + + /** + * クライアントからメッセージ受信時 + */ + private async onWsConnectionMessage(data: websocket.Message) { + if (data.type !== "utf8") return; + if (data.utf8Data == null) return; + + let objs: Record[]; + + try { + objs = [JSON.parse(data.utf8Data)]; + } catch (e) { + return; + } + + for (const obj of objs) { + const { type, body } = obj; + // console.log(type, body); + switch (type) { + case "readNotification": + this.onReadNotification(body); + break; + case "subNote": + this.onSubscribeNote(body); + break; + case "s": + this.onSubscribeNote(body); + break; // alias + case "sr": + this.onSubscribeNote(body); + this.readNote(body); + break; + case "unsubNote": + this.onUnsubscribeNote(body); + break; + case "un": + this.onUnsubscribeNote(body); + break; // alias + case "connect": + this.onChannelConnectRequested(body); + break; + case "disconnect": + this.onChannelDisconnectRequested(body); + break; + case "channel": + this.onChannelMessageRequested(body); + break; + case "ch": + this.onChannelMessageRequested(body); + break; // alias + + // 個々のチャンネルではなくルートレベルでこれらのメッセージを受け取る理由は、 + // クライアントの事情を考慮したとき、入力フォームはノートチャンネルやメッセージのメインコンポーネントとは別 + // なこともあるため、それらのコンポーネントがそれぞれ各チャンネルに接続するようにするのは面倒なため。 + case "typingOnChannel": + this.typingOnChannel(body.channel); + break; + case "typingOnMessaging": + this.typingOnMessaging(body); + break; + case "callSignal": + this.callSignal(body); + break; + } + } + } + + private onBroadcastMessage(data: StreamMessages["broadcast"]["payload"]) { + this.sendMessageToWs(data.type, data.body); + } + + public cacheNote(note: Packed<"Note">) { + const add = (note: Packed<"Note">) => { + const existIndex = this.cachedNotes.findIndex((n) => n.id === note.id); + if (existIndex > -1) { + this.cachedNotes[existIndex] = note; + return; + } + + this.cachedNotes.unshift(note); + if (this.cachedNotes.length > 32) { + this.cachedNotes.splice(32); + } + }; + + add(note); + if (note.reply) add(note.reply); + if (note.renote) add(note.renote); + } + + private readNote(body: any) { + const id = body.id; + + const note = this.cachedNotes.find((n) => n.id === id); + if (note == null) return; + + if (this.user && note.userId !== this.user.id) { + readNote(this.user.id, [note], { + following: this.following, + followingChannels: this.followingChannels, + }); + } + } + + private onReadNotification(payload: any) { + if (!payload.id) return; + readNotification(this.user!.id, [payload.id]); + } + + /** + * 投稿購読要求時 + */ + private onSubscribeNote(payload: any) { + if (!payload.id) return; + + const current = this.subscribingNotes.get(payload.id) || 0; + this.subscribingNotes.set(payload.id, current + 1); + + if (!current) { + this.subscriber.on(`noteStream:${payload.id}`, this.onNoteStreamMessage); + } + } + + /** + * 投稿購読解除要求時 + */ + private onUnsubscribeNote(payload: any) { + if (!payload.id) return; + + const current = this.subscribingNotes.get(payload.id) || 0; + if (current <= 1) { + this.subscribingNotes.delete(payload.id); + this.subscriber.off(`noteStream:${payload.id}`, this.onNoteStreamMessage); + return; + } + this.subscribingNotes.set(payload.id, current - 1); + } + + private async onNoteStreamMessage(data: StreamMessages["note"]["payload"]) { + this.sendMessageToWs("noteUpdated", { + id: data.body.id, + type: data.type, + body: data.body.body, + }); + } + + /** + * チャンネル接続要求時 + */ + private onChannelConnectRequested(payload: any) { + const { channel, id, params, pong } = payload; + this.connectChannel(id, params, channel, pong); + } + + /** + * チャンネル切断要求時 + */ + private onChannelDisconnectRequested(payload: any) { + const { id } = payload; + this.disconnectChannel(id); + } + + /** + * クライアントにメッセージ送信 + */ + public sendMessageToWs(type: string, payload: any) { + this.wsConnection.send( + JSON.stringify({ + type: type, + body: payload, + }), + ); + } + + /** + * チャンネルに接続 + */ + public connectChannel( + id: string, + params: any, + channel: string, + pong = false, + ) { + if ((channels as any)[channel].requireCredential && this.user == null) { + return; + } + + // 共有可能チャンネルに接続しようとしていて、かつそのチャンネルに既に接続していたら無意味なので無視 + if ( + (channels as any)[channel].shouldShare && + this.channels.some((c) => c.chName === channel) + ) { + return; + } + + const ch: Channel = new (channels as any)[channel](id, this); + this.channels.push(ch); + ch.init(params); + + if (pong) { + this.sendMessageToWs("connected", { + id: id, + }); + } + } + + /** + * チャンネルから切断 + * @param id チャンネルコネクションID + */ + public disconnectChannel(id: string) { + const channel = this.channels.find((c) => c.id === id); + + if (channel) { + if (channel.dispose) channel.dispose(); + this.channels = this.channels.filter((c) => c.id !== id); + } + } + + /** + * チャンネルへメッセージ送信要求時 + * @param data メッセージ + */ + private onChannelMessageRequested(data: any) { + const channel = this.channels.find((c) => c.id === data.id); + if (channel?.onMessage != null) { + channel.onMessage(data.type, data.body); + } + } + + private typingOnChannel(channel: ChannelModel["id"]) { + if (this.user) { + publishChannelStream(channel, "typing", this.user.id); + } + } + + private typingOnMessaging(param: { + partner?: User["id"]; + group?: UserGroup["id"]; + }) { + if (this.user) { + if (param.partner) { + publishMessagingStream( + param.partner, + this.user.id, + "typing", + this.user.id, + ); + } else if (param.group) { + publishGroupMessagingStream(param.group, "typing", this.user.id); + } + } + } + + private async callSignal(body: { + to: string; + toUserId?: string; + sessionId: string; + kind: "audio" | "video"; + signal: any; + }) { + if (!this.user || !body.sessionId) return; + + const recipients = new Set(); + let recipientGroupId: string | null = null; + const target = typeof body.to === "string" ? body.to.trim() : ""; + if (body.toUserId) { + const user = await Users.findOneBy({ id: body.toUserId, host: null }); + if (user && user.id !== this.user.id) recipients.add(user.id); + } else if (target === "") { + if (body.signal?.type !== "offer") return; + const users = await Users.createQueryBuilder("user") + .select(["user.id"]) + .where("user.host IS NULL") + .andWhere("user.id != :meId", { meId: this.user.id }) + .andWhere("user.isSuspended = FALSE") + .andWhere("user.isDeleted = FALSE") + .getMany(); + for (const user of users) recipients.add(user.id); + } else if (target.startsWith("@@")) { + const group = await UserGroups.findOneBy({ + username: target.slice(2).toLowerCase(), + allowCalls: true, + }); + if (!group) return; + recipientGroupId = group.id; + const joinings = await UserGroupJoinings.findBy({ userGroupId: group.id }); + if (group.userId !== this.user.id) recipients.add(group.userId); + for (const joining of joinings) { + if (joining.userId !== this.user.id) recipients.add(joining.userId); + } + } else if (target.startsWith("@")) { + const username = target.slice(1).split("@")[0].toLowerCase(); + const user = await Users.findOneBy({ usernameLower: username, host: null }); + const profile = user + ? await UserProfiles.findOneBy({ userId: user.id, allowCalls: true }) + : null; + if (user && profile && user.id !== this.user.id) recipients.add(user.id); + } else { + const group = await UserGroups.findOneBy({ + username: target.toLowerCase(), + allowCalls: true, + }); + if (!group) return; + recipientGroupId = group.id; + const isMember = + group.userId === this.user.id || + (await UserGroupJoinings.exist({ + where: { userGroupId: group.id, userId: this.user.id }, + })); + if (!isMember) return; + const joinings = await UserGroupJoinings.findBy({ userGroupId: group.id }); + if (group.userId !== this.user.id) recipients.add(group.userId); + for (const joining of joinings) { + if (joining.userId !== this.user.id) recipients.add(joining.userId); + } + } + + if (recipientGroupId != null) { + const groupCallBlocked = await CallBlockings.exist({ + where: { + groupId: recipientGroupId, + blockeeId: this.user.id, + }, + }); + if (groupCallBlocked) return; + } + + for (const recipient of recipients) { + if (await this.isCallRecipientBlocked(recipient)) continue; + + publishMainStream(recipient, "callSignal", { + fromUserId: this.user.id, + to: target, + sessionId: body.sessionId, + kind: body.kind, + signal: body.signal, + }); + } + } + + private async isCallRecipientBlocked(recipient: User["id"]): Promise { + const normalBlocked = await Blockings.exist({ + where: [ + { blockerId: recipient, blockeeId: this.user!.id, groupId: null }, + { blockerId: this.user!.id, blockeeId: recipient, groupId: null }, + ], + }); + if (normalBlocked) return true; + + return await CallBlockings.exist({ + where: [ + { blockerId: recipient, blockeeId: this.user!.id, groupId: null }, + { blockerId: this.user!.id, blockeeId: recipient, groupId: null }, + ], + }); + } + + private async updateFollowing() { + const followings = await Followings.find({ + where: { + followerId: this.user!.id, + }, + select: ["followeeId"], + }); + + this.following = new Set(followings.map((x) => x.followeeId)); + } + + private async updateMuting() { + const mutings = await Mutings.find({ + where: { + muterId: this.user!.id, + }, + select: ["muteeId"], + }); + + this.muting = new Set(mutings.map((x) => x.muteeId)); + } + + private async updateRenoteMuting() { + const renoteMutings = await RenoteMutings.find({ + where: { + muterId: this.user!.id, + }, + select: ["muteeId"], + }); + + this.renoteMuting = new Set(renoteMutings.map((x) => x.muteeId)); + } + + private async updateBlocking() { + // ここでいうBlockingは被Blockingの意 + const blockings = await Blockings.find({ + where: { + blockeeId: this.user!.id, + }, + select: ["blockerId"], + }); + + this.blocking = new Set(blockings.map((x) => x.blockerId)); + } + + private async updateHidden() { + const hidden = await UserListJoinings.find({ + where: { + userList: { userId: this.user!.id, hideFromHomeTl: true }, + }, + select: ["userId"], + }); + + this.hidden = new Set(hidden.map((x) => x.userId)); + } + + private async updateFollowingChannels() { + const followings = await ChannelFollowings.find({ + where: { + followerId: this.user!.id, + }, + select: ["followeeId"], + }); + + this.followingChannels = new Set( + followings.map((x) => x.followeeId), + ); + } + + private async updateUserProfile() { + this.userProfile = await UserProfiles.findOneBy({ + userId: this.user!.id, + }); + } + + /** + * ストリームが切れたとき + */ + public dispose() { + for (const c of this.channels.filter((c) => c.dispose)) { + if (c.dispose) c.dispose(); + } + } +} diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts new file mode 100644 index 0000000..fe0725a --- /dev/null +++ b/packages/backend/src/server/api/stream/types.ts @@ -0,0 +1,378 @@ +import type { EventEmitter } from "events"; +import type Emitter from "strict-event-emitter-types"; +import type { Channel } from "@/models/entities/channel.js"; +import type { User } from "@/models/entities/user.js"; +import type { UserProfile } from "@/models/entities/user-profile.js"; +import type { Note } from "@/models/entities/note.js"; +import type { Antenna } from "@/models/entities/antenna.js"; +import type { DriveFile } from "@/models/entities/drive-file.js"; +import type { DriveFolder } from "@/models/entities/drive-folder.js"; +import type { UserList } from "@/models/entities/user-list.js"; +import type { MessagingMessage } from "@/models/entities/messaging-message.js"; +import type { UserGroup } from "@/models/entities/user-group.js"; +import type { AbuseUserReport } from "@/models/entities/abuse-user-report.js"; +import type { Signin } from "@/models/entities/signin.js"; +import type { Page } from "@/models/entities/page.js"; +import type { Packed } from "@/misc/schema.js"; +import type { Webhook } from "@/models/entities/webhook"; +import { Announcement } from "@/models/entities/announcement.js"; + +//#region Stream type-body definitions +export interface InternalStreamTypes { + userChangeSuspendedState: { + id: User["id"]; + isSuspended: User["isSuspended"]; + }; + userChangeSilencedState: { + id: User["id"]; + isSilenced: User["isSilenced"]; + }; + userChangeModeratorState: { + id: User["id"]; + isModerator: User["isModerator"]; + }; + userTokenRegenerated: { + id: User["id"]; + oldToken: User["token"]; + newToken: User["token"]; + }; + localUserUpdated: { + id: User["id"]; + }; + localUserDeleted: { + id: User["id"]; + }; + remoteUserUpdated: { + id: User["id"]; + }; + remoteUserDeleted: { + id: User["id"]; + }; + webhookCreated: Webhook; + webhookDeleted: Webhook; + webhookUpdated: Webhook; + antennaCreated: Antenna; + antennaDeleted: Antenna; + antennaUpdated: Antenna; +} + +export interface BroadcastTypes { + emojiAdded: { + emoji: Packed<"Emoji">; + }; + announcementAdded: Announcement; + announcementDeleted: Announcement["id"]; +} + +export interface UserStreamTypes { + terminate: Record; + followChannel: Channel; + unfollowChannel: Channel; + updateUserProfile: UserProfile; + mute: User; + unmute: User; + follow: Packed<"UserDetailedNotMe">; + unfollow: Packed<"User">; + userAdded: Packed<"User">; + userHidden: User["id"]; + userUnhidden: User["id"]; +} + +export interface MainStreamTypes { + notification: Packed<"Notification">; + mention: Packed<"Note">; + reply: Packed<"Note">; + renote: Packed<"Note">; + follow: Packed<"UserDetailedNotMe">; + followed: Packed<"User">; + unfollow: Packed<"User">; + meUpdated: Packed<"User">; + pageEvent: { + pageId: Page["id"]; + event: string; + var: any; + userId: User["id"]; + user: Packed<"User">; + }; + urlUploadFinished: { + marker?: string | null; + file: Packed<"DriveFile">; + }; + readAllNotifications: undefined; + unreadNotification: Packed<"Notification">; + unreadMention: Note["id"]; + readAllUnreadMentions: undefined; + unreadSpecifiedNote: Note["id"]; + readAllUnreadSpecifiedNotes: undefined; + readAllMessagingMessages: undefined; + messagingMessage: Packed<"MessagingMessage">; + unreadMessagingMessage: Packed<"MessagingMessage">; + readAllAntennas: undefined; + unreadAntenna: Antenna; + readAllAnnouncements: undefined; + readAllChannels: undefined; + unreadChannel: Note["id"]; + myTokenRegenerated: undefined; + signin: Signin; + registryUpdated: { + scope?: string[]; + key: string; + value: any | null; + }; + driveFileCreated: Packed<"DriveFile">; + readAntenna: Antenna; + receiveFollowRequest: Packed<"User">; + callSignal: { + fromUserId: User["id"]; + to: string; + toUserId?: string; + sessionId: string; + kind: "audio" | "video"; + signal: any; + }; +} + +export interface DriveStreamTypes { + fileCreated: Packed<"DriveFile">; + fileDeleted: DriveFile["id"]; + fileUpdated: Packed<"DriveFile">; + folderCreated: Packed<"DriveFolder">; + folderDeleted: DriveFolder["id"]; + folderUpdated: Packed<"DriveFolder">; +} + +export interface NoteStreamTypes { + pollVoted: { + choice: number; + userId: User["id"]; + }; + deleted: { + deletedAt: Date; + }; + reacted: { + reaction: string; + emoji?: { + name: string; + url: string; + } | null; + userId: User["id"]; + }; + unreacted: { + reaction: string; + userId: User["id"]; + }; + replied: { + id: Note["id"]; + }; + updated: { + updatedAt?: Note["updatedAt"]; + }; +} +type NoteStreamEventTypes = { + [key in keyof NoteStreamTypes]: { + id: Note["id"]; + body: NoteStreamTypes[key]; + }; +}; + +export interface NoteUpdatesStreamTypes { + deleted: Note; + updated: Note; +} + +export interface ChannelStreamTypes { + typing: User["id"]; +} + +export interface ReversiStreamTypes { + invited: { user: Packed<"UserLite"> }; + matched: { game: any }; +} + +export interface ReversiGameStreamTypes { + changeReadyStates: { user1: boolean; user2: boolean }; + updateSettings: { userId: User["id"]; key: string; value: any }; + started: { game: any }; + log: { + time: number; + player: boolean; + operation: "put"; + pos: number; + id: string | null; + }; + ended: { winnerId: User["id"] | null; game: any }; + canceled: { userId: User["id"] }; +} + +export interface ShogiStreamTypes { + invited: { user: Packed<"UserLite"> }; + matched: { game: any }; +} + +export interface ShogiGameStreamTypes { + changeReadyStates: { user1: boolean; user2: boolean }; + started: { game: any }; + log: { + id: string | null; + at: number; + userId: User["id"]; + usi: string; + sfen: string; + }; + ended: { winnerId: User["id"] | null; game: any }; + canceled: { userId: User["id"] }; +} + +export interface UserListStreamTypes { + userAdded: Packed<"User">; + userRemoved: Packed<"User">; +} + +export interface AntennaStreamTypes { + note: Note; +} + +export interface MessagingStreamTypes { + read: MessagingMessage["id"][]; + typing: User["id"]; + message: Packed<"MessagingMessage">; + deleted: MessagingMessage["id"]; +} + +export interface GroupMessagingStreamTypes { + read: { + ids: MessagingMessage["id"][]; + userId: User["id"]; + }; + typing: User["id"]; + message: Packed<"MessagingMessage">; + deleted: MessagingMessage["id"]; +} + +export interface MessagingIndexStreamTypes { + read: MessagingMessage["id"][]; + message: Packed<"MessagingMessage">; +} + +export interface AdminStreamTypes { + newAbuseUserReport: { + id: AbuseUserReport["id"]; + targetUserId: User["id"]; + reporterId: User["id"]; + comment: string; + }; +} +//#endregion + +// 辞書(interface or type)から{ type, body }ユニオンを定義 +// https://stackoverflow.com/questions/49311989/can-i-infer-the-type-of-a-value-using-extends-keyof-type +// VS Codeの展開を防止するためにEvents型を定義 +type Events = { [K in keyof T]: { type: K; body: T[K] } }; +type EventUnionFromDictionary> = U[keyof U]; + +// name/messages(spec) pairs dictionary +export type StreamMessages = { + internal: { + name: "internal"; + payload: EventUnionFromDictionary; + }; + broadcast: { + name: "broadcast"; + payload: EventUnionFromDictionary; + }; + user: { + name: `user:${User["id"]}`; + payload: EventUnionFromDictionary; + }; + main: { + name: `mainStream:${User["id"]}`; + payload: EventUnionFromDictionary; + }; + drive: { + name: `driveStream:${User["id"]}`; + payload: EventUnionFromDictionary; + }; + note: { + name: `noteStream:${Note["id"]}`; + payload: EventUnionFromDictionary; + }; + channel: { + name: `channelStream:${Channel["id"]}`; + payload: EventUnionFromDictionary; + }; + reversi: { + name: `reversiStream:${User["id"]}`; + payload: EventUnionFromDictionary; + }; + reversiGame: { + name: `reversiGameStream:${string}`; + payload: EventUnionFromDictionary; + }; + shogi: { + name: `shogiStream:${User["id"]}`; + payload: EventUnionFromDictionary; + }; + shogiGame: { + name: `shogiGameStream:${string}`; + payload: EventUnionFromDictionary; + }; + userList: { + name: `userListStream:${UserList["id"]}`; + payload: EventUnionFromDictionary; + }; + antenna: { + name: `antennaStream:${Antenna["id"]}`; + payload: EventUnionFromDictionary; + }; + messaging: { + name: `messagingStream:${User["id"]}-${User["id"]}`; + payload: EventUnionFromDictionary; + }; + groupMessaging: { + name: `messagingStream:${UserGroup["id"]}`; + payload: EventUnionFromDictionary; + }; + messagingIndex: { + name: `messagingIndexStream:${User["id"]}`; + payload: EventUnionFromDictionary; + }; + admin: { + name: `adminStream:${User["id"]}`; + payload: EventUnionFromDictionary; + }; + notes: { + name: "notesStream"; + payload: Note; + }; + noteUpdates: { + name: "noteUpdatesStream"; + payload: EventUnionFromDictionary; + }; +}; + +// API event definitions +// ストリームごとのEmitterの辞書を用意 +type EventEmitterDictionary = { + [x in keyof StreamMessages]: Emitter< + EventEmitter, + { + [y in StreamMessages[x]["name"]]: ( + e: StreamMessages[x]["payload"], + ) => void; + } + >; +}; +// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( + k: infer I, +) => void + ? I + : never; +// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする +export type StreamEventEmitter = UnionToIntersection< + EventEmitterDictionary[keyof StreamMessages] +>; +// { [y in name]: (e: spec) => void }をまとめてその交差型をEmitterにかけるとts(2590)にひっかかる + +// provide stream channels union +export type StreamChannels = StreamMessages[keyof StreamMessages]["name"]; diff --git a/packages/backend/src/server/api/streaming.ts b/packages/backend/src/server/api/streaming.ts new file mode 100644 index 0000000..d0c1d29 --- /dev/null +++ b/packages/backend/src/server/api/streaming.ts @@ -0,0 +1,106 @@ +import type * as http from "node:http"; +import { EventEmitter } from "events"; +import type { ParsedUrlQuery } from "querystring"; +import * as websocket from "websocket"; + +import { subscriber as redisClient } from "@/db/redis.js"; +import { Users } from "@/models/index.js"; +import MainStreamConnection from "./stream/index.js"; +import authenticate from "./authenticate.js"; +import { apiLogger } from "@/server/api/logger.js"; +import { MastodonStreamingConnection } from "@/server/api/mastodon/streaming/index.js"; +import { AccessToken } from "@/models/entities/access-token.js"; +import { OAuthApp } from "@/models/entities/oauth-app.js"; +import { ILocalUser } from "@/models/entities/user.js"; +import { getTokenFromOAuth } from "@/server/api/mastodon/middleware/auth.js"; +import { OAuthToken } from "@/models/entities/oauth-token.js"; + +export const streamingLogger = apiLogger.createSubLogger("streaming"); + +export const initializeStreamingServer = (server: http.Server) => { + // Init websocket server + const ws = new websocket.server({ + httpServer: server, + }); + + ws.on("request", async (request) => { + const q = request.resourceURL.query as ParsedUrlQuery; + const headers = request.httpRequest.headers["sec-websocket-protocol"] || ""; + const cred = q.i || q.access_token || headers; + const rawAccessToken = cred.toString(); + const accessToken = rawAccessToken.length > 0 ? rawAccessToken : null; + const isMastodon = request.resourceURL.pathname?.startsWith('/api/v1/streaming'); + + let main: MainStreamConnection | MastodonStreamingConnection; + let user: ILocalUser | null | undefined; + let app: AccessToken | null | undefined; + let token: OAuthToken | null | undefined; + + if (!isMastodon) { + [user, app] = await authenticate( + request.httpRequest.headers.authorization, + accessToken, + ).catch((err) => { + request.reject(403, err.message); + return []; + }); + + } else { + token = await getTokenFromOAuth(accessToken); + if (!token || !token.user) { + request.reject(400); + return; + } + + user = token.user as ILocalUser; + } + + if (user?.isSuspended) { + request.reject(400); + return; + } + + const connection = request.accept(request.requestedProtocols[0] ?? undefined); + + const ev = new EventEmitter(); + + async function onRedisMessage(_: string, data: string) { + const parsed = JSON.parse(data); + ev.emit(parsed.channel, parsed.message); + } + + redisClient.on("message", onRedisMessage); + const host = `https://${request.host}`; + const prepareStream = q.stream?.toString(); + + main = isMastodon + ? new MastodonStreamingConnection(connection, ev, user, token, q) + : new MainStreamConnection(connection, ev, user, app, host, accessToken ?? "", prepareStream); + + const intervalId = user + ? setInterval(() => { + Users.update(user!.id, { + lastActiveDate: new Date(), + }); + }, 1000 * 60 * 5) + : null; + if (user) { + Users.update(user.id, { + lastActiveDate: new Date(), + }); + } + + connection.once("close", () => { + ev.removeAllListeners(); + main.dispose(); + redisClient.off("message", onRedisMessage); + if (intervalId) clearInterval(intervalId); + }); + + connection.on("message", async (data) => { + if (data.type === "utf8" && data.utf8Data === "ping") { + connection.send("pong"); + } + }); + }); +}; diff --git a/packages/backend/src/server/file/assets/bad-egg.png b/packages/backend/src/server/file/assets/bad-egg.png new file mode 100644 index 0000000..16ce935 --- /dev/null +++ b/packages/backend/src/server/file/assets/bad-egg.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bba9483dea2f01bc8506fd9ad51ef29fcc896244e470597a6968fb7d68537571 +size 1676 diff --git a/packages/backend/src/server/file/assets/cache-expired.png b/packages/backend/src/server/file/assets/cache-expired.png new file mode 100644 index 0000000..890ff8a --- /dev/null +++ b/packages/backend/src/server/file/assets/cache-expired.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:032a927030562ae9fa1ea65c97c093c611986cb5ef8852c05dc09caea9326302 +size 6048 diff --git a/packages/backend/src/server/file/assets/dummy.png b/packages/backend/src/server/file/assets/dummy.png new file mode 100644 index 0000000..1703bad --- /dev/null +++ b/packages/backend/src/server/file/assets/dummy.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe0f4c44a5e63ac228fafc6fa3aba1fbe88188dea73ca39d2f8c950f17f74d47 +size 6285 diff --git a/packages/backend/src/server/file/assets/not-an-image.png b/packages/backend/src/server/file/assets/not-an-image.png new file mode 100644 index 0000000..582dca6 --- /dev/null +++ b/packages/backend/src/server/file/assets/not-an-image.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8f05ff092ccb301de916b0af580c4d869809fe940756ddbb346e98215bf8c366 +size 2780 diff --git a/packages/backend/src/server/file/assets/thumbnail-not-available.png b/packages/backend/src/server/file/assets/thumbnail-not-available.png new file mode 100644 index 0000000..ba489c6 --- /dev/null +++ b/packages/backend/src/server/file/assets/thumbnail-not-available.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:65f5b8de0f28a495ef8acb35ec067275ce5f641694617ecc9a72bd0bdbc983f8 +size 5705 diff --git a/packages/backend/src/server/file/assets/tombstone.png b/packages/backend/src/server/file/assets/tombstone.png new file mode 100644 index 0000000..05cb6e9 --- /dev/null +++ b/packages/backend/src/server/file/assets/tombstone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6cb566a6e40b1740624867b738317af32e4afa4e09fa9d842f85f977bca0ac35 +size 5028 diff --git a/packages/backend/src/server/file/index.ts b/packages/backend/src/server/file/index.ts new file mode 100644 index 0000000..cdb5313 --- /dev/null +++ b/packages/backend/src/server/file/index.ts @@ -0,0 +1,49 @@ +/** + * File Server + */ + +import * as fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; +import Koa from "koa"; +import cors from "@koa/cors"; +import Router from "@koa/router"; +import sendDriveFile from "./send-drive-file.js"; +import { serverLogger } from "../index.js"; +import { isIgnorableConnectionError } from "../is-ignorable-connection-error.js"; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +// Init app +const app = new Koa(); +app.on("error", (err) => { + if (isIgnorableConnectionError(err)) return; + serverLogger.error(err); +}); +app.use(cors()); +app.use(async (ctx, next) => { + ctx.set( + "Content-Security-Policy", + `default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`, + ); + await next(); +}); + +// Init router +const router = new Router(); + +router.get("/app-default.jpg", (ctx) => { + const file = fs.createReadStream(`${_dirname}/assets/dummy.png`); + ctx.body = file; + ctx.set("Content-Type", "image/jpeg"); + ctx.set("Cache-Control", "max-age=31536000, immutable"); +}); + +router.get("/:key", sendDriveFile); +router.get("/:key/{*splat}", sendDriveFile); + +// Register router +app.use(router.routes()); + +export default app; diff --git a/packages/backend/src/server/file/send-drive-file.ts b/packages/backend/src/server/file/send-drive-file.ts new file mode 100644 index 0000000..8932436 --- /dev/null +++ b/packages/backend/src/server/file/send-drive-file.ts @@ -0,0 +1,258 @@ +import * as fs from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname } from "node:path"; +import type Koa from "koa"; +import send from "koa-send"; +import rename from "rename"; +import { serverLogger } from "../index.js"; +import { contentDisposition } from "@/misc/content-disposition.js"; +import { DriveFiles } from "@/models/index.js"; +import { InternalStorage } from "@/services/drive/internal-storage.js"; +import { createTemp } from "@/misc/create-temp.js"; +import { downloadUrl } from "@/misc/download-url.js"; +import { detectType } from "@/misc/get-file-info.js"; +import { convertToWebp } from "@/services/drive/image-processor.js"; +import { GenerateVideoThumbnail } from "@/services/drive/generate-video-thumbnail.js"; +import { StatusError } from "@/misc/fetch.js"; +import { FILE_TYPE_BROWSERSAFE, MINUTE } from "@/const.js"; +import { IEndpointMeta } from "@/server/api/endpoints.js"; +import { getIpHash } from "@/misc/get-ip-hash.js"; +import { limiter } from "@/server/api/limiter.js"; +import authenticate from "@/server/api/authenticate.js"; +import { isIgnorableConnectionError } from "../is-ignorable-connection-error.js"; + +const _filename = fileURLToPath(import.meta.url); +const _dirname = dirname(_filename); + +const assets = `${_dirname}/../../server/file/assets/`; + +const commonReadableHandlerGenerator = + (ctx: Koa.Context) => (e: Error): void => { + if (isIgnorableConnectionError(e)) return; + serverLogger.error(e); + ctx.status = 500; + ctx.set("Cache-Control", "max-age=300"); + }; + +export default async function (ctx: Koa.Context) { + const key = ctx.params.key; + + // Fetch drive file + const file = await DriveFiles.createQueryBuilder("file") + .where("file.accessKey = :accessKey", { accessKey: key }) + .orWhere("file.thumbnailAccessKey = :thumbnailAccessKey", { + thumbnailAccessKey: key, + }) + .orWhere("file.webpublicAccessKey = :webpublicAccessKey", { + webpublicAccessKey: key, + }) + .getOne(); + + if (file == null) { + ctx.status = 404; + ctx.set("Cache-Control", "max-age=86400"); + await send(ctx as any, "/dummy.png", { root: assets }); + return; + } + + ctx.set("X-Content-Type-Options", "nosniff"); + + const isThumbnail = file.thumbnailAccessKey === key; + const isWebpublic = file.webpublicAccessKey === key; + const requestedDownload = ctx.query.download === "1"; + const requestedStream = ctx.query.stream === "1"; + const isStreamableMedia = file.type.startsWith("video/") || file.type.startsWith("audio/"); + + // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. + const limitActor = getIpHash(ctx.ip); + const isMediaPlayback = isStreamableMedia && !requestedDownload; + const limit: IEndpointMeta["limit"] = { + key: `drive-file:${key}`, + duration: MINUTE * 10, + max: isMediaPlayback ? 600 : 10, + }; + + await limiter( + limit as IEndpointMeta["limit"] & { key: NonNullable }, + limitActor, + ).catch((e) => { + const remainingTime = e.remainingTime + ? `Please try again in ${e.remainingTime}.` + : "Please try again later."; + + ctx.status = 429; + ctx.body = "Rate limit exceeded. " + remainingTime; + ctx.set("Content-Type", "text/plain; charset=utf-8"); + ctx.set("Cache-Control", "no-store"); + }); + + if (ctx.status == 429) return; + + const requesterId = requestedDownload ? await getRequesterId(ctx) : null; + const isOwner = requesterId != null && requesterId === file.userId; + const protectedOriginal = !isThumbnail && !isWebpublic && !file.allowDownload && !isOwner; + + if (requestedDownload && protectedOriginal) { + ctx.status = 403; + ctx.body = "Download is not allowed for this file."; + ctx.set("Cache-Control", "max-age=300"); + return; + } + + if (!file.storedInternal) { + if (file.isLink && file.uri) { + // 期限切れリモートファイル + const [path, cleanup] = await createTemp(); + + try { + await downloadUrl(file.uri, path); + + const { mime, ext } = await detectType(path); + + const convertFile = async () => { + if (isThumbnail) { + if ( + [ + "image/jpeg", + "image/webp", + "image/png", + "image/svg+xml", + "image/avif", + ].includes(mime) + ) { + return await convertToWebp(path, 996, 560); + } else if (mime.startsWith("video/")) { + return await GenerateVideoThumbnail(path); + } + } + + if (isWebpublic) { + if (["image/svg+xml"].includes(mime)) { + return await convertToWebp(path, 2048, 2048, 100); + } + } + + return { + data: fs.readFileSync(path), + ext, + type: mime, + }; + }; + + const image = await convertFile(); + ctx.body = image.data; + ctx.set( + "Content-Type", + FILE_TYPE_BROWSERSAFE.includes(image.type) + ? image.type + : "application/octet-stream", + ); + ctx.set("Cache-Control", "max-age=31536000, immutable"); + } catch (e) { + if (isIgnorableConnectionError(e)) return; + serverLogger.error(`${e}`); + + if (e instanceof StatusError && !e.isRetryable) { + ctx.status = e.statusCode; + ctx.set("Cache-Control", "max-age=86400"); + } else { + ctx.status = 500; + ctx.set("Cache-Control", "max-age=300"); + } + } finally { + cleanup(); + } + return; + } + + ctx.status = 204; + ctx.set("Cache-Control", "max-age=86400"); + return; + } + + if (isThumbnail || isWebpublic) { + const { mime, ext } = await detectType(InternalStorage.resolvePath(key)); + const filename = rename(file.name, { + suffix: isThumbnail ? "-thumb" : "-web", + extname: ext ? `.${ext}` : undefined, + }).toString(); + + ctx.body = InternalStorage.read(key); + ctx.set( + "Content-Type", + FILE_TYPE_BROWSERSAFE.includes(mime) ? mime : "application/octet-stream", + ); + ctx.set("Cache-Control", "max-age=31536000, immutable"); + ctx.set("Content-Disposition", contentDisposition("inline", filename)); + } else { + const storageKey = file.accessKey!; + const disposition = requestedDownload ? "attachment" : "inline"; + ctx.set( + "Content-Type", + isStreamableMedia || FILE_TYPE_BROWSERSAFE.includes(file.type) + ? file.type + : "application/octet-stream", + ); + ctx.set("Cache-Control", "max-age=31536000, immutable"); + ctx.set("Content-Disposition", contentDisposition(disposition, file.name)); + + if (!requestedDownload && isStreamableMedia) { + const servedRange = await sendRange(ctx, storageKey); + if (servedRange) return; + } + + const readable = InternalStorage.read(storageKey); + readable.on("error", commonReadableHandlerGenerator(ctx)); + ctx.body = readable; + } +} + +async function getRequesterId(ctx: Koa.Context): Promise { + const queryToken = typeof ctx.query.i === "string" ? ctx.query.i : null; + try { + const [user] = await authenticate(ctx.get("authorization") || null, queryToken, true); + return user?.id ?? null; + } catch { + return null; + } +} + +async function sendRange(ctx: Koa.Context, key: string): Promise { + const range = ctx.get("range"); + if (!range) { + ctx.set("Accept-Ranges", "bytes"); + return false; + } + + const path = InternalStorage.resolvePath(key); + const stat = await fs.promises.stat(path); + const match = /^bytes=(\d*)-(\d*)$/.exec(range); + if (!match) { + ctx.status = 416; + ctx.set("Content-Range", `bytes */${stat.size}`); + return true; + } + + let start = match[1] === "" ? 0 : Number(match[1]); + let end = match[2] === "" ? stat.size - 1 : Number(match[2]); + if (match[1] === "" && match[2] !== "") { + const suffixLength = Number(match[2]); + start = Math.max(stat.size - suffixLength, 0); + end = stat.size - 1; + } + + if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start < 0 || end >= stat.size) { + ctx.status = 416; + ctx.set("Content-Range", `bytes */${stat.size}`); + return true; + } + + ctx.status = 206; + ctx.set("Accept-Ranges", "bytes"); + ctx.set("Content-Range", `bytes ${start}-${end}/${stat.size}`); + ctx.set("Content-Length", String(end - start + 1)); + const readable = fs.createReadStream(path, { start, end }); + readable.on("error", commonReadableHandlerGenerator(ctx)); + ctx.body = readable; + return true; +} diff --git a/packages/backend/src/server/index.ts b/packages/backend/src/server/index.ts new file mode 100644 index 0000000..bcfa746 --- /dev/null +++ b/packages/backend/src/server/index.ts @@ -0,0 +1,217 @@ +/** + * Core Server + */ + +import cluster from "node:cluster"; +import * as fs from "node:fs"; +import * as http from "node:http"; +import * as https from "node:https"; +import Koa from "koa"; +import Router from "@koa/router"; +import cors from "@koa/cors"; +import mount from "koa-mount"; +import koaLogger from "koa-logger"; +import * as slow from "koa-slow"; + +import { IsNull } from "typeorm"; +import config from "@/config/index.js"; +import Logger from "@/services/logger.js"; +import { Users } from "@/models/index.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { genIdenticon } from "@/misc/gen-identicon.js"; +import { createTemp } from "@/misc/create-temp.js"; +import * as Acct from "@/misc/acct.js"; +import { envOption } from "@/env.js"; +import activityPub from "./activitypub.js"; +import nodeinfo from "./nodeinfo.js"; +import wellKnown from "./well-known.js"; +import apiServer from "./api/index.js"; +import fileServer from "./file/index.js"; +import proxyServer from "./proxy/index.js"; +import webServer from "./web/index.js"; +import { initializeStreamingServer } from "./api/streaming.js"; +import removeTrailingSlash from "koa-remove-trailing-slashes"; +import { koaBody } from "koa-body"; +import { setupEndpointsAuthRoot } from "@/server/api/mastodon/endpoints/auth.js"; +import { CatchErrorsMiddleware } from "@/server/api/mastodon/middleware/catch-errors.js"; +import { handleMetrics } from "@/metrics.js"; +import { isIgnorableConnectionError } from "./is-ignorable-connection-error.js"; +export const serverLogger = new Logger("server", "gray", false); + +// Init app +const app = new Koa(); +app.proxy = true; +app.on("error", (err) => { + if (isIgnorableConnectionError(err)) return; + serverLogger.error(err); +}); + +app.use(removeTrailingSlash()); + +app.use( + cors({ + origin: "*", + }), +); + +if (!["production", "test"].includes(process.env.NODE_ENV || "")) { + // Logger + app.use( + koaLogger((str) => { + serverLogger.info(str); + }), + ); + + // Delay + if (envOption.slow) { + app.use( + slow({ + delay: 3000, + }), + ); + } +} + +// HSTS +// 6months (15552000sec) +if (config.url.startsWith("https") && !config.disableHsts) { + app.use(async (ctx, next) => { + ctx.set("strict-transport-security", "max-age=15552000; preload"); + await next(); + }); +} + +app.use(mount("/api", apiServer)); +app.use(mount("/files", fileServer)); +app.use(mount("/proxy", proxyServer)); + +// Init router +const router = new Router(); +const mastoRouter = new Router(); + +// Routing +router.use(activityPub.routes()); +router.use(nodeinfo.routes()); +router.use(wellKnown.routes()); + +router.get("/avatar/@:acct", async (ctx) => { + const { username, host } = Acct.parse(ctx.params.acct); + const user = await Users.findOne({ + where: { + usernameLower: username.toLowerCase(), + host: host == null || host === config.host || host === config.domain ? IsNull() : host, + isSuspended: false, + }, + relations: ["avatar"], + }); + + if (user) { + ctx.redirect(Users.getAvatarUrlSync(user)); + } else { + ctx.redirect("/static-assets/user-unknown.png"); + } +}); + +router.get("/identicon/:x", async (ctx) => { + const meta = await fetchMeta(); + if (meta.enableIdenticonGeneration) { + const [temp, cleanup] = await createTemp(); + await genIdenticon(ctx.params.x, fs.createWriteStream(temp)); + ctx.set("Content-Type", "image/png"); + ctx.body = fs.createReadStream(temp).on("close", () => cleanup()); + } else { + ctx.redirect("/static-assets/avatar.png"); + } +}); + +if (config.metrics?.enable) { + router.get("/metrics", handleMetrics); +} + +mastoRouter.use( + koaBody({ + urlencoded: true, + multipart: true, + }), +); + +mastoRouter.use(async (ctx, next) => { + if (ctx.request.query) { + if (!ctx.request.body || Object.keys(ctx.request.body).length === 0) { + ctx.request.body = ctx.request.query; + } else { + ctx.request.body = { ...ctx.request.body, ...ctx.request.query }; + } + } + await next(); +}); + +mastoRouter.use(CatchErrorsMiddleware); +setupEndpointsAuthRoot(mastoRouter); + +// Register router +app.use(router.routes()); +app.use(mastoRouter.routes()); + +app.use(mount(webServer)); + +function createServer() { + if (config.tls?.keyPath && config.tls?.certPath) { + return https.createServer( + { + key: fs.readFileSync(config.tls.keyPath), + cert: fs.readFileSync(config.tls.certPath), + }, + app.callback(), + ); + } + + return http.createServer(app.callback()); +} + +// For testing +export const startServer = () => { + const server = createServer(); + + initializeStreamingServer(server); + + server.listen(config.port, config.listen); + + return server; +}; + +export default () => + new Promise((resolve) => { + const server = createServer(); + + initializeStreamingServer(server); + + server.on("error", (e) => { + if (isIgnorableConnectionError(e)) return; + switch ((e as any).code) { + case "EACCES": + serverLogger.error( + `You do not have permission to listen on port ${config.port}.`, + ); + break; + case "EADDRINUSE": + serverLogger.error( + `Port ${config.port} is already in use by another process.`, + ); + break; + default: + serverLogger.error(e); + break; + } + + if (cluster.isWorker) { + process.send!("listenFailed"); + } else { + // disableClustering + process.exit(1); + } + }); + + // @ts-ignore + server.listen(config.port, config.listen, resolve); + }); diff --git a/packages/backend/src/server/is-ignorable-connection-error.ts b/packages/backend/src/server/is-ignorable-connection-error.ts new file mode 100644 index 0000000..8c19245 --- /dev/null +++ b/packages/backend/src/server/is-ignorable-connection-error.ts @@ -0,0 +1,4 @@ +export function isIgnorableConnectionError(err: unknown): boolean { + const code = (err as { code?: string })?.code; + return code === "EPIPE" || code === "ECONNRESET" || code === "ERR_STREAM_DESTROYED"; +} diff --git a/packages/backend/src/server/nodeinfo.ts b/packages/backend/src/server/nodeinfo.ts new file mode 100644 index 0000000..65fe5a3 --- /dev/null +++ b/packages/backend/src/server/nodeinfo.ts @@ -0,0 +1,117 @@ +import Router from "@koa/router"; +import config from "@/config/index.js"; +import { fetchMeta } from "@/misc/fetch-meta.js"; +import { Users, Notes } from "@/models/index.js"; +import { IsNull, MoreThan } from "typeorm"; +import { MAX_NOTE_TEXT_LENGTH, MAX_CAPTION_TEXT_LENGTH } from "@/const.js"; +import { Cache } from "@/misc/cache.js"; + +const router = new Router(); + +const nodeinfo2_1path = "/nodeinfo/2.1"; +const nodeinfo2_0path = "/nodeinfo/2.0"; + +// to cleo: leave this http or bonks +export const links = [ + { + rel: "http://nodeinfo.diaspora.software/ns/schema/2.1", + href: config.url + nodeinfo2_1path, + }, + { + rel: "http://nodeinfo.diaspora.software/ns/schema/2.0", + href: config.url + nodeinfo2_0path, + }, +]; + +const nodeinfo2 = async () => { + const now = Date.now(); + const [meta, total, activeHalfyear, activeMonth, localPosts] = + await Promise.all([ + fetchMeta(true), + Users.count({ where: { host: IsNull() } }), + Users.count({ + where: { + host: IsNull(), + lastActiveDate: MoreThan(new Date(now - 15552000000)), + }, + }), + Users.count({ + where: { + host: IsNull(), + lastActiveDate: MoreThan(new Date(now - 2592000000)), + }, + }), + Notes.count({ where: { userHost: IsNull() } }), + ]); + + return { + software: { + name: "iceshrimp", + version: config.version, + repository: meta.repositoryUrl, + homepage: "https://iceshrimp.dev/", + }, + protocols: ["activitypub"], + services: { + inbound: [] as string[], + outbound: ["atom1.0", "rss2.0"], + }, + openRegistrations: !meta.disableRegistration, + usage: { + users: { total, activeHalfyear, activeMonth }, + localPosts, + localComments: 0, + }, + metadata: { + nodeName: meta.name, + nodeDescription: meta.description, + maintainer: { + name: meta.maintainerName, + email: meta.maintainerEmail, + }, + langs: meta.langs, + tosUrl: meta.ToSUrl, + repositoryUrl: meta.repositoryUrl, + feedbackUrl: meta.feedbackUrl, + disableRegistration: meta.disableRegistration, + disableLocalTimeline: meta.disableLocalTimeline, + disableRecommendedTimeline: meta.disableRecommendedTimeline, + disableGlobalTimeline: meta.disableGlobalTimeline, + emailRequiredForSignup: meta.emailRequiredForSignup, + postEditing: true, + postImports: meta.experimentalFeatures?.postImports || false, + enableHcaptcha: meta.enableHcaptcha, + enableRecaptcha: meta.enableRecaptcha, + maxNoteTextLength: MAX_NOTE_TEXT_LENGTH, + maxCaptionTextLength: MAX_CAPTION_TEXT_LENGTH, + enableGithubIntegration: meta.enableGithubIntegration, + enableDiscordIntegration: meta.enableDiscordIntegration, + enableEmail: meta.enableEmail, + themeColor: meta.themeColor || "#31748f", + }, + }; +}; + +const cache = new Cache>>( + "nodeinfo", + 60 * 10, +); + +router.get(nodeinfo2_1path, async (ctx) => { + const base = await cache.fetch(null, () => nodeinfo2()); + + ctx.body = { version: "2.1", ...base }; + ctx.set("Cache-Control", "public, max-age=600"); +}); + +router.get(nodeinfo2_0path, async (ctx) => { + const base = await cache.fetch(null, () => nodeinfo2()); + + // @ts-ignore + base.software.repository = undefined; + + ctx.body = { version: "2.0", ...base }; + ctx.set("Cache-Control", "public, max-age=600"); +}); + +export default router; diff --git a/packages/backend/src/server/proxy/index.ts b/packages/backend/src/server/proxy/index.ts new file mode 100644 index 0000000..02c138f --- /dev/null +++ b/packages/backend/src/server/proxy/index.ts @@ -0,0 +1,35 @@ +/** + * Media Proxy + */ + +import Koa from "koa"; +import cors from "@koa/cors"; +import Router from "@koa/router"; +import { proxyMedia } from "./proxy-media.js"; +import { serverLogger } from "../index.js"; +import { isIgnorableConnectionError } from "../is-ignorable-connection-error.js"; + +// Init app +const app = new Koa(); +app.on("error", (err) => { + if (isIgnorableConnectionError(err)) return; + serverLogger.error(err); +}); +app.use(cors()); +app.use(async (ctx, next) => { + ctx.set( + "Content-Security-Policy", + `default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'`, + ); + await next(); +}); + +// Init router +const router = new Router(); + +router.get("/*url", proxyMedia); + +// Register router +app.use(router.routes()); + +export default app; diff --git a/packages/backend/src/server/proxy/proxy-media.ts b/packages/backend/src/server/proxy/proxy-media.ts new file mode 100644 index 0000000..b1ee331 --- /dev/null +++ b/packages/backend/src/server/proxy/proxy-media.ts @@ -0,0 +1,148 @@ +import * as fs from "node:fs"; +import net from "node:net"; +import { promises } from "node:dns"; +import type Koa from "koa"; +import sharp from "sharp"; +import type { IImage } from "@/services/drive/image-processor.js"; +import { convertToWebp } from "@/services/drive/image-processor.js"; +import { createTemp } from "@/misc/create-temp.js"; +import { downloadUrl } from "@/misc/download-url.js"; +import { detectType } from "@/misc/get-file-info.js"; +import { StatusError } from "@/misc/fetch.js"; +import { FILE_TYPE_BROWSERSAFE, MINUTE } from "@/const.js"; +import { serverLogger } from "../index.js"; +import { isMimeImage } from "@/misc/is-mime-image.js"; +import { getIpHash } from "@/misc/get-ip-hash.js"; +import { limiter } from "@/server/api/limiter.js"; +import { IEndpointMeta } from "@/server/api/endpoints.js"; +import { isIgnorableConnectionError } from "../is-ignorable-connection-error.js"; + +export async function proxyMedia(ctx: Koa.Context) { + const url = "url" in ctx.query ? ctx.query.url : `https://${ctx.params.url}`; + + if (typeof url !== "string") { + ctx.status = 400; + return; + } + + // koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app. + let limitActor: string; + limitActor = getIpHash(ctx.ip); + + const parsedUrl = new URL(url); + + const limit: IEndpointMeta["limit"] = { + key: `media-proxy:${parsedUrl.host}:${parsedUrl.pathname}`, + duration: MINUTE * 10, + max: 10 + } + + // Rate limit + await limiter( + limit as IEndpointMeta["limit"] & { key: NonNullable }, + limitActor, + ).catch((e) => { + const remainingTime = e.remainingTime + ? `Please try again in ${e.remainingTime}.` + : "Please try again later."; + + ctx.status = 429; + ctx.body = "Rate limit exceeded. " + remainingTime; + }); + + if (ctx.status == 429) return; + + // Create temp file + const [path, cleanup] = await createTemp(); + + try { + await downloadUrl(url, path); + + const { mime, ext } = await detectType(path); + const isConvertibleImage = isMimeImage(mime, "sharp-convertible-image"); + + let image: IImage; + + if ("glyph" in ctx.query) { + if (mime !== "image/svg+xml") { + throw new StatusError("Unexpected mime", 404); + } + + ctx.set("Content-Type", "text/plain; charset=utf-8"); + ctx.set("Cache-Control", "max-age=31536000, immutable"); + ctx.body = fs.readFileSync(path, "utf8"); + return; + } else if ("static" in ctx.query && isConvertibleImage) { + image = await convertToWebp(path, 996, 560); + } else if ("preview" in ctx.query && isConvertibleImage) { + image = await convertToWebp(path, 400, 400); + } else if ("badge" in ctx.query) { + if (!isConvertibleImage) { + // 画像でないなら404でお茶を濁す + throw new StatusError("Unexpected mime", 404); + } + + const mask = sharp(path) + .resize(96, 96, { + fit: "inside", + withoutEnlargement: false, + }) + .greyscale() + .normalise() + .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast + .flatten({ background: "#000" }) + .toColorspace("b-w"); + + const stats = await mask.clone().stats(); + + if (stats.entropy < 0.1) { + // エントロピーがあまりない場合は404にする + throw new StatusError("Skip to provide badge", 404); + } + + const data = sharp({ + create: { + width: 96, + height: 96, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }) + .pipelineColorspace("b-w") + .boolean(await mask.png().toBuffer(), "eor"); + + image = { + data: await data.png().toBuffer(), + ext: "png", + type: "image/png", + }; + } else if (mime === "image/svg+xml") { + image = await convertToWebp(path, 2048, 2048, 1); + } else if ( + !(mime.startsWith("image/") && FILE_TYPE_BROWSERSAFE.includes(mime)) + ) { + throw new StatusError("Rejected type", 403, "Rejected type"); + } else { + image = { + data: fs.readFileSync(path), + ext, + type: mime, + }; + } + + ctx.set("Content-Type", image.type); + ctx.set("Cache-Control", "max-age=31536000, immutable"); + ctx.body = image.data; + } catch (e) { + if (isIgnorableConnectionError(e)) return; + serverLogger.error(`${e}`); + + if (e instanceof StatusError && (e.statusCode === 302 || e.isClientError)) { + ctx.status = e.statusCode; + } else { + ctx.status = 500; + } + } finally { + cleanup(); + } +} diff --git a/packages/backend/src/server/web/bios.css b/packages/backend/src/server/web/bios.css new file mode 100644 index 0000000..d927f26 --- /dev/null +++ b/packages/backend/src/server/web/bios.css @@ -0,0 +1,147 @@ +main > .tabs { + padding: 16px; + border-bottom: 4px solid #908caa; +} +#lsEditor > .adder { + margin: 16px; + padding: 16px; + border: 2px solid #908caa; +} +#lsEditor > .adder > textarea { + display: block; + width: 100%; + min-height: 5em; + box-sizing: border-box; +} +#lsEditor > .record { + padding: 16px; + border-bottom: 1px solid #908caa; +} +#lsEditor > .record > header { + font-weight: 700; +} +#lsEditor > .record > textarea { + display: block; + width: 100%; + min-height: 5em; + box-sizing: border-box; +} + +html { + background: #191724; +} +main { + background: #1f1d2e; + border-radius: 10px; +} +#tl > div { + padding: 16px; + border-bottom: 1px solid #908caa; +} +#tl > div > header { + font-weight: 700; +} + +* { + font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; +} +#iceshrimp_app { + display: none !important; +} +body, +html { + background-color: #191724; + color: #e0def4; + justify-content: center; + margin: auto; + padding: 10px; + text-align: center; +} +button { + border-radius: 999px; + padding: 0px 12px 0px 12px; + border: none; + cursor: pointer; + margin-bottom: 12px; + background: linear-gradient(-45deg, rgb(156, 207, 216), rgb(49, 116, 143)); + line-height: 50px; + color: #191724; + font-weight: bold; + font-size: 20px; + padding: 12px; +} +button { + border-radius: 999px; + padding: 0px 12px 0px 12px; + border: none; + cursor: pointer; + margin-bottom: 12px; +} +button { + background: #444; + line-height: 40px; + color: rgb(156, 207, 216); + font-size: 16px; + padding: 0 20px; + margin-right: 5px; + margin-left: 5px; +} +button:hover { + background: #555; +} +#ls { + background: linear-gradient(-45deg, rgb(156, 207, 216), rgb(49, 116, 143)); + line-height: 30px; + color: #191724; + font-weight: bold; + font-size: 18px; + padding: 12px; +} +#ls:hover { + background: rgb(156, 207, 216); +} +a { + color: rgb(156, 207, 216); + text-decoration: none; +} +p, +li { + font-size: 16px; +} + +h1 { + font-size: 32px; +} +code { + font-family: Fira, FiraCode, monospace; +} +textarea { + background-color: #444; + border: solid #aaa; + border-radius: 10px; + color: #e0def4; + margin-top: 1rem; + margin-bottom: 1rem; + width: 20rem; + height: 7.5rem; + padding: 0.5rem; +} + +textarea:focus { + border: solid #eee; +} +input { + background-color: #666; + border: solid #aaa; + border-radius: 10px; + color: #e0def4; + margin-top: 1rem; + margin-bottom: 1rem; + width: 10rem; + height: 1rem; + padding: 0.5rem; +} + +input:focus { + border: solid #eee; +} diff --git a/packages/backend/src/server/web/bios.js b/packages/backend/src/server/web/bios.js new file mode 100644 index 0000000..e715a01 --- /dev/null +++ b/packages/backend/src/server/web/bios.js @@ -0,0 +1,89 @@ +"use strict"; + +window.onload = async () => { + const account = JSON.parse(localStorage.getItem("account")); + const i = account.token; + + const api = (endpoint, data = {}) => { + const promise = new Promise((resolve, reject) => { + // Append a credential + if (i) data.i = i; + + // Send request + fetch(endpoint.indexOf("://") > -1 ? endpoint : `/api/${endpoint}`, { + method: "POST", + body: JSON.stringify(data), + credentials: "omit", + cache: "no-cache", + }) + .then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }) + .catch(reject); + }); + + return promise; + }; + + const content = document.getElementById("content"); + + document.getElementById("ls").addEventListener("click", () => { + content.innerHTML = ""; + + const lsEditor = document.createElement("div"); + lsEditor.id = "lsEditor"; + + const adder = document.createElement("div"); + adder.classList.add("adder"); + const addKeyInput = document.createElement("input"); + const addValueTextarea = document.createElement("textarea"); + const addButton = document.createElement("button"); + addButton.textContent = "Add"; + addButton.addEventListener("click", () => { + localStorage.setItem(addKeyInput.value, addValueTextarea.value); + location.reload(); + }); + + adder.appendChild(addKeyInput); + adder.appendChild(addValueTextarea); + adder.appendChild(addButton); + lsEditor.appendChild(adder); + + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + const record = document.createElement("div"); + record.classList.add("record"); + const header = document.createElement("header"); + header.textContent = k; + const textarea = document.createElement("textarea"); + textarea.textContent = localStorage.getItem(k); + const saveButton = document.createElement("button"); + saveButton.textContent = "Save"; + saveButton.addEventListener("click", () => { + localStorage.setItem(k, textarea.value); + location.reload(); + }); + const removeButton = document.createElement("button"); + removeButton.textContent = "Remove"; + removeButton.addEventListener("click", () => { + localStorage.removeItem(k); + location.reload(); + }); + record.appendChild(header); + record.appendChild(textarea); + record.appendChild(saveButton); + record.appendChild(removeButton); + lsEditor.appendChild(record); + } + + content.appendChild(lsEditor); + }); +}; diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js new file mode 100644 index 0000000..bec9186 --- /dev/null +++ b/packages/backend/src/server/web/boot.js @@ -0,0 +1,333 @@ +/** + * BOOT LOADER + * サーバーからレスポンスされるHTMLに埋め込まれるスクリプトで、以下の役割を持ちます。 + * - 翻訳ファイルをフェッチする。 + * - バージョンに基づいて適切なメインスクリプトを読み込む。 + * - キャッシュされたコンパイル済みテーマを適用する。 + * - クライアントの設定値に基づいて対応するHTMLクラス等を設定する。 + * テーマをこの段階で設定するのは、メインスクリプトが読み込まれる間もテーマを適用したいためです。 + * 注: webpackは介さないため、このファイルではrequireやimportは使えません。 + */ + +"use strict"; + +// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので +(async () => { + window.onerror = (e) => { + console.error(e); + renderError("SOMETHING_HAPPENED", e); + }; + window.onunhandledrejection = (e) => { + console.error(e); + renderError("SOMETHING_HAPPENED_IN_PROMISE", e); + }; + + //#region Detect language & fetch translations + const cachedVersion = localStorage.getItem("v"); + const v = VERSION; + if (cachedVersion !== VERSION) { + localStorage.setItem("v", VERSION); + } + + const supportedLangs = LANGS; + let lang = localStorage.getItem("lang"); + if (lang == null || !supportedLangs.includes(lang)) { + if (supportedLangs.includes(navigator.language)) { + lang = navigator.language; + } else { + lang = supportedLangs.find((x) => x.split("-")[0] === navigator.language); + + // Fallback + if (lang == null) lang = "en-US"; + } + } + + const res = await fetch(`/assets/locales/${lang}.${v}.json`); + if (res.status === 200) { + localStorage.setItem("lang", lang); + localStorage.setItem("locale", await res.text()); + localStorage.setItem("localeVersion", v); + } else { + await checkUpdate(); + renderError("LOCALE_FETCH"); + return; + } + //#endregion + + //#region Script + function importAppScript() { + import(`/assets/${CLIENT_ENTRY}`).catch(async (e) => { + await checkUpdate(); + console.error(e); + renderError("APP_IMPORT", e); + }); + } + + // タイミングによっては、この時点でDOMの構築が済んでいる場合とそうでない場合とがある + if (document.readyState !== "loading") { + importAppScript(); + } else { + window.addEventListener("DOMContentLoaded", () => { + importAppScript(); + }); + } + //#endregion + + //#region Theme + const theme = localStorage.getItem("theme"); + if (theme) { + for (const [k, v] of Object.entries(JSON.parse(theme))) { + document.documentElement.style.setProperty(`--${k}`, v.toString()); + + // HTMLの theme-color 適用 + if (k === "htmlThemeColor") { + for (const tag of document.head.children) { + if ( + tag.tagName === "META" && + tag.getAttribute("name") === "theme-color" + ) { + tag.setAttribute("content", v); + break; + } + } + } + } + } + const colorSchema = localStorage.getItem("colorSchema"); + if (colorSchema) { + document.documentElement.style.setProperty("color-schema", colorSchema); + } + //#endregion + + let fontSize = localStorage.getItem("fontSize"); + if (fontSize) { + if (fontSize < 10) { + // need to do this for now, as values before were 1, 2, 3 depending on the option + localStorage.setItem("fontSize", null); + fontSize = localStorage.getItem("fontSize"); + } + document.documentElement.style.fontSize = `${fontSize}px`; + } + + if (["ja-JP", "ja-KS", "ko-KR", "zh-CN", "zh-TW"].includes(lang)) { + document.documentElement.classList.add("useCJKFont"); + } + + const useSystemFont = localStorage.getItem("useSystemFont"); + if (useSystemFont) { + document.documentElement.classList.add("useSystemFont"); + } + + const wallpaper = localStorage.getItem("wallpaper"); + if (wallpaper) { + document.documentElement.style.backgroundImage = `url(${wallpaper})`; + } + + const customCss = localStorage.getItem("customCss"); + if (customCss && customCss.length > 0) { + const style = document.createElement("style"); + style.innerHTML = customCss; + document.head.appendChild(style); + } + + async function addStyle(styleText) { + const css = document.createElement("style"); + css.appendChild(document.createTextNode(styleText)); + document.head.appendChild(css); + } + + function renderError(code, details) { + let errorsElement = document.getElementById("errors"); + + if (!errorsElement) { + document.body.innerHTML = ` + + + + + +

An error has occurred!

+ +

Don't worry, it's (probably) not your fault.

+

Please make sure your browser is up-to-date.

+

While we don't serve ads, ad-blockers might interfere with this application,
so disabling them might also fix this issue.

+

If the problem persists after refreshing, please contact your instance's administrator.
You may also try the following options:

+ + + +
+ + + +
+ + + +
+
+ `; + errorsElement = document.getElementById("errors"); + } + const detailsElement = document.createElement("details"); + detailsElement.innerHTML = ` +
+ + ERROR CODE: ${code} + + ${JSON.stringify(details)}`; + errorsElement.appendChild(detailsElement); + addStyle(` + * { + font-family: Roboto,HelveticaNeue,Arial,sans-serif; + } + + #iceshrimp_app, + #splash { + display: none !important; + } + + body, + html { + background-color: #3b364c; + color: rgb(231, 237, 255); + justify-content: center; + margin: auto; + padding: 10px; + text-align: center; + } + + button { + border-radius: 999px; + padding: 0px 12px 0px 12px; + border: none; + cursor: pointer; + margin-bottom: 12px; + } + + .button-big { + background: linear-gradient(-45deg, rgb(154, 146, 255), rgb(131, 114, 245)); + line-height: 50px; + } + + .button-big:hover { + background: rgb(201, 197, 255); + } + + .button-small { + background: #544d77; + line-height: 40px; + } + + .button-small:hover { + background: #504967; + } + + .button-label-big { + color: #3b364c; + font-weight: bold; + font-size: 2em; + padding: 12px; + } + + .button-label-small { + color: rgb(231, 237, 255); + font-size: 16px; + padding: 12px; + } + + a { + color: rgb(255, 123, 114); + text-decoration: none; + } + + p, + li { + font-size: 16px; + } + + .dont-worry, + #msg { + font-size: 18px; + } + + .icon-warning { + color: rgb(236, 182, 55); + height: 4rem; + padding-top: 2rem; + } + + h1 { + font-size: 32px; + } + + code { + font-family: Fira, FiraCode, monospace; + } + + details { + background: #423c55; + margin-bottom: 2rem; + padding: 0.5rem 1rem; + width: 40rem; + border-radius: 10px; + justify-content: center; + margin: auto; + } + + summary { + cursor: pointer; + } + + summary > * { + display: inline; + } + + @media screen and (max-width: 500px) { + details { + width: 50%; + } + `); + } + + async function checkUpdate() { + try { + const res = await fetch("/api/meta", { + method: "POST", + cache: "no-cache", + }); + + const meta = await res.json(); + + if (meta.version != v) { + localStorage.setItem("v", meta.version); + refresh(); + } + } catch (e) { + console.error(e); + renderError("UPDATE_CHECK", e); + throw e; + } + } + + function refresh() { + // Clear cache (service worker) + try { + navigator.serviceWorker.controller.postMessage("clear"); + navigator.serviceWorker.getRegistrations().then((registrations) => { + registrations.forEach((registration) => registration.unregister()); + }); + } catch (e) { + console.error(e); + } + + location.reload(); + } +})(); diff --git a/packages/backend/src/server/web/cli.css b/packages/backend/src/server/web/cli.css new file mode 100644 index 0000000..9407b5f --- /dev/null +++ b/packages/backend/src/server/web/cli.css @@ -0,0 +1,92 @@ +html { + background: #191724; +} +main { + background: #1f1d2e; + border-radius: 10px; +} +#tl > div { + border: 1px solid #908caa; + border-radius: 10px; + margin: 10px; + padding: 10px; + width: fit-content; +} +#tl > div > header { + font-weight: 700; + display: inline-flex; +} + +img { + border-radius: 10px; + margin-right: 10px; +} + +#form { + text-align: center; +} + +#iceshrimp_app { + display: none !important; +} + +body, +html { + font-family: BIZ UDGothic, Roboto, HelveticaNeue, Arial, sans-serif; + background-color: #191724; + color: #e0def4; + justify-content: center; + margin: auto; + padding: 10px; +} +button { + border-radius:999px; + padding:0 40px; + margin-top: 1rem; + border:none; + cursor:pointer; + margin-bottom:12px; + background:linear-gradient(-45deg,#9ccfd8,#31748f); + line-height:50px; + color:#191724; + font-weight:700; + font-size:20px; + } +button:hover { + background: rgb(156, 207, 216); +} +a { + color: rgb(156, 207, 216); + text-decoration: none; +} +p, +li { + font-size: 16px; +} + +h1 { + font-size: 32px; +} +code { + font-family: Fira, FiraCode, monospace; +} +#text { + background-color: #444; + border: solid #aaa; + border-radius: 10px; + color: #e0def4; + margin-top: 3rem; + width: 20rem; + height: 5rem; + padding: 0.5rem; +} + +#text:focus { + border: solid #eee; +} + +@media screen and (max-width: 500px) { + #text { + width: 80% + } +} diff --git a/packages/backend/src/server/web/cli.js b/packages/backend/src/server/web/cli.js new file mode 100644 index 0000000..85a61a2 --- /dev/null +++ b/packages/backend/src/server/web/cli.js @@ -0,0 +1,72 @@ +"use strict"; + +window.onload = async () => { + const account = JSON.parse(localStorage.getItem("account")); + const i = account.token; + + const api = (endpoint, data = {}) => { + const promise = new Promise((resolve, reject) => { + // Append a credential + if (i) data.i = i; + + // Send request + fetch(endpoint.indexOf("://") > -1 ? endpoint : `/api/${endpoint}`, { + method: "POST", + body: JSON.stringify(data), + credentials: "omit", + cache: "no-cache", + }) + .then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }) + .catch(reject); + }); + + return promise; + }; + + document.getElementById("submit").addEventListener("click", () => { + api("notes/create", { + text: document.getElementById("text").value, + }).then(() => { + location.reload(); + }); + }); + + api("notes/timeline").then((notes) => { + const tl = document.getElementById("tl"); + for (const note of notes) { + const el = document.createElement("div"); + const header = document.createElement("header"); + const name = document.createElement("p"); + const avatar = document.createElement("img"); + name.textContent = `${note.user.name} @${note.user.username}`; + avatar.src = note.user.avatarUrl; + avatar.style = "height: 40px"; + const text = document.createElement("div"); + text.textContent = `${note.text}`; + el.appendChild(header); + header.appendChild(avatar); + header.appendChild(name); + if (note.text) { + el.appendChild(text); + } + if (note.files) { + for (const file of note.files) { + const img = document.createElement("img"); + img.src = file.properties.thumbnailUrl; + el.appendChild(img); + } + } + tl.appendChild(el); + } + }); +}; diff --git a/packages/backend/src/server/web/feed.ts b/packages/backend/src/server/web/feed.ts new file mode 100644 index 0000000..5a0dd88 --- /dev/null +++ b/packages/backend/src/server/web/feed.ts @@ -0,0 +1,165 @@ +import { Feed } from "feed"; +import { In, IsNull } from "typeorm"; +import config from "@/config/index.js"; +import type { User } from "@/models/entities/user.js"; +import { Notes, DriveFiles, UserProfiles, Users } from "@/models/index.js"; + +export default async function ( + user: User, + threadDepth = 5, + history = 20, + noteintitle = false, + renotes = true, + replies = true, +) { + const author = { + link: `${config.url}/@${user.username}`, + email: `${user.username}@${config.domain}`, + name: user.name || user.username, + }; + + const profile = await UserProfiles.findOneByOrFail({ userId: user.id }); + + const searchCriteria = { + userId: user.id, + visibility: In(["public", "home"]), + }; + + if (!renotes) { + searchCriteria.renoteId = IsNull(); + } + + if (!replies) { + searchCriteria.replyId = IsNull(); + } + + const notes = await Notes.find({ + where: searchCriteria, + order: { createdAt: -1 }, + take: history, + }); + + const feed = new Feed({ + id: author.link, + title: `${author.name} (@${user.username}@${config.domain})`, + updated: notes[0].createdAt, + generator: "FrozenFriendsYume", + description: `${user.notesCount} Notes, ${ + profile.ffVisibility === "public" ? user.followingCount : "?" + } Following, ${ + profile.ffVisibility === "public" ? user.followersCount : "?" + } Followers${profile.description ? ` · ${profile.description}` : ""}`, + link: author.link, + image: await Users.getAvatarUrl(user), + feedLinks: { + json: `${author.link}.json`, + atom: `${author.link}.atom`, + }, + author, + copyright: user.name || user.username, + }); + + for (const note of notes) { + let contentStr = await noteToString(note, true); + let next = note.renoteId ? note.renoteId : note.replyId; + let depth = threadDepth; + while (depth > 0 && next) { + const finding = await findById(next); + contentStr += finding.text; + next = finding.next; + depth -= 1; + } + + let title = `${author.name} `; + if (note.renoteId) { + title += "renotes"; + } else if (note.replyId) { + title += "replies"; + } else { + title += "says"; + } + if (noteintitle) { + const content = note.cw ?? note.text; + if (content) { + title += `: ${content}`; + } else { + title += "something"; + } + } + + feed.addItem({ + title: title + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") + .substring(0, 100), + link: `${config.url}/notes/${note.id}`, + date: note.createdAt, + description: note.cw + ? note.cw.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "") + : undefined, + content: contentStr.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ""), + }); + } + + async function noteToString(note, isTheNote = false) { + const author = isTheNote + ? null + : await Users.findOneBy({ id: note.userId }); + let outstr = author + ? `${author.name}(@${author.username}@${ + author.host ? author.host : config.host + }) ${ + note.renoteId ? "renotes" : note.replyId ? "replies" : "says" + }:
` + : ""; + const files = + note.fileIds.length > 0 + ? await DriveFiles.findBy({ + id: In(note.fileIds), + }) + : []; + let fileEle = ""; + for (const file of files) { + if (file.type.startsWith("image/")) { + fileEle += `
`; + } else if (file.type.startsWith("audio/")) { + fileEle += `